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

30 KiB
Raw Blame History

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).