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