mirror of
https://github.com/newliver666/apk-reverse.git
synced 2026-09-28 21:56:59 +02:00
d0fe0ea337
An external review found the evidence record honest and the gates real, but raised three
concrete defects. Each is fixed at its cause rather than at its symptom, and the reasoning
is recorded in docs/tool-verification/REPO-DECISIONS.md.
build_scripts.py -- the audit reported 37 findings, most of them noise
It matched any `X:\...` literal, so `C:\Program Files\...`, `C:\Python314\...` and literal
placeholders all counted as machine-specific leftovers. A gate whose output is mostly noise
stops being read -- the same failure scan_leaks.py names when it refuses to exempt the
RFC 5737 ranges. Two explicit exemption families are added instead of a looser regex:
GENERIC_PATH_PREFIXES (install locations that exist on every host of that platform) and
`_leaks_nothing()` (also exempts an already-redacted path, since a placeholder or an
ellipsis means a normalisation pass already ran).
Two traps surfaced only because the change was tested rather than merely run. BARE_ABS stops
at whitespace, so `C:\Program Files\...` arrives truncated as `C:\Program` and a one-way
`startswith` misses the exemption; the comparison is now bidirectional under a minimum
length. And with a lookahead of only `[a-z0-9]` the optional `\\?` backtracks to empty, the
next character is the separator, it passes, and a real personal path is swallowed -- the
lookahead has to exclude the separator as well. Both were caught by the case table, not by
the audit run.
Result: 37 -> 0 findings, exit 2 -> exit 0. The real hits (`E:\apk-reverse\...`,
`E:\agb_apktool.jar`) were fixed in the documents, not by widening the list.
Three absolute paths on the published surface
EXTENSION-desensitization.md, EXTENSION-extraction-shell-bench.md and EXTENSION-vmp-diff.md
quoted a workbench root, a tool jar and an SDK location absolutely.
docs/tool-verification/ already states the rule -- a record may say a run happened at a
`tools/...` path, written relatively -- so the three files were made to match the rule they
were already under. The tool-jar and SDK cases are rephrased rather than placeholder-ised,
so no reader has to learn a new token.
check_budget.py -- blind to the half of the problem it was not measuring
The gate budgets the always-loaded part and could not see the corpus, where a new reference
costs exactly one index line: the corpus can double while the narrative count stays green.
Two note-level thresholds now report it. Notes, not failures -- a large corpus is a choice,
a growth nobody measured is the defect. The first run reports 47 references + 55 scripts =
102 files, 31,423 lines.
A cross-tool inconsistency this pass ran into, recorded rather than papered over
build_scripts.py now exempts any `<...>` placeholder; scan_leaks.py recognises a fixed token
list. An invented placeholder in a source comment passed the first and was read by the
second as the very personal name its `user_path_windows` rule exists to catch, turning
check_repo.py red. That is by design -- a strict list is what stops a real name hiding
behind a plausible-looking placeholder -- but the two tools disagree about what "already
redacted" means, and the disagreement stays invisible until someone writes a new token.
Rephrased here; a future pass may want one shared list.
Verification
- build_scripts.py: 37 -> 0 findings on a clean clone, exit 0.
- An 18-case table pins both directions, 0 failures: generic and already-redacted paths are
exempt, while a real workbench path, a real personal name after `C:\Users\`, a real POSIX
home and a real project path still report. It found two defects in the first
implementation.
- All four gates pass: check_repo 0 problem, check_refs 0 dangling / 0 warning,
check_budget 0 failure / 0 warning / 3 notes, build_scripts 0 findings.
294 lines
14 KiB
Markdown
294 lines
14 KiB
Markdown
# Extension verification — differential hardening for Dex-VMP
|
|
|
|
Covers the extension pass that added `references/vmp-differential-analysis.md` and
|
|
`scripts/vmp_diff_harness.py`. Strength labels as defined in this directory's README:
|
|
**observed** (exact command + output here), **inferred** (follows from observed facts,
|
|
step not executed), **unverified** (assumed or externally reported, not independently
|
|
confirmed).
|
|
|
|
Reference environment: Windows 11 host, Python 3.14.0, JDK 17.0.4.1
|
|
(`C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe`), Android build-tools 2.19 / D8
|
|
8.2.2-dev off-PATH. **No device and no network were involved in this pass** — unlike the
|
|
unpacking and rootdump passes, this one is deliberately machine-local, because the thing
|
|
worth measuring here is the comparison logic, not the platform.
|
|
|
|
**Headline: the local half of the route is measured and closes exactly; the
|
|
platform half was not exercised at all, and the cost table says why it cannot be.**
|
|
|
|
---
|
|
|
|
## 1. Environment discovery — observed, and one correction to `tools/_work/ENV.md`
|
|
|
|
`ENV.md` records `E:\tools\android-14\` as a complete build-tools directory. At pass time
|
|
it held **three** entries:
|
|
|
|
```
|
|
$ Get-ChildItem 'E:\tools\android-14\' -Name
|
|
apksigner.bat
|
|
lib
|
|
zipalign.exe
|
|
```
|
|
|
|
`aapt2.exe`, `d8.bat` and `dexdump.exe` were absent, so the `build` subcommand had no
|
|
toolchain. The complete build-tools tree was sitting unextracted in `E:\tools\bt.zip`:
|
|
|
|
```
|
|
$ (bt.zip entries) | Where-Object { $_.Name -match '^(aapt2|d8|dexdump|split-select)' }
|
|
android-14/aapt2.exe 4566296
|
|
android-14/d8.bat 3156
|
|
android-14/dexdump.exe 986392
|
|
android-14/split-select.exe 1589016
|
|
android-14/lib/d8.jar 14929869
|
|
```
|
|
|
|
After `Expand-Archive -Path 'E:\tools\bt.zip' -DestinationPath 'E:\tools\' -Force`:
|
|
|
|
```
|
|
$ E:\tools\android-14\aapt2.exe version
|
|
Android Asset Packaging Tool (aapt) 2.19-10229193
|
|
|
|
$ E:\tools\android-14\d8.bat --version
|
|
D8 8.2.2-dev (build facedf41bbd28b563d1e9e09c5f72d7c5ca598d8 from go/r8bot (luci-r8-custom-ci-focal-14-zjz7))
|
|
```
|
|
|
|
Two further tool facts worth recording, both **observed**:
|
|
|
|
- **The apktool jar the wrapper points at does not exist**, so `<tools>\bin\apktool.bat` (which is
|
|
`java -jar "<tools>\agb_apktool.jar" %*`) resolves to nothing. apktool is unavailable on this
|
|
machine, and a bare `apktool d` fails with a jar-not-found error rather than a
|
|
recognisable "tool missing" message.
|
|
- `javac` is on PATH only through the Oracle `javapath` forwarder; the real JDK is at
|
|
`C:\Program Files\Java\jdk-17.0.4.1`. The harness takes `--javac` explicitly for this
|
|
reason.
|
|
|
|
## 2. `build` — observed
|
|
|
|
```
|
|
$ python skills/apk-reverse/scripts/vmp_diff_harness.py build \
|
|
--build-tools E:\tools\android-14 --out tools/_work/t5-vmp/fixture \
|
|
--javac "C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe" --apk
|
|
javac: C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe
|
|
d8: E:\tools\android-14\d8.bat
|
|
aapt2: E:\tools\android-14\aapt2.exe
|
|
generated: ...\src\OpCoverBig.java (37196 B)
|
|
|
|
$ ... javac -g --release 11 -d ...\classes ...\OpCoverProbe.java ...\OpCoverBig.java
|
|
javac ok: 9 class files
|
|
|
|
$ E:\tools\android-14\d8.bat --min-api 26 --no-desugaring --output ...\dex <9 .class paths>
|
|
d8 ok: ...\dex\classes.dex (35028 B)
|
|
== ...\classes.dex (35028 bytes) ==
|
|
classes=9 methods_with_code=75 instructions=3065 desynced=0
|
|
opcode coverage: 218/224 (97.3%)
|
|
not emitted by this fixture (6):
|
|
0x09 move-object/16 move-object/16 (32x) needs both registers >= 256; the object
|
|
frame reaches 256 on the source side only, which d8 encodes as 0x08
|
|
0x1b const-string/jumbo needs a string_ids index > 65535, i.e. a fixture carrying
|
|
65536+ string constants
|
|
0x2a goto/32 needs a >32767-code-unit backward jump; javac refuses the method
|
|
first with 'code too large' (its own 64 KB per-method bytecode cap)
|
|
0xfd invoke-custom/range d8 emitted the 35c form for every lambda shape tried, including
|
|
a six-parameter one
|
|
0xfe const-method-handle no Java-language literal; reachable only by hand-written smali
|
|
or direct dex construction
|
|
0xff const-method-type same as 0xFE
|
|
|
|
$ E:\tools\android-14\aapt2.exe link --manifest ...\apk\AndroidManifest.xml -o ...\apk\base.apk
|
|
aapt2 ok: ...\probe.apk (20482 B, unsigned)
|
|
build rc=0
|
|
```
|
|
|
|
Structural facts behind those numbers: **0 desynced methods** (every `decode_all` walk
|
|
ended exactly on `insns_off + insns_size*2`, i.e. the format table and the emitted stream
|
|
agree), 3,065 instructions across 75 method bodies, 9 classes.
|
|
|
|
### 2a. The two toolchain traps this measured — observed
|
|
|
|
Both are encoded in the script so they are not re-derived:
|
|
|
|
- **`d8` refuses a non-existent `--output` directory.** First run:
|
|
`Error: Invalid output: ...\dex / Output must be a .zip or .jar archive or an existing
|
|
directory / Compilation failed` and rc=1. Same class of trap as `frida-dexdump -o`.
|
|
`build` creates the directory first.
|
|
- **`aapt2 link` needs `android.jar` for every `android:` attribute.** A manifest carrying
|
|
`android:versionCode` / `android:versionName` / `android:minSdkVersion` produced five
|
|
`error: attribute android:... not found` lines and rc=1. A manifest using only the
|
|
un-namespaced `package` attribute links cleanly with no `-I` flag at all — which is what
|
|
the harness ships, at the cost of an APK with no version attributes. That is acceptable
|
|
for a dex carrier and is stated in the output.
|
|
|
|
### 2b. `goto/32` is unreachable from Java — observed
|
|
|
|
Driving the generator to 33,000 loop-body statements to push the backward jump past the
|
|
20t range:
|
|
|
|
```
|
|
$ python tools/_work/t5-vmp/gen_big_probe.py 33000
|
|
generated ...\OpCoverBig.java: 34442 lines, 766468 bytes
|
|
javac rc=1
|
|
OpCoverBig.java:1427: error: code too large
|
|
public static int gotoFar(int x) {
|
|
1 error
|
|
```
|
|
|
|
The JVM's own 64 KB per-method bytecode limit fires before the dex branch-distance
|
|
threshold can be crossed. `goto/32` therefore cannot be covered by any javac-built
|
|
fixture, at any generator size — this is a structural ceiling, not a tuning problem.
|
|
|
|
## 3. Closed loop — observed, and this is the load-bearing result
|
|
|
|
`simulate` relabels every opcode through a bijection whose ground truth it writes to disk.
|
|
`compare` is then given only the two dex files and must re-derive that table.
|
|
|
|
```
|
|
$ python .../vmp_diff_harness.py simulate .../fixture/dex/classes.dex \
|
|
--out tools/_work/t5-vmp/sim.dex --seed 7 --table tools/_work/t5-vmp/truth.json
|
|
simulated: ...classes.dex -> ...sim.dex
|
|
mode=relabel, 3065 method body(ies)/instruction(s) touched (seed 7)
|
|
header recomputed: checksum_ok=True signature_ok=True
|
|
ground-truth table written to ...truth.json (compare must re-derive it)
|
|
|
|
$ python .../vmp_diff_harness.py compare .../fixture/dex/classes.dex tools/_work/t5-vmp/sim.dex
|
|
aligned=75 resized=0 stripped=0 missing=0 added=0
|
|
aligned slots compared: 3065
|
|
verdict: USABLE
|
|
(map: 218 rows, every one verdict=high)
|
|
compare rc=0
|
|
```
|
|
|
|
Scoring the derivation against the ground truth:
|
|
|
|
```
|
|
ground-truth bijection size : 256
|
|
opcodes the fixture emits : 218
|
|
compare rows : 218
|
|
compare high-confidence rows : 218
|
|
compare undetermined : 6
|
|
compare non-injective : 0
|
|
compare run verdict : usable
|
|
|
|
derived entries that are WRONG : 0 (none)
|
|
derived entries with no truth : 0 []
|
|
emitted opcodes NOT derived : 0 []
|
|
|
|
EXACT RECOVERY: 218/218 emitted opcodes (100.0%)
|
|
CLOSED-LOOP VERDICT: IDENTICAL
|
|
```
|
|
|
|
Why the denominator is 218 and not 256: the ground-truth bijection covers all 256 byte
|
|
values, but 32 of those are unallocated dex slots and 6 are opcodes this fixture never
|
|
emits — and an opcode that never appears leaves no known plaintext. The six are reported
|
|
as `undetermined` with their reasons, never guessed. **Every entry the run did produce was
|
|
correct, and every opcode that could be read was read.**
|
|
|
|
## 4. Negative shapes — observed
|
|
|
|
The decision table's failure branches were driven on purpose, because a detector that has
|
|
never been seen to fire is not a detector.
|
|
|
|
**Stub shape** (`--mode stub`: every body replaced by an equal-length `return-void` fill —
|
|
the extraction shape):
|
|
|
|
```
|
|
$ python .../vmp_diff_harness.py compare .../classes.dex tools/_work/t5-vmp/stub.dex --quiet
|
|
aligned=75 resized=0 stripped=0 missing=0 added=0
|
|
aligned slots compared: 3065
|
|
verdict: NOT-USABLE
|
|
! 1 private byte(s) are claimed by up to 218 original opcodes each. No injective
|
|
relabelling can do that: it is the signature of method bodies replaced by one
|
|
common stub, and any substitution read off such a pair describes the stub, not a
|
|
private opcode space.
|
|
rc=1
|
|
```
|
|
|
|
This is the failure mode worth reading twice. In the per-opcode direction the reading is
|
|
**perfect** — 218 rows, all `high`, zero conflicts — because every original opcode maps to
|
|
the same private byte. Only the reverse (`private byte → set of originals`) check exposes
|
|
it. An implementation without that check would have returned a confident, wholly
|
|
meaningless table, and the first version of this one did exactly that: measured before the
|
|
fix, the same input returned `verdict: PARTIALLY-USABLE` alongside a note reading
|
|
"0 of 218 opcodes saw more than one candidate byte" — a warning that contradicted itself.
|
|
|
|
**Length-changing shape** (`--mode shrink`: `insns_size` rewritten without moving bytes, a
|
|
deliberately inconsistent image that exists only to drive the detector):
|
|
|
|
```
|
|
$ python .../vmp_diff_harness.py compare .../classes.dex tools/_work/t5-vmp/shrink.dex --quiet
|
|
aligned=0 resized=75 stripped=0 missing=0 added=0
|
|
aligned slots compared: 0
|
|
verdict: NOT-APPLICABLE
|
|
! 75 method(s) kept a body but changed its length. Boundaries cannot be projected
|
|
across a length change; a private opcode stream with its own length table is not
|
|
reachable this way.
|
|
rc=1
|
|
```
|
|
|
|
Regression after both fixes: the relabel loop still reports `USABLE` and the closed-loop
|
|
check still reports `IDENTICAL` — recorded above, re-run after the verdict-logic change.
|
|
|
|
**Not driven:** the `stripped` shape (`code_off == 0`) needs a class-data rewrite rather
|
|
than a byte edit, so its detector path is **inferred** from the shape counters, not
|
|
observed firing. The counters themselves (`stripped=0` in every run above) are observed.
|
|
|
|
## 5. `emit-smali` — observed
|
|
|
|
```
|
|
$ python .../vmp_diff_harness.py emit-smali tools/_work/t5-vmp/sim.dex \
|
|
--table tools/_work/t5-vmp/cmp.json --class LOpCoverProbe --method arithInt
|
|
; instructions restored: 3065 ; unmapped-opcode stops: 0
|
|
; THIS IS A READING SKELETON, NOT ASSEMBLABLE SMALI.
|
|
...
|
|
.class public LOpCoverProbe;
|
|
.super Ljava/lang/Object;
|
|
.source "OpCoverProbe.java"
|
|
|
|
# ---- arithInt(II)I registers=4 insns_size=29
|
|
.method public static arithInt(II)I
|
|
.registers 4
|
|
# 0x0000 90000203 add-int
|
|
# 0x0004 d000e803 add-int/lit16
|
|
# 0x0008 d8000007 add-int/lit8
|
|
# 0x000c b130 sub-int/2addr
|
|
...
|
|
# 0x0038 0f00 return
|
|
.end method
|
|
```
|
|
|
|
Two checks that the restore is real rather than plausible: the stream decodes to the
|
|
**original fixture's** `arithInt` body (compare it against `OpCoverProbe.java`'s 17
|
|
operations), and the payload-bearing `switchPacked` method also restores cleanly —
|
|
`packed-switch` followed by its payload block, stepped over by its own layout rather than
|
|
mistaken for opcodes. `unmapped-opcode stops: 0` is the honest coverage measure, and it is
|
|
zero only because the table is complete for this fixture.
|
|
|
|
Three defects were found and fixed by this run, each one a silent-wrong-answer shape:
|
|
|
|
- `--class LOpCoverProbe;` matched **zero** classes because the filter accepted only one of
|
|
the three spellings (`LOpCoverProbe;` / `LOpCoverProbe` / `OpCoverProbe`) — output read
|
|
exactly like a class that is not in the file.
|
|
- `.source` printed `"<string_idx 34748 out of range>"`: `source_file_idx` sits at
|
|
`class_def_item + 16`, not `+ 20` (that is `annotations_off`).
|
|
- Methods rendered without access flags, so the skeleton was not readable as smali.
|
|
|
|
## 6. What was not done
|
|
|
|
- **No hardening platform was contacted.** The entire submission link of the chain is
|
|
`unverified`, and its cost estimate in the reference file is reasoning about how those
|
|
services are distributed (accounts, rate limits, download-page delivery, possible refusal
|
|
of a synthetic fixture), not a measured upload. This is the pass's largest gap and it is
|
|
not closeable from this machine.
|
|
- **No real VMP sample was used.** `tools/_work/bench/repos/DexPatcher/assets/ezAndroid.apk`
|
|
is recorded in `ENV.md` as a real Dex-VMP challenge sample and was **not** opened in this
|
|
pass; the differential route needs a *matching hardened counterpart of the same build*,
|
|
which no available artifact provides.
|
|
- **The engine-behaviour assumption is inferred**: that a real VMP substitutes per-opcode
|
|
at stable instruction length. `simulate` implements the weakest form of that assumption
|
|
deliberately — it is the only form this harness can check itself against.
|
|
- **Table stability across inputs is unverified.** Whether one fixture's table applies to
|
|
another build of the same app, or survives a vendor version change, was not tested and is
|
|
the reason the reference file says to re-derive per target.
|
|
- **The `stripped` detector path did not fire** (see §4).
|
|
- `--mode shrink` writes a structurally inconsistent dex by design; `dexutil.Dex.check()`
|
|
will not reject it because it does not range-check `insns` against the file. It is a
|
|
detector probe, is labelled as such in `--help`, and must not be treated as a build.
|