mirror of
https://codeberg.org/reverse-skill-lab/apk-reverse
synced 2026-11-15 20:44:22 +08:00
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.
540 lines
30 KiB
Markdown
540 lines
30 KiB
Markdown
# Extension verification — extraction shells and the Dex-VMP boundary (bench rows B3 / B4)
|
||
|
||
Covers benchmark rows **B3** (extraction-shell skeleton detection) and **B4** (Dex-VMP
|
||
boundary: refuse-to-repack and downgrade the deliverable). Strength labels are this
|
||
directory's own three tiers: **observed** (an exact command and its output are quoted
|
||
here), **inferred** (follows from observed facts; the step was not executed),
|
||
**unverified** (assumed or externally reported, not reproduced here).
|
||
|
||
Everything below was produced on this repository's Windows 11 host with the rootless
|
||
PC toolchain in `tools/_work/ENV.md`; the device results in §4 name a
|
||
`<DEVICE>` and use `<PKG>` placeholders for the target package.
|
||
|
||
## 0. Headline
|
||
|
||
| Question | Answer | Strength |
|
||
|---|---|---|
|
||
| Does `stub%` separate a skeleton copy from a genuine dex? | **Only when the shell writes bare `return-void`.** Three other measured emptying shapes score 0.0 %–2.3 %, at or below the *untouched* control (1.9 %) | observed |
|
||
| Was the pre-existing `trivial_ratio` detector correct? | **No — two defects**, one of which inverted the ranking and named a fully-nop-wiped skeleton the "most likely original" | observed |
|
||
| Is `repos/CyReverse`'s shipped "extraction shell" asset usable as a shell fixture? | **No.** The pair differs by six bytes and one body; neither is a shell | observed |
|
||
| Is `DexPatcher/assets/ezAndroid.apk` a Dex VMP? | **No.** It is a JNI-sinking target: 7 `native` declarations, an exported `Java_*` for each, 34,566 clean dalvik instruction decodes, zero structural errors | observed |
|
||
| Can a static dex probe decide VMP vs not? | **It can decide "not dex-VMP"** when the bodies decode cleanly and each native declaration has an export. It **cannot** decide whether the native island performs runtime hardening | observed (negative half) / unverified (positive half) |
|
||
| Does `dex_mem_scan.py` find and cut a dex out of a blob? | **Yes** — exact offset, no false hits in 2 MiB of noise, image accepted by the judge | observed |
|
||
| Does it reach a dex in a live process's anonymous mapping? | **Not measured here.** The fixture is built and the commands staged; the device was held by another row throughout | unverified |
|
||
|
||
One claim in this pass was **retracted**: §3.4 records a Dex-VMP verdict drawn from a
|
||
desynchronised decoder and the measurement that killed it.
|
||
|
||
---
|
||
|
||
## 1. B3 — the shipped CyReverse "extraction shell" assets are a 6-byte difference, not a shell
|
||
|
||
Source: `repos/CyReverse/app/src/main/assets/{classes3.dex, classes3_extracted.dex}`
|
||
(12,536 B each).
|
||
|
||
```
|
||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3
|
||
== dex dump validation: 2 file(s), 2 parse, 0 rejected ==
|
||
name size sha256[:12] ver cksum sig class noco code stub%
|
||
cyrev-extracted.dex 12536 c2d7345e955a 037 ok BAD 4 0 20 10.0%
|
||
cyrev-skeleton.dex 12536 ba9d5b120a3a 037 ok ok 4 0 20 10.0%
|
||
```
|
||
|
||
A byte diff of the pair (`tools/_work/bench/unpack/b3/analyze_pair.py`) locates every
|
||
difference:
|
||
|
||
```
|
||
$ python analyze_pair.py
|
||
len A=12536 len B=12536
|
||
first diff offset = 8 (0x8)
|
||
diff byte count = 6
|
||
diff ranges:
|
||
0x8 .. 0x8 (1 B) <- dex checksum (header offset 8)
|
||
0xa .. 0xb (2 B) <- dex signature, first half (offset 12 is 4 B wide)
|
||
0x121c .. 0x121c (1 B) <- INSNS of PluginClass->getString, units 0 and 2
|
||
0x121e .. 0x121e (1 B)
|
||
0x1220 .. 0x1220 (1 B)
|
||
```
|
||
|
||
Per-method census of the pair shows the "extracted" copy is the *degraded* one:
|
||
|
||
```
|
||
== cyrev-skeleton.dex == Lcom/cyrus/example/plugin/PluginClass;->getString insns=3 units=001a 0087 0011
|
||
== cyrev-extracted.dex == Lcom/cyrus/example/plugin/PluginClass;->getString insns=3 units=0000 0000 0000
|
||
```
|
||
|
||
`const-string v0, "…"; return-object v0` was overwritten with three `0x0000` units in
|
||
exactly one of the twenty bodies, and the header was re-fixed. The shipped
|
||
`classes3_extracted.dex` is **not** an extraction shell and **not** a recovered dex:
|
||
it is a single-body demonstration of body clearing, with 19 of 20 bodies identical to
|
||
the skeleton. Neither file can test extraction-shell routing — 10.0 % `stub%` on both
|
||
sides is a coincidence of the same body set, not a signal.
|
||
|
||
**Consequence for the benchmark row:** B3 cannot be run against this asset pair. The
|
||
skeleton measurement below had to be built from a real application dex instead.
|
||
|
||
## 2. B3 — measured `stub%` across every emptying shape, with a zero-change control
|
||
|
||
Fixture builder: `tools/_work/bench/unpack/b3/make_skeleton.py` derives each variant
|
||
from a real 982,480-byte application dex (`src-r2pay.dex`, extracted from
|
||
`tools/_work/bench/apks/r2pay-v1.0.apk`, sha256 `c89ff2364879…`). Every variant is
|
||
**byte-length preserving**: only the `insns` array of a `code_item` is rewritten and
|
||
the tail is nop-filled, so offsets, class table and string table are untouched and the
|
||
header is re-fixed with `dexutil.fix_dex_header` (signature first, checksum last).
|
||
|
||
```
|
||
$ python make_skeleton.py src-r2pay.dex skel
|
||
source: src-r2pay.dex
|
||
ctrl.dex fraction=0.00 mode=return emptied=0/5061
|
||
skel-25.dex fraction=0.25 mode=return emptied=1265/5061
|
||
skel-50.dex fraction=0.50 mode=return emptied=2531/5061
|
||
skel-75.dex fraction=0.75 mode=return emptied=3796/5061
|
||
skel-all.dex fraction=1.00 mode=return emptied=5061/5061
|
||
pruned.dex fraction=1.00 mode=pruned emptied=5061/5061
|
||
nopfilled.dex fraction=1.00 mode=nop emptied=5061/5061
|
||
allstub.dex fraction=1.00 mode=allstub emptied=5061/5061
|
||
throwstub.dex fraction=1.00 mode=throw emptied=4134/5061
|
||
```
|
||
|
||
Verdicts from `dex_dump_validate.py` over the whole set — the decisive matrix:
|
||
|
||
```
|
||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skelall
|
||
name size sha256[:12] ver cksum sig class noco code stub% erased% emptied%
|
||
allstub.dex 982480 c8d7ca8fb163 035 ok ok 647 250 5061 100.0% 0.0% 100.0%
|
||
ctrl.dex 982480 1b3a97896b40 035 ok ok 647 250 5061 1.9% 0.0% 1.9%
|
||
nopfilled.dex 982480 de92dcea4eaf 035 ok ok 647 250 5061 0.0% 100.0% 100.0%
|
||
pruned.dex 982480 58d7fa3dce48 035 ok ok 647 250 5061 93.2% 0.0% 93.2%
|
||
skel-25.dex 982480 63346d9b41f5 035 ok ok 647 250 5061 1.7% 0.0% 1.7%
|
||
skel-50.dex 982480 d84601b7aa47 035 ok ok 647 250 5061 1.7% 0.0% 1.7%
|
||
skel-75.dex 982480 64114285656d 035 ok ok 647 250 5061 2.1% 0.0% 2.1%
|
||
skel-all.dex 982480 6345daef0a3f 035 ok ok 647 250 5061 2.3% 0.0% 2.3%
|
||
throwstub.dex 982480 cab8fd51a743 035 ok ok 647 250 5061 1.9% 0.0% 1.9%
|
||
```
|
||
|
||
Read the `mode=` column against `stub%`:
|
||
|
||
| Emptying shape (what the shell leaves behind) | `stub%` | Separated from `ctrl` (1.9 %)? |
|
||
|---|---|---|
|
||
| bare `return-void` in every body (`allstub`) | **100.0 %** | yes, by 98 points |
|
||
| body truncated to `const/4 v0,#0; return v0` + nop fill (`pruned`) | 93.2 % | yes |
|
||
| nop fill, no return at all (`nopfilled`) | **0.0 %** | **no — scores *below* the untouched control** |
|
||
| `new RuntimeException; throw` stub (`throwstub`) | 1.9 % | **no — indistinguishable from control** |
|
||
| 25 % / 50 % / 75 % of bodies emptied with a legal minimal body | 1.7 / 1.7 / 2.1 % | **no** |
|
||
|
||
Three conclusions, all observed:
|
||
|
||
1. **The threshold is not a number, it is a shape.** `stub%` is bimodal — it is either
|
||
≈ control (blind) or ≈ 100 % (sighted). There is no intermediate band where a
|
||
threshold could sit, so the "tens of percent" calibration the reference file
|
||
describes has no measured support.
|
||
2. **Partial extraction is invisible.** `skel-75.dex` — three quarters of all bodies
|
||
emptied — reports 2.1 % against a 1.9 % control, a 0.2-point difference inside the
|
||
sampling noise of what a real app's own trivial bodies contribute.
|
||
3. **The two shapes that defeat it are the two that real shells prefer.**
|
||
`throw new RuntimeException` stubs are the classic 360/legu family shape, and a
|
||
nop-wiped body is what a shell writes when it clears the slot before decrypting.
|
||
|
||
The false-negative on `nopfilled` was a **defect, not a boundary**, and is fixed in §3.
|
||
The `throwstub` blind spot is a **boundary** and is documented as one.
|
||
|
||
## 3. B3 — defects found in `dex_dump_validate.py`, and the fix
|
||
|
||
### 3.1 Defect 1: a fully nop-filled body was classified `real`
|
||
|
||
`classify_body` skipped every `0x0000` unit and then fell through to `return "real"`
|
||
when nothing else was found. Measured consequence: `nopfilled.dex` — **all 5,061
|
||
bodies wiped** — reported `stub% = 0.0`, and the ranking named it the most likely
|
||
original:
|
||
|
||
```
|
||
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/skel/{nopfilled,ctrl}.dex
|
||
ranking (most likely original first):
|
||
1. nopfilled.dex stub%=0.0 cksum=ok sig=ok classes=647
|
||
…
|
||
verdict: nopfilled.dex is the most likely original (1 copy(ies) in this set)
|
||
```
|
||
|
||
An agent routing on that line patches a wiped skeleton. That is the failure the file
|
||
exists to prevent, so the defect is on the wrong side of "the tool still produces
|
||
output".
|
||
|
||
**Fix.** `classify_body` gained two classes and a wider body scan:
|
||
|
||
- `erased` — `insns_size > 0` with every unit `0x0000`. A real compiler never emits a
|
||
whole body of nops, so this is skeleton evidence.
|
||
- `minimal` — a body truncated to `const/4 vR,#0; return vR` (legal, and the shape the
|
||
detector is blind to by design). Reported separately so the blind spot is visible
|
||
rather than hidden inside `real`.
|
||
- The early-exit `meaningful > 1 → real` became `meaningful > 2`, so the two-unit
|
||
minimal body reaches its own verdict instead of being mislabelled.
|
||
|
||
`trivial_ratio` keeps its original definition so previously recorded numbers stay
|
||
comparable; `emptied_ratio = (stub + erased) / with_code` is the new skeleton signal,
|
||
and `rank_key` sorts on it **low-first** with `bodies_erased` as a tiebreak.
|
||
|
||
### 3.2 Defect 2 (retracted in-flight): `0xNN0E` is not malformed
|
||
|
||
An intermediate version of the fix classified a lone `0x020E` unit as `malformed`,
|
||
reading a non-zero high byte on a `0x0E` opcode as an illegal `return-void`. The
|
||
control dump proved otherwise — 82 of the untouched dex's own bodies are exactly that
|
||
shape — and `const/4 v0, #0; return v0` is a legal minimal non-void body. The class was
|
||
renamed `minimal`, its comment corrected, and the fixture extended with `pruned.dex`
|
||
to measure it deliberately (93.2 %, `stub%` sighted). Recorded here because the
|
||
mislabel would have taught the next reader that ordinary short methods are damage.
|
||
|
||
### 3.3 Post-fix behaviour, and no regression on the recorded fixture
|
||
|
||
`nopfilled.dex` is now ranked last and flagged:
|
||
|
||
```
|
||
8. nopfilled.dex stub%=0.0 erased%=100.0 cksum=ok sig=ok classes=647 <-- SKELETON: emptied%=100, extraction-shell shape
|
||
9. …
|
||
verdict: skel-25.dex is the most likely original (1 copy(ies) in this set)
|
||
```
|
||
|
||
The pre-existing acceptance fixture from `EXTENSION-unpacking.md` §2 still produces
|
||
its recorded verdict, with only the two new columns appended:
|
||
|
||
```
|
||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
|
||
== dex dump validation: 7 file(s), 4 parse, 3 rejected ==
|
||
01_original_shell.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4% 0.0% 3.4%
|
||
02_duplicate.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4% 0.0% 3.4%
|
||
03_broken_magic.dex 8923752 33eee53b1317 ? REJECTED: magic=b'XXXX'
|
||
04_tampered_body.dex 8923752 b89bc8cc73d9 035 BAD BAD 4 160 29 3.4% 0.0% 3.4%
|
||
05_truncated.dex 65536 e6687390a663 035 REJECTED: file_size=8923752 actual=65536
|
||
06_extracted_skeleton.dex 8923752 85e56fed1d56 035 ok ok 4 160 29 100.0% 0.0% 100.0%
|
||
07_tiny_garbage.dex 16 be45cb2605bf ? REJECTED: too small (16 B) to carry a dex header
|
||
verdict: 01_original_shell.dex is the most likely original (2 copy(ies) in this set)
|
||
```
|
||
|
||
Same winner, same three rejections, same dedupe group. The change is additive on that
|
||
fixture and corrective on the new one.
|
||
|
||
### 3.3b The ranking was still pointing at a modified image (found by independent review)
|
||
|
||
The first fix above added the `emptied_ratio` signal — and then **kept ranking on a
|
||
metric this same pass had just proved insensitive to skeletons**. Reproduced by
|
||
leading the review on the variant set:
|
||
|
||
```
|
||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skel
|
||
ranking (most likely original first):
|
||
1. skel-25.dex stub%=1.7 erased%=0.0 cksum=ok sig=ok classes=647
|
||
2. skel-50.dex stub%=1.7 erased%=0.0 cksum=ok sig=ok classes=647
|
||
3. ctrl.dex stub%=1.9 erased%=0.0 cksum=ok sig=ok classes=647
|
||
…
|
||
verdict: skel-25.dex is the most likely original (1 copy(ies) in this set)
|
||
```
|
||
|
||
`skel-25.dex` — a dex with **25 % of its bodies removed** — was named the most likely
|
||
original, and the genuine untouched control ranked third. The cause is the bimodality
|
||
of §2: inside the low band the ratio is not a signal, and 1.7 % versus 1.9 % is noise
|
||
(the extracted bodies are replaced by `const/4 + return`, which *lowers* the
|
||
`return-void` count relative to the control). An agent routing on that line adopts a
|
||
modified image as its baseline, and every later diff is then built on the wrong
|
||
artifact — the same class of failure as the defect in §3.1, reached by a different
|
||
path.
|
||
|
||
**Fix.** `rank_key` became three-level, with an explicit eviction level so that either
|
||
signal can disqualify an image instead of the two merely tying:
|
||
|
||
1. `emptied_ratio >= 0.5` sorts last. Nothing mostly emptied is a candidate however
|
||
else it scores. This level exists because the count below cannot see the
|
||
`return-void`-in-every-body skeleton: that image has `minimal = erased = 0`, so a
|
||
pure-count key ranked it **first** — measured, and it is why this level was added
|
||
rather than the count being promoted alone.
|
||
2. `bodies_minimal + bodies_erased` low-first. This orders the low band, where the
|
||
count is monotone: 82 untouched, then 1299 / 2524 / 3736 / 4946 at 25 / 50 / 75 /
|
||
100 %.
|
||
3. `emptied_ratio` low-first, then header verdicts, then name.
|
||
|
||
Result on the same command:
|
||
|
||
```
|
||
ranking (most likely original first):
|
||
1. ctrl.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||
2. only.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||
3. throwstub.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||
4. skel-25.dex stub%=1.7 erased%=0.0 min/erased=1299 cksum=ok sig=ok
|
||
5. skel-50.dex stub%=1.7 erased%=0.0 min/erased=2524 cksum=ok sig=ok
|
||
6. skel-75.dex stub%=2.1 erased%=0.0 min/erased=3736 cksum=ok sig=ok
|
||
7. skel-all.dex stub%=2.3 erased%=0.0 min/erased=4946 cksum=ok sig=ok
|
||
8. allstub.dex stub%=100.0 erased%=0.0 min/erased=0 cksum=ok sig=ok <-- SKELETON
|
||
9. pruned.dex stub%=93.2 erased%=0.0 min/erased=344 cksum=ok sig=ok <-- SKELETON
|
||
10. nopfilled.dex stub%=0.0 erased%=100.0 min/erased=5061 cksum=ok sig=ok <-- SKELETON
|
||
verdict: ctrl.dex is the best-supported candidate for the original (1 copy(ies) in this set)
|
||
```
|
||
|
||
The control wins and every skeleton sorts behind it. The verdict line now states the
|
||
key and its limits instead of declaring a winner outright, and phrases the result as
|
||
"best-supported candidate" with an instruction to confirm before patching. `--trim`,
|
||
`--json` and `--find` are unchanged (verified: the recorded fixture still returns
|
||
`01_original_shell.dex` first, `--json` only gained keys). Note the residual limit the
|
||
report prints rather than hides: **`throwstub.dex` still ranks third**, because no body
|
||
statistic here sees a `throw`-stub skeleton. That is a boundary, not a bug — it is
|
||
listed in §7 with the reproduction.
|
||
|
||
### 3.4 A retracted Dex-VMP verdict — the measurement that killed it
|
||
|
||
An intermediate workbench script of mine
|
||
(`tools/_work/bench/unpack/b4/vmp_probe.py`, `vmp_methods.py`) decoded instruction
|
||
widths from a hand-written table and reported:
|
||
|
||
```
|
||
code_items=22424 desync=1393 (6.21%) bad_op=0xf8 / 0xef / 0xea / …
|
||
```
|
||
|
||
I read that as "bodies present but not decodable → private opcodes → Dex VMP". **The
|
||
reading was wrong, and the error was my table's.** The official decoder settles it:
|
||
|
||
```
|
||
$ E:\tools\android-14\dexdump.exe -d tools/_work/bench/unpack/b4/orig/classes.dex
|
||
Processing 'orig\classes.dex'...
|
||
Opened 'orig\classes.dex', DEX version '035'
|
||
… 943,223 lines …
|
||
instruction lines (hex:...) : 348566
|
||
code items resolved ('insns size :') : 22424
|
||
code '(none)' : 1179
|
||
```
|
||
|
||
One of the methods my script called a desync decodes cleanly under `dexdump`:
|
||
|
||
```
|
||
--- androidx.appcompat.app.AlertController$ButtonHandler.<init>:(Landroid/content/DialogInterface;)V
|
||
insns size : 11 16-bit code units
|
||
1fd564: 7010 9b05 0100 |0000: invoke-direct {v1}, Landroid/os/Handler;.<init>:()V
|
||
1fd56a: 2200 040e |0003: new-instance v0, Ljava/lang/ref/WeakReference;
|
||
1fd56e: 7020 fb76 2000 |0005: invoke-direct {v0, v2}, Ljava/lang/ref/WeakReference;.<init>:(Ljava/lang/Object;)V
|
||
1fd574: 5b10 ed08 |0008: iput-object v0, v1, …$ButtonHandler;.mDialog:…;
|
||
1fd578: 0e00 |000a: return-void
|
||
```
|
||
|
||
My script consumed 9 of 11 units and stopped on `0xed`; `0xed` is a legal
|
||
`invoke-*/range` opcode. **The lesson is the one the reference file already states and
|
||
this pass then failed to apply: a hand-rolled opcode table is not a VMP detector, and
|
||
"my decoder desynchronised" is a statement about my decoder.** Any future
|
||
"rare-opcode density" metric must be validated against `dexdump` on the same file
|
||
before it is allowed to inform a verdict.
|
||
|
||
The corrected count of bodies that fail to decode as dalvik in this dex is **0**.
|
||
Correction confirmed by an independent path: `dexdump` emits 34,566 instruction lines
|
||
and reports no structural error for any of the 22,424 code items.
|
||
|
||
## 4. B4 — the Dex-VMP judgement chain, re-opened
|
||
|
||
### 4.1 Static findings (`observed`)
|
||
|
||
Target: `repos/DexPatcher/assets/ezAndroid.apk`, 2,984,841 B, sha256
|
||
`76e3fa8119cf8b7c…`, package `<PKG>` (the `ezAndroid` CTF sample shipped in DexPatcher).
|
||
|
||
```
|
||
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/b4/orig/classes.dex
|
||
classes=2905 methods_with_code=22424 stub%=1.9 cksum=ok sig=BAD
|
||
```
|
||
|
||
- **Native declarations: 7**, all in the app's own obfuscated classes, each with no
|
||
code (`code : (none)`), enumerated from `dexdump`:
|
||
`Lcom/example/ezandroid/C;->cf(Ljava/lang/String;I)Z`,
|
||
`Lcom/example/ezandroid/MU;->GMS(Ljava/lang/String;)Ljava/lang/String;`,
|
||
`Lcom/example/ezandroid/S;->Stringfjni()Ljava/lang/String;`,
|
||
`Lcom/example/ezandroid/S;->onCreate`, `Lcom/example/ezandroid/S2;->onCreate`,
|
||
`Lcom/example/ezandroid/S$1;->onClick`, `Lcom/example/ezandroid/S2$1;->onClick`.
|
||
- **Two JNI exports** in `lib/arm64-v8a/libezandroid.so` (206,904 B, aarch64):
|
||
`Java_com_example_ezandroid_S_Stringfjni` @ `0xea6c` (184 B) and
|
||
`Java_com_example_ezandroid_MainActivity_stringFromJNI` @ `0xe91c` (184 B).
|
||
Both are thin JNI wrappers that construct a `std::string` and dispatch through a
|
||
virtual call — the body of the check is behind the vtable, not in the wrapper.
|
||
- **No `JNI_OnLoad` and no `RegisterNatives` symbol** anywhere in the library; the
|
||
string `JNI_OnLoad` does not occur in the file at all.
|
||
- **`assets/` holds six small opaque files** (`0OooOO` 118 B, `O0ooOO` 126 B,
|
||
`oo0Ooo` 86 B, `ooO0oo` 132 B, `OOoo0O` 398 B, `OOooO0` 260 B). Each begins with a
|
||
three-unit header (`05 00 38 00 02 00 …`) followed by 2-byte-aligned units.
|
||
- **No dex string references those six filenames.** Grep over the full 943,223-line
|
||
`dexdump` output for `0OooOO|O0ooOO|oo0Ooo|ooO0oo|OOoo0O|OOooO0|assets/` returns
|
||
**0 hits**.
|
||
|
||
### 4.2 Verdict: JNI sinking, not Dex VMP (`observed`)
|
||
|
||
The reference file's Dex-VMP test is "the dump's method bodies are intact but decode
|
||
as private opcodes — rare-opcode density off the charts, unknown opcode slots". On this
|
||
target **zero method bodies fail to decode**, and every `native` declaration has a
|
||
matching exported `Java_*`. The dex is not carrying an encoded program; the code left
|
||
the dex for the `.so`, which is `java2c-and-jni-sinking.md`'s territory, not this
|
||
file's. The two must not be conflated: an agent that reads "bodies decode as nonsense"
|
||
here will go hunting a private disassembler for code that is ordinary ARM64 in a
|
||
206 KB library.
|
||
|
||
The six `assets/` files are a separate, **unresolved** observation: they are not
|
||
referenced by any dex string, so they cannot be load-bearing at the dex layer. Whether
|
||
the native island reads them by constructing the path at runtime, and what they
|
||
contain, is **unverified** here — it needs a dynamic run (§4.3), not more static
|
||
searching.
|
||
|
||
### 4.3 What static analysis structurally cannot see (the B4 boundary)
|
||
|
||
`java2c`'s independent pass reached the same classification on this sample and recorded
|
||
that the hardening takes effect **at runtime only**. That is the general shape, and it
|
||
is the half of B4 that no dex probe can settle:
|
||
|
||
| Question | Answerable statically? | Why |
|
||
|---|---|---|
|
||
| Are the bodies real dalvik or private opcodes? | yes | `dexdump` decodes them or does not; measured 0 failures here |
|
||
| Did the code leave the dex for native? | yes | `native` access flags + `Java_*` exports / `JNI_OnLoad` |
|
||
| Does the native island check the signature / root / debugger? | **no** | the logic is ARM64 behind a vtable; the check may not exist in any dex |
|
||
| Do those `assets/` blobs get decrypted and used? | **no** | the path may be assembled at runtime; grep for the literal names finds nothing |
|
||
| Does a repack actually fail, and why? | **no** | needs the repack installed and run against a control (G4) |
|
||
|
||
**Rule this pass proposes for the reference file:** a static dex probe may return
|
||
"this is *not* dex-VMP" when the bodies decode cleanly and every native declaration has
|
||
an export. It may **not** return "this is not hardened" from the same evidence —
|
||
those are different claims, and the second one is the misdiagnosis
|
||
`java2c-and-jni-sinking.md` was written to stop.
|
||
|
||
### 4.4 Repack control — `observed` up to the install, `unverified` beyond
|
||
|
||
G4 requires a zero-change control through the same pipeline. Built with the
|
||
repository's own repacker against an explicit toolchain (this host has no uber-apk-signer;
|
||
`keytool` lives in the JDK that `ENV.md` does not list):
|
||
|
||
```
|
||
$ python skills/apk-reverse/scripts/repack.py \
|
||
--apk tools/_work/bench/unpack/b4/ezAndroid.apk \
|
||
--out tools/_work/bench/unpack/b4/ez-ctrl.apk \
|
||
--ks tools/_work/bench/unpack/b4/ctrl.jks --signer apksigner \
|
||
--apksigner E:\tools\android-14\apksigner.bat \
|
||
--zipalign E:\tools\android-14\zipalign.exe \
|
||
--keytool "C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe"
|
||
[in ] tools/_work/bench\unpack\b4\ezAndroid.apk (2984841 bytes)
|
||
[zip] - META-INF/1N0R.SF / 1N0R.RSA / MANIFEST.MF (signature artifact)
|
||
[zip] unsigned written: 2828919 bytes
|
||
[zip] AndroidManifest.xml STORED offset=0x31
|
||
[zip] classes.dex 3960320 bytes method=8 crc=037e9cb3
|
||
[zip] resources.arsc STORED offset=0x202f14 aligned
|
||
[sign] route: apksigner
|
||
[verify:zipalign] rc=0
|
||
[verify:v2block] present
|
||
[cert] SHA1 C9:89:C5:75:A4:83:AE:E0:A6:4B:F4:F6:0D:E3:D2:73:C2:8B:65:69
|
||
[result] OK
|
||
```
|
||
|
||
Zero-change repack, v2 block present, alignment verified, `classes.dex` carried through
|
||
at its original CRC (`037e9cb3`). **Install and launch on `<DEVICE>` are `unverified`
|
||
in this record**: the shared device was held by a parallel benchmark row (`B1 L1 repack
|
||
control + patched install`, `B2 L3 frida`) for the whole window available to this pass.
|
||
No claim is made here about whether the control launches; the artifact and the command
|
||
above are the reproducible half, and the device half is the open item.
|
||
|
||
Consequently **"repacking is truly refused on this target" is `inferred`, not
|
||
`observed`** — it follows from the native island being the only home of the app's own
|
||
logic (`Stringfjni`, `cf`, `GMS` are all native declarations), which makes a
|
||
dex-level patch unable to reach the behaviour, but no device run confirms a refusal.
|
||
The honest deliverable form for this target is the G1 row **annotated analysis report
|
||
with a stated boundary**, with `lsposed_scaffold.py` / `frida_rpc_serve.py` named as
|
||
the routes *if* a device run shows the build is refused.
|
||
|
||
## 5. `dex_mem_scan.py` — the cut half is measured, the in-process half is not
|
||
|
||
The script's job splits cleanly into *find-and-cut* (locate `dex\n03x` in a blob and
|
||
write exactly the declared `file_size`) and *reach the blob at all* (do it against a
|
||
live process's anonymous mappings through `/proc/<pid>/mem`). Only the first half was
|
||
measurable in this pass, and it was measured with a synthetic high-entropy capture
|
||
built to have the shape a heap buffer presents:
|
||
|
||
```
|
||
$ python … # 2 MiB of random bytes with a 4,124-byte dex written at offset 0x9a000
|
||
fixture dex: 4124 bytes, magic=b'dex\n039\x00'
|
||
capture written: …/memscan/anonregion.bin 2097152 bytes dex at 0x9a000
|
||
|
||
$ python skills/apk-reverse/scripts/dex_mem_scan.py …/anonregion.bin --dump …/scanout
|
||
== dex memory scan: 1 capture(s), 1 hit(s) ==
|
||
capture offset ver file_size classes state
|
||
anonregion.bin 0x9a000 039 4124 1 ok
|
||
wrote …/scanout/anonregion+0x0009a000.dex
|
||
|
||
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/scanout
|
||
name size sha256[:12] ver cksum sig class noco code stub%
|
||
anonregion+0x0009a000.dex 4124 d3dedcd2b75e 039 ok ok 1 0 5 0.0%
|
||
```
|
||
|
||
Exact offset, zero false hits in 2 MiB of noise, the extracted image byte-identical
|
||
by construction and accepted by the judge including its checksum and signature. That
|
||
is the **whole find-and-cut path, observed** — the halves compose correctly.
|
||
|
||
**What is not measured.** The live-process half: the fixture that would exercise it is
|
||
built and staged (§6), but the shared device was held by a parallel benchmark row for
|
||
the entire window this pass had, so `dex_mem_scan.py` has still never been run against
|
||
a real anonymous mapping here. The one related number that exists is from the earlier
|
||
root-dump pass, and it is a **zero**: 142 anonymous regions scanned, no hits, against
|
||
17 dex images found in *named* ART mappings with no search at all. A zero-hit scan is
|
||
weak evidence for the reasons listed in the script's own header, and the honest
|
||
summary is "the finder works on a shape that looks right; its in-process reach is
|
||
unverified".
|
||
|
||
To close it (all commands staged, none run in this pass):
|
||
|
||
```
|
||
adb push tools/_work/bench/unpack/memscan/out2/memfixture.jar /data/local/tmp/
|
||
adb push tools/_work/bench/unpack/b3/src-r2pay.dex /data/local/tmp/payload.dex
|
||
adb shell 'su -c "CLASSPATH=/data/local/tmp/memfixture.jar app_process /system/bin \
|
||
com.revprobe.memfixture.Main /data/local/tmp/payload.dex"' &
|
||
# the fixture prints its own pid, truncates the on-disk payload so the heap copy is the
|
||
# only remaining source, and writes /data/local/tmp/memfixture.maps
|
||
adb shell 'su -c "grep -c . /proc/<pid>/maps"' # confirm cheap maps first
|
||
adb shell 'su -c "grep -v \"/\" /proc/<pid>/maps"' # the anonymous set to export
|
||
```
|
||
|
||
Note the fixture deliberately truncates its source file: a scan that finds the dex
|
||
after that point cannot have found a file copy, which is what makes the result
|
||
attributable to memory rather than to disk.
|
||
|
||
## 6. Not done — and why
|
||
|
||
- **No device run of the B4 control build.** The shared device was locked by another
|
||
benchmark row for the whole window; `devlock.py acquire unpack` timed out twice
|
||
(900 s and 1,800 s waits). Install, launch, time-to-death and refusal/success are
|
||
therefore `unverified`.
|
||
- **No in-process run of `dex_mem_scan.py`.** The fixture is built and staged
|
||
(`tools/_work/bench/unpack/memscan/`, §5), the arm64 `app_process` route is chosen
|
||
because this host has **no `android.jar`** and therefore cannot compile a real APK
|
||
at all, and the closing commands are listed — but the device was never available, so
|
||
the anonymous-mapping hit rate remains unmeasured. The only related measurement is
|
||
the earlier pass's **0 of 142** anonymous regions.
|
||
- **Whether the `assets/` blobs are live.** Needs a dynamic run (read the files out of
|
||
the process after the native island runs, or hook the path construction).
|
||
- **No end-to-end FART splice.** The reference file's FART loop needs a live
|
||
extraction-shell target; nothing in `repos/` is one (§1), and building an actively
|
||
resisting shell is out of scope for this pass.
|
||
- **A real APK build on this host.** The build-tools directory ships `d8`, `aapt2`,
|
||
`apksigner` and `zipalign` but **no `android.jar`**, and no `android.jar` exists
|
||
anywhere on the machine (searched the tool directories, `C:\Program Files`, and
|
||
the SDK path). Any
|
||
`javac` against `android.*` fails with "package android.os does not exist". APKs can
|
||
still be *repacked* here (`repack.py` does not recompile), which is why §4.4 works
|
||
and the self-built-APK route in the task brief does not.
|
||
|
||
## 7. Reproduction
|
||
|
||
```
|
||
python tools/_work/bench/unpack/b3/make_skeleton.py tools/_work/bench/unpack/b3/src-r2pay.dex tools/_work/bench/unpack/b3/skel
|
||
python tools/_work/bench/unpack/b3/analyze_pair.py
|
||
python tools/_work/bench/unpack/b3/crosscheck.py tools/_work/bench/unpack/b3/skel/*.dex
|
||
python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skelall
|
||
python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
|
||
python tools/_work/bench/unpack/b4/vmp_boundary.py tools/_work/bench/unpack/b4/orig/classes.dex
|
||
E:\tools\android-14\dexdump.exe -d tools/_work/bench/unpack/b4/orig/classes.dex
|
||
python skills/apk-reverse/scripts/dex_mem_scan.py tools/_work/bench/unpack/memscan/anonregion.bin --dump tools/_work/bench/unpack/memscan/scanout
|
||
```
|
||
|
||
Raw captures live beside the fixtures: `evidence/prefix-validate.txt` (before the fix,
|
||
including the inverted ranking) and `evidence/postfix-validate.txt` (after). The
|
||
`memscan/` tree carries the staged (unrun) in-process fixture: `plain/com/revprobe/memfixture/Main.java`
|
||
(no-`android.jar` form), `build.ps1` (the APK build that cannot complete here, kept as
|
||
the record of *why*), and `out2/{classes.dex,memfixture.jar}`.
|
||
|
||
To re-verify the fix from scratch, the two commands that matter are
|
||
`dex_dump_validate.py …/skelall` (expect `nopfilled.dex` flagged, not ranked first) and
|
||
`dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'` (expect
|
||
`01_original_shell.dex` still the winner).
|