reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user