Files
reverse-skills-max/vendor-new/apk-reverse/docs/tool-verification/EXTENSION-extraction-shell-bench.md

540 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 tools/_work/bench/unpack/b3/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 tools/_work/bench/unpack/b3/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).