mirror of
https://codeberg.org/reverse-skill-lab/apk-reverse
synced 2026-11-15 20:34:29 +08:00
Benchmark pass: measure the documented routes, close five tool gaps, correct four claims
Public targets under the existing routes, recorded per row in tests/benchmark.md with each row's own strength label: the MASTG crackmes (equal-length patch -> repack -> re-sign with a zero-change control, and the anti-debug race with its time-to-death baseline), DexPatcher's ezAndroid sample, two real split APK sets, and a self-built extraction-skeleton set. Hardening-shape discrimination now separates Java2C from an extraction shell, a VMP and JNI sinking. New: java2c_probe.py and java2c-and-jni-sinking.md; split-apk.md with repack.py growing set inventory, unified re-signing and merge; protobuf_decode_raw.py cross-checked against the official runtime; vmp_diff_harness.py and its cost table; kernelsu_syscall_mask.py with three kernel-side templates; stalker_trace.js gains Stalker.exclude() and a zero-event warning. Corrections, each because a measurement contradicted a recorded conclusion: the trivial-body ratio is bimodal rather than a threshold and the old threshold claim was deleted; a Dex-VMP verdict was withdrawn after dexdump contradicted a hand-written opcode table; the assumption that Stalker.exclude() fixes zero-event delivery is disproved (it keeps the process alive, nothing more); and a repack.py signing-path claim expired mid-pass when a second writer added one. Emulated and kernel-side routes are labelled unverified where no toolchain or device could run them; each evidence file states what was not run.
This commit is contained in:
@@ -0,0 +1,638 @@
|
||||
# Extension verification — benchmark B1/B2 against OWASP MASTG Crackmes
|
||||
|
||||
Covers the pass that turned the `tests/benchmark.md` rows **B1** (equal-length dex patch + repack +
|
||||
re-sign installability, gates G2/G4) and **B2** (anti-debug / self-destruct race) from `pending`
|
||||
into measured results.
|
||||
|
||||
Strength labels follow `docs/tool-verification/README.md`: **observed** (exact command and its
|
||||
output reproduced here), **inferred** (follows from observed facts, the step itself not executed),
|
||||
**unverified** (assumed or reported and not independently confirmed).
|
||||
|
||||
Reference environment: Windows 11 host, Python 3.14.0, host frida 16.7.19 (frida-tools 13.7.1),
|
||||
JDK 17.0.4.1 at `C:\Program Files\Java\jdk-17.0.4.1` (not on `PATH`), `apksigner.bat` /
|
||||
`zipalign.exe` at `E:\tools\bin`. Device: **`<DEVICE>`**, Android 11 / API 30, arm64-v8a, root via
|
||||
`su`, Magisk denylist `enforced` with an **empty** list (no app is root-hidden). Package under test:
|
||||
**`<PKG>`**.
|
||||
|
||||
All device operations ran under the shared device lock (`tools/_work/devlock.py`).
|
||||
|
||||
---
|
||||
|
||||
## 0. Headline
|
||||
|
||||
- **B1 is `observed` and positive.** An equal-length dex edit (2 bytes of a conditional branch
|
||||
replaced by `nop`s), through `dex_patch_bytes.py` → `repack.py --no-sign` → `zipalign` →
|
||||
`apksigner` → root `pm install`, installs and **demonstrably changes the app's behaviour on
|
||||
screen**: the original and a zero-change control both show a blocking `Root detected!` dialog, the
|
||||
patched build reaches `MainActivity` with the dialog gone.
|
||||
- **`repack.py`'s default signing path did not work when this pass started, and the blocker was not
|
||||
`apksigner`.** `apksigner`/`zipalign` are both present and both work; at that moment `repack.py`
|
||||
signed only through an `uber-apk-signer` jar, which is not installed. **The repository changed
|
||||
mid-pass** — another writer landed a `sign_with_apksigner()` route — and re-running the same
|
||||
command then produced a fully signed, `[result] OK` APK in one invocation with byte-identical
|
||||
output to the manual route (`sha256 ad6a51ce…7e56`). Both states are recorded below, because the
|
||||
stale state is what `tools/_work/ENV.md` predicted and the new state is what a reader will meet.
|
||||
This is also a reminder that a "the tool cannot do X" finding on a shared worktree has an expiry
|
||||
date.
|
||||
- **B2 is a split result.** The two scripts run and the probe writes its bytes (0.04–0.05 s after
|
||||
resume, gated on `PATCHED`), so the *tooling* is verified. But the **patch does not prevent the
|
||||
target's termination**: `libfoo.so` aborts through `goodbye()+12` at **3.22 s** after resume, and
|
||||
three patched sites including `goodbyev` itself do not change that. Neutralising the obvious death
|
||||
site is **not sufficient** on this target.
|
||||
- **A finding that changes the whole B2 setup:** on this device the **unmodified, original APK
|
||||
already reports `Rooting or tampering detected.`** and its tamper path runs before anything is
|
||||
attached. L3's native CRC self-check fails against the pristine community APK on Android 11 /
|
||||
arm64-v8a, so the "pristine baseline" the row assumed does not exist here.
|
||||
|
||||
---
|
||||
|
||||
## 1. Gate G2 and G4 — environment and control (`observed`)
|
||||
|
||||
`scripts/doctor.py`:
|
||||
|
||||
```
|
||||
platform : Windows 11 / AMD64
|
||||
python : 3.14.0
|
||||
[OK ] static (dex/zip/strings) [OK ] repack + sign
|
||||
[OK ] native ELF patching [-- ] jadx decompile
|
||||
[-- ] apktool unpack [OK ] device work
|
||||
[OK ] root on device [OK ] frida dynamic
|
||||
[OK ] java 17.0.4.1 [OK ] adb 1.0.41 [OK ] frida 16.7.19
|
||||
[-- ] apktool not on PATH
|
||||
[-- ] unzip not on PATH
|
||||
java jars found near this skill : (none)
|
||||
device : <DEVICE> arm64-v8a Android 11 (sdk 30) root: yes
|
||||
clock skew: 3.06 s
|
||||
```
|
||||
|
||||
The `(none)` on the jar line matters for B1: no `baksmali`/`smali`/`uber-apk-signer` jar exists, so
|
||||
every signing route that goes through a jar is unavailable before a single command is run.
|
||||
|
||||
**Keystore and tool locations** (recorded because `repack.py` resolves both off-`PATH`):
|
||||
|
||||
```
|
||||
keytool : C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe
|
||||
jarsigner : C:\Program Files\Java\jdk-17.0.4.1\bin\jarsigner.exe
|
||||
apksigner : E:\tools\bin\apksigner.bat
|
||||
zipalign : E:\tools\bin\zipalign.exe
|
||||
```
|
||||
|
||||
`repack.py`'s `sibling_tool()` cannot find `keytool` from `C:\Program Files\Common Files\Oracle\Java\javapath\java.exe`,
|
||||
because that directory holds only forwarders. Passing `--keytool` explicitly is required.
|
||||
|
||||
---
|
||||
|
||||
## 2. B1 — L1 equal-length dex patch, full chain (`observed`)
|
||||
|
||||
### 2.1 Source facts
|
||||
|
||||
```
|
||||
$ python -c "... load_dex('UnCrackable-Level1.apk') ..."
|
||||
classes.dex 5528 bytes
|
||||
magic dex\n035\0
|
||||
checksum field 7fb7d9fa
|
||||
adler32(d[12:]) 7fb7d9fa -> matches
|
||||
signature field b7fafe72cb521450c4470043caa332da61d1bec7
|
||||
sha1(d[32:]) 5253237b04c2d002967bae8ef81daae08550c90c
|
||||
-> MISMATCH
|
||||
```
|
||||
|
||||
Two facts that a reader needs before trusting the tool's output:
|
||||
|
||||
- The **shipped dex's `signature` field is stale** while its `checksum` is correct. `dex_patch_bytes.py`
|
||||
prints `header before: checksum_ok=True signature_ok=False` and explains it. If the tool had
|
||||
written the two fields in the wrong order, that starting state would have hidden the bug.
|
||||
- The class that owns the behaviour is
|
||||
`Lsg/vantagepoint/uncrackable1/MainActivity;.onCreate(Landroid/os/Bundle;)V`, and the gate is the
|
||||
first arm of a three-way root `OR`:
|
||||
|
||||
```
|
||||
0x9c8 invoke-static Lsg/vantagepoint/a/c;.a()Z
|
||||
0x9ce move-result
|
||||
0x9d0 39000e00 if-nez v0 -> 0x9ec <-- the edit site
|
||||
0x9d4 invoke-static Lsg/vantagepoint/a/c;.b()Z
|
||||
```
|
||||
|
||||
`0x9ec` is `const-string "Root detected!"` followed by the private `a(String)` that builds the
|
||||
blocking dialog. **The branch structure, not the method name, was read before editing** — the edit
|
||||
target is the arm whose *fall-through* continues into the next check.
|
||||
|
||||
### 2.2 The patch
|
||||
|
||||
```json
|
||||
[{
|
||||
"name": "l1-first-root-check-fallthrough",
|
||||
"class": "Lsg/vantagepoint/uncrackable1/MainActivity;",
|
||||
"method": "onCreate", "desc": "(Landroid/os/Bundle;)V",
|
||||
"match": {"kind": "if-testz", "bytes": "39000e00"},
|
||||
"expect_next": {"kind": "invoke", "target_class": "Lsg/vantagepoint/a/c;",
|
||||
"target_method": "b"},
|
||||
"replace": {"kind": "nops"}
|
||||
}]
|
||||
```
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_patch_bytes.py tools/_work/bench/l1l3/dex/classes.dex \
|
||||
--spec tools/_work/bench/l1l3/l1_root.spec.json --dry-run
|
||||
-- [l1-first-root-check-fallthrough] Lsg/vantagepoint/uncrackable1/MainActivity;.onCreate(...)V
|
||||
site 0x9d0 0x9d0: 39000e00 if-nez v0 -> 0x9ec
|
||||
(before) 0x9c8: 710027000000 invoke-static Lsg/vantagepoint/a/c;.a()Z
|
||||
(before) 0x9ce: 0a00 move-result
|
||||
-> 0x9d0: 00000000 if-nez
|
||||
(after) 0x9d4: 710028000000 invoke-static Lsg/vantagepoint/a/c;.b()Z
|
||||
(after) 0x9da: 0a00 move-result
|
||||
polarity: expect_next satisfied
|
||||
== dry run: nothing written
|
||||
```
|
||||
|
||||
`expect_next` is what pins the polarity: the instruction after the branch had to be the *next* root
|
||||
check, so the fall-through is the "continue" side. Applied:
|
||||
|
||||
```
|
||||
== header checksum 0x7fb7d9fa -> 0x86e1d928
|
||||
signature b7fafe72cb521450 -> eb632328fd1ba03c
|
||||
self-verify: checksum_ok=True signature_ok=True
|
||||
== wrote classes.patched.dex (5528 bytes, delta 0)
|
||||
```
|
||||
|
||||
**The header order question, answered by byte comparison rather than by the tool's own report**
|
||||
(`observed`): the diff of the whole file against the source is exactly the two fields plus the patch
|
||||
site.
|
||||
|
||||
```
|
||||
sizes 5528 5528
|
||||
diff offsets raw: ['0x8','0xa','0xb','0xc'...'0x1f', '0x9d0','0x9d2']
|
||||
site 0x9d0 orig 39000e00 patched 00000000
|
||||
8..12 fad9b77f -> 28d9e186 (checksum, bytes 8..12)
|
||||
12..32 b7fafe72cb521450c4470043caa332da61d1bec7
|
||||
-> eb632328fd1ba03c7ebc6df181e0708a761aa2e3 (signature, bytes 12..32)
|
||||
selfverify checksum True signature True
|
||||
```
|
||||
|
||||
Recomputing `zlib.adler32(d[12:])` and `hashlib.sha1(d[32:])` **on the written file** reproduces both
|
||||
header fields, which is only possible if the signature was computed before the checksum. The patch
|
||||
owns 4 bytes and the header owns 24; nothing else moved.
|
||||
|
||||
### 2.3 Repack — where the documented pipeline actually fails
|
||||
|
||||
`repack.py`'s default signing route:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
|
||||
--dex classes.dex=classes.patched.dex --out out/l1_patched.apk \
|
||||
--workdir out --ks out/bench.keystore \
|
||||
--keytool ".../jdk-17.0.4.1/bin/keytool.exe" \
|
||||
--jarsigner ".../jdk-17.0.4.1/bin/jarsigner.exe" \
|
||||
--apksigner E:\tools\bin\apksigner.bat --zipalign E:\tools\bin\zipalign.exe
|
||||
[zip] - META-INF/CERT.RSA (signature artifact)
|
||||
[zip] - META-INF/CERT.SF (signature artifact)
|
||||
[zip] - META-INF/MANIFEST.MF (signature artifact)
|
||||
[zip] + classes.dex (5528 bytes <- .../classes.patched.dex)
|
||||
[zip] unsigned written: 60967 bytes
|
||||
[ks] generated E:\...\out\bench.keystore (alias=apkreverse)
|
||||
error: signer jar not found: uber-apk-signer.jar
|
||||
pass --signer-jar <path>, or set APK_SIGNER_JAR.
|
||||
Any zipalign+apksigner based signer works here.
|
||||
[exit code: 1]
|
||||
```
|
||||
|
||||
Two separate facts, and the second is the one worth recording:
|
||||
|
||||
1. `--signer-jar` defaults to `uber-apk-signer.jar` and no such jar exists here, so the default run
|
||||
cannot sign. (`ENV.md` predicted this.)
|
||||
2. **The error message's second line is misleading in this environment.** "Any zipalign+apksigner
|
||||
based signer works here" is true, but `repack.py` has no code path that *uses* a plain
|
||||
`zipalign`+`apksigner` pair for signing — `sign_apk()` only ever invokes
|
||||
`java -jar <signer_jar>`. `--apksigner`/`--zipalign` are used for **verification only**. So the
|
||||
documented Route B works, but only if the caller performs the sign step outside `repack.py`.
|
||||
The `--no-sign` flag is the supported seam.
|
||||
|
||||
Working route, both builds through the **same** pipeline:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
|
||||
--dexdir tools/_work/bench/l1l3/dex_orig \
|
||||
--out out/l1_control_unsigned.apk --workdir out/ctrl --no-sign
|
||||
[zip] unsigned written: 60967 bytes
|
||||
[zip] resources.arsc STORED offset=0xd4d4 aligned
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
[result] UNSIGNED (--no-sign): good for inspection, not installable as-is
|
||||
|
||||
$ (same, with --dex classes.dex=classes.patched.dex) -> out/l1_patched_unsigned.apk
|
||||
[zip] resources.arsc STORED offset=0xd4d4 aligned
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
```
|
||||
|
||||
`AndroidManifest.xml` is reported `STORED ... NOT ALIGNED`, which is correct and not a defect: the
|
||||
4-byte alignment requirement is `resources.arsc` (and uncompressed `lib/*.so`), not the manifest.
|
||||
|
||||
Signing, explicitly, both builds, same key:
|
||||
|
||||
```
|
||||
$ E:\tools\bin\zipalign.exe -p -f 4 l1_control_unsigned.apk l1_control_aligned.apk ; rc=0
|
||||
$ E:\tools\bin\apksigner.bat sign --ks bench.keystore --ks-pass pass:<pw> --key-pass pass:<pw> \
|
||||
--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
|
||||
--out l1_control.apk l1_control_aligned.apk ; rc=0
|
||||
$ E:\tools\bin\apksigner.bat verify --print-certs --verbose \
|
||||
--min-sdk-version 21 --max-sdk-version 34 l1_control.apk
|
||||
Verifies
|
||||
Verified using v1 scheme (JAR signing): true
|
||||
Verified using v2 scheme (APK Signature Scheme v2): true
|
||||
Verified using v3 scheme (APK Signature Scheme v3): true
|
||||
Signer #1 certificate SHA-256 digest: ad13912308e0b6ded5d8be7da2868356348655033d1dde402d8f70d452fbf6db
|
||||
```
|
||||
|
||||
Identical result for `l1_patched.apk`, with the same certificate digest, so the two builds differ
|
||||
only in the dex bytes.
|
||||
|
||||
### 2.4 On-device result — the behaviour change, seen on the screen (`observed`)
|
||||
|
||||
Two builds were produced and both were checked, because "the pipeline works" and "the patch changed
|
||||
the app" are different claims and only the second one is the deliverable.
|
||||
|
||||
**The one-command pipeline, re-run after the repository changed** (this supersedes §2.3's manual
|
||||
route as the recommended invocation):
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --apk UnCrackable-Level1.apk \
|
||||
--dex classes.dex=classes.patched.dex --out out/l1_patched_final.apk \
|
||||
--workdir out/fin --ks out/bench.keystore \
|
||||
--keytool "C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe" \
|
||||
--apksigner E:\tools\bin\apksigner.bat --zipalign E:\tools\bin\zipalign.exe
|
||||
[sign] route: apksigner
|
||||
[out] ...\out\l1_patched_final.apk (70647 bytes)
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
Verified using v1 scheme (JAR signing): true
|
||||
Verified using v2 scheme (APK Signature Scheme v2): true
|
||||
Verified using v3 scheme (APK Signature Scheme v3): true
|
||||
[result] OK
|
||||
local sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56
|
||||
```
|
||||
|
||||
The manual `zipalign`+`apksigner` route earlier in this section produced **the same sha256**, so the
|
||||
two paths agree byte for byte and the result does not depend on which one a reader follows.
|
||||
|
||||
Install through the **root** path (`pm install -r -d`, which bypasses this ROM's streamed-install
|
||||
interception), and the installed `base.apk` is hashed and compared to the local file so "installed"
|
||||
cannot be confused with "the previous build is still there":
|
||||
|
||||
```
|
||||
control device sha256 e5a9f335155e3e20219d89bc859ae89cc0a82f7135123397c526d94bfca4b3b4
|
||||
local sha256 e5a9f335155e3e20219d89bc859ae89cc0a82f7135123397c526d94bfca4b3b4 identical
|
||||
patched device sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56
|
||||
local sha256 ad6a51cef4699b73ff052d4e049ca9efd64e9570900d19509f0b7a7c20f87e56 identical
|
||||
```
|
||||
|
||||
`am start -W` for both builds:
|
||||
|
||||
```
|
||||
LaunchState: COLD TotalTime: 241 ms WaitTime: 249 ms Status: ok (control)
|
||||
LaunchState: COLD TotalTime: 232 ms WaitTime: 234 ms Status: ok (patched)
|
||||
```
|
||||
|
||||
Window focus, sampled four times at 2 s intervals:
|
||||
|
||||
```
|
||||
original mCurrentFocus=Window{a5d2ba7 u0 Root detected!} (all four samples)
|
||||
control mCurrentFocus=Window{c4047e2 u0 Root detected!} (all four samples)
|
||||
patched mCurrentFocus=Window{8c94fc1 u0 <PKG>/...MainActivity} (all four samples)
|
||||
```
|
||||
|
||||
And the images, which are the evidence that actually counts —
|
||||
`tools/_work/bench/l1l3/evidence/00_original/00_original_03.png` shows the modal
|
||||
`Root detected! / This is unacceptable. The app is now going to exit. / OK` over a greyed-out input
|
||||
screen. The same shot on the control build (`evidence/01_control/*.png`, and again on
|
||||
`evidence/12_control_final/*.png` from the final one-command build) is byte-comparable to the
|
||||
original's. `evidence/13_patched_final/13_patched_final.png` — the final patched build — shows the
|
||||
screen **without any dialog**: input field live with the caret in it, `VERIFY` enabled, and
|
||||
`mCurrentFocus` on `<PKG>/...MainActivity`.
|
||||
|
||||
**G4 is therefore cleared in the strong form**: the control is not just "the same APK" — it went
|
||||
through the identical repack-and-sign pipeline with a byte-identical dex and still shows the old
|
||||
behaviour. The behaviour change is attributable to the four patched bytes, and it reproduced on a
|
||||
second, independently built APK from the final pipeline.
|
||||
|
||||
### 2.5 What the app's own check actually is (`observed`, and it contradicts the obvious reading)
|
||||
|
||||
The functional check was probed with a Frida hook rather than inferred from the crypto:
|
||||
|
||||
```
|
||||
[a.b] input="8d127684cbc37c17616d806cf50473cc" -> 16 bytes: 8d127684cbc37c17616d806cf50473cc
|
||||
[lib a.a] key=8d127684cbc37c17616d806cf50473cc
|
||||
data=e5426215cb5b9a06c3a0b5e6a4bd769a49e8f074f82eff1d95ab7c17147618e7
|
||||
-> 17 bytes 492077616e7420746f2062656c69657665 ascii="I want to believe"
|
||||
[a.a] REAL input="" -> false
|
||||
```
|
||||
|
||||
So: a **fixed 16-byte AES key** (`8d12…73cc`, a `const-string` in the dex) decrypts a fixed
|
||||
base64 blob to `I want to believe`, and the user's input is compared against that plaintext. My first
|
||||
reading — that the hex string was itself the secret — is **wrong**, and it was falsified by running
|
||||
it: entering `8d127684cbc37c17616d806cf50473cc` produces `Nope...`.
|
||||
|
||||
`TextUtils.equals` is **not** the comparison site: hooking it captured only dialog-internal
|
||||
comparisons (`("Nope...", "<null>")`). The comparison lives in the decrypted-bytes path of
|
||||
`a.a(String)`.
|
||||
|
||||
Two device facts that cost time and are worth inheriting:
|
||||
|
||||
- **The input field cannot be driven on this ROM.** `adb shell input text "I%swant%sto%sbelieve"`
|
||||
and a per-character `input keyevent` loop both landed `iwantto`: 7 of 17 characters survived and
|
||||
every space was dropped. Three independent quoting routes gave the same result, so the field
|
||||
itself rejects what the injection path sends. Reading the field back with `uiautomator dump` after
|
||||
each attempt is what distinguished "the app said Nope" from "my input never arrived" — the first
|
||||
two attempts looked like a wrong secret and were not.
|
||||
- `EditText.setText(String)` is not reachable from outside (only
|
||||
`setText(CharSequence, TextView$BufferType)` is declared, and `TextView.BufferType` is not exposed
|
||||
as a property on the class object); an **empty** `EditText` also returns a **null** `Editable`, so
|
||||
`getText().length()` fails with "not a function" and looks like a bridge limitation rather than an
|
||||
empty field.
|
||||
|
||||
The success branch was therefore reached through the app's own method with the boolean forced, which
|
||||
is honest as long as it is labelled — and it is:
|
||||
`evidence/09_success_branch/09_success_branch.png` shows the app's `Success! / This is the correct
|
||||
secret.` dialog. That image proves **the patched build reaches and renders the app's own success
|
||||
branch**; it does not prove the secret was typed and accepted. The check itself is evidenced by the
|
||||
libfoo/`a.b`/`a.a` log above.
|
||||
|
||||
### 2.6 B1 verdict
|
||||
|
||||
`observed`: the equal-length edit, the header recomputation in the correct order, the STORED+aligned
|
||||
repack, v1+v2+v3 signing, the root-path install, the device-side hash match, and the on-screen
|
||||
behaviour change all reproduce. G2 and G4 both clear.
|
||||
|
||||
---
|
||||
|
||||
## 3. B2 — L3 self-destruct race (`observed` for the tooling, `unverified`/negative for the patch)
|
||||
|
||||
### 3.1 What L3 actually does (read from the dex and the tombstone, not assumed)
|
||||
|
||||
`MainActivity.onCreate` calls `verifyLibs()` **first**, then `init("pizzapizzapizzapizzapizz".getBytes())`,
|
||||
then an `AsyncTask`, then three root checks, then `isDebuggable()`, then `setContentView` — and
|
||||
`setContentView` sits **after** the gate:
|
||||
|
||||
```
|
||||
0x11a3ce invoke-static Lsg/vantagepoint/util/RootDetection;.checkRoot1()Z
|
||||
...
|
||||
0x11a406 sget Lsg/vantagepoint/uncrackable3/MainActivity;.tampered:I
|
||||
0x11a40a if-eqz v0 -> 0x11a418 ; tampered == 0 -> skip the dialog
|
||||
0x11a40e const-string "Rooting or tampering detected."
|
||||
0x11a412 invoke-direct MainActivity;.showDialog(Ljava/lang/String;)V
|
||||
0x11a418 ... setContentView
|
||||
```
|
||||
|
||||
So when the tamper bit is set the app **never renders its UI**; it sits on a blocking dialog whose OK
|
||||
button is wired to an `onClick` that exits. `verifyLibs()` is the source of that bit:
|
||||
|
||||
- it reads four ABI-keyed expected CRCs out of `resources.arsc` string resources
|
||||
(`R.string` ids `0x7f0b002b`, `0x7f0b0029`, `0x7f0b0034`, `0x7f0b0035`),
|
||||
- compares them to the zip entry CRCs of `lib/<abi>/libfoo.so`, and
|
||||
- compares `classes.dex`'s zip CRC to the value the native `baz()` returns.
|
||||
|
||||
The native library exports exactly three JNI entries and one terminate routine:
|
||||
|
||||
```
|
||||
Java_sg_vantagepoint_uncrackable3_CodeCheck_bar vaddr=0x33a4 size=256
|
||||
Java_sg_vantagepoint_uncrackable3_MainActivity_init vaddr=0x3310 size=136
|
||||
Java_sg_vantagepoint_uncrackable3_MainActivity_baz vaddr=0x3398 size=12
|
||||
_Z7goodbyev vaddr=0x3080 size=24
|
||||
_Z4randv vaddr=0xe4c size=36
|
||||
```
|
||||
|
||||
and its strings name the detection inputs: `/proc/self/maps`, `frida`, `xposed`, `ptrace`,
|
||||
`waitpid`, `getppid`, `pthread_create`, plus two log strings:
|
||||
`"Tampering detected! Terminating..."` and `"Error opening /proc/self/maps! Terminating..."`.
|
||||
|
||||
`libfoo.so`'s single RX `PT_LOAD` is `off=0x0 vaddr=0x0 filesz=0x4700`, so **file offset == virtual
|
||||
address == runtime offset from the module base** for everything in `.text`. That is what makes
|
||||
`hook_patch_only.js`'s `FILE_OFFSET` meaningful here, and it is not true in general — a library whose
|
||||
text segment has a non-zero `vaddr` would need `p_vaddr + delta`.
|
||||
|
||||
### 3.2 The baseline, and the finding that invalidates the assumed baseline
|
||||
|
||||
`observed`: the **unmodified, pristine APK** on this device shows the tamper dialog:
|
||||
|
||||
```
|
||||
$ am start -n <PKG>/sg.vantagepoint.uncrackable3.MainActivity
|
||||
833 ms pid=5377 5413 mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
|
||||
1638 ms ... mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
|
||||
...
|
||||
10712 ms ... mCurrentFocus=Window{1d9ee56 u0 Rooting or tampering detected.}
|
||||
```
|
||||
|
||||
and `logcat` shows the CRCs it compared (`V/UnCrackable3`), with `tampered` set. The process stays
|
||||
alive in this state for at least 25 s: the tamper dialog alone does **not** kill the process, it waits
|
||||
for the OK button.
|
||||
|
||||
`observed` — the **time-to-death baseline with a frida probe attached**, which is the number the row
|
||||
needs:
|
||||
|
||||
```
|
||||
$ python ttd_attach.py --package <PKG> --activity sg.vantagepoint.uncrackable3.MainActivity \
|
||||
--js l3_read_crc.js --launch-delay 1.5 --seconds 20
|
||||
[i] pre-attach main pid=7020 at 1.88s
|
||||
[i] attached at +0.00s (probe loaded)
|
||||
attach+ 0.75s main_pid=7020
|
||||
... (alive)
|
||||
attach+ 4.90s main_pid=7020
|
||||
attach+ 5.34s main_pid=-
|
||||
=== logcat ===
|
||||
F/libc ( 7020): Fatal signal 6 (SIGABRT), code -6 (SI_TKILL) in tid 7052 (tg.uncrackable3)
|
||||
F/DEBUG ( 7203): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
|
||||
F/DEBUG ( 7203): #02 pc 00000000000031ac .../lib/arm64/libfoo.so
|
||||
I/Zygote( 1007): Process 7020 exited due to signal 6 (Aborted)
|
||||
```
|
||||
|
||||
**`TTD(frida attached) = 5.34 s`, and the death is a native `SIGABRT` originating in `goodbye()+12`.**
|
||||
Note which process dies: `7020` is the main `<PKG>` process, `7052`/`tg.uncrackable3` is the failing
|
||||
*thread*. That distinction matters — see §3.4.
|
||||
|
||||
### 3.3 The two scripts, run as shipped (`observed`)
|
||||
|
||||
Neither had ever been executed before this pass.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/spawn_patch_detach.py \
|
||||
--package <PKG> --js skills/apk-reverse/scripts/hook_patch_only.js \
|
||||
--captures 2 --interval 3 --no-screencap
|
||||
[i] spawned pid=7442
|
||||
[!] PATCH_BYTES is empty -- configure this probe before using it
|
||||
[MSG] PATCHFAIL
|
||||
[i] patched=False
|
||||
[i] detached (memory writes persist, hooks are gone)
|
||||
=== capture 0 ===
|
||||
7567 00:01 sh -c ps -A -o PID,ETIME,ARGS | grep -i uncrackable3
|
||||
7570 00:01 grep -i uncrackable3
|
||||
mCurrentFocus=Window{7c35f28 u0 com.oppo.launcher/com.oppo.launcher.Launcher}
|
||||
```
|
||||
|
||||
Three defects, all confirmed by later runs:
|
||||
|
||||
1. **`hook_patch_only.js` is a template, and its guard is correct but the guard is the whole
|
||||
result.** `PATCH_BYTES = []` correctly refuses and reports `PATCHFAIL`, so an unconfigured run
|
||||
cannot silently claim success. That is good behaviour; it just means the shipped file tests
|
||||
nothing.
|
||||
2. **`spawn_patch_detach.py` resumed the target before waiting for `PATCHED`.** The original order
|
||||
was `resume(pid)` → then wait. That makes the wait meaningless: by the time the probe's
|
||||
`Process.findModuleByName` poll sees the library, the target has been running.
|
||||
3. **`--activity` defaults to `None` and the code then did nothing.** After `am force-stop` and a
|
||||
spawn, omitting `--activity` left the app un-launched, so the capture set was of the launcher and
|
||||
read exactly like "the app died". Fixed by resolving the launcher through
|
||||
`cmd package resolve-activity --brief <pkg>` when `--activity` is absent.
|
||||
|
||||
**Correction to my own first fix.** I changed the order so the driver held the target **paused**
|
||||
until `PATCHED`. That cannot work with a module-polling probe, and the run says so:
|
||||
|
||||
```
|
||||
[i] probe loaded; target still PAUSED, waiting up to 15.0s for the patch
|
||||
[5.00s][alive] [10.00s][alive] [15.00s][alive]
|
||||
[!] the probe did not report PATCHED in 15.0s
|
||||
[i] resumed pid=7666
|
||||
[15.11s][i] libfoo.so base=0x764c0aa000 bytes@0x3080 = fd 7b bf a9 ...
|
||||
[15.12s][PATCH] now = 00 00 80 52 d6 03 5f c0 ...
|
||||
```
|
||||
|
||||
**While the process is frozen its libraries are not mapped**, so the poll can never succeed: 15 s
|
||||
paused produced nothing, and `libfoo.so base=0x764c0aa000` appeared 0.11 s after resume. The shipped
|
||||
order was right about *that*; what was wrong was detaching before the write landed. The corrected
|
||||
driver resumes, then waits for `PATCHED` (reported loudly when it never arrives), then detaches.
|
||||
|
||||
### 3.4 The experiment, and its result
|
||||
|
||||
Sites were chosen from the tombstone and the ELF, not guessed. `goodbyev` at `0x3080`:
|
||||
|
||||
```
|
||||
0x3080 a9bf7bfd stp x29, x30, [sp, #-16]!
|
||||
0x3084 910003fd mov x29, sp
|
||||
0x3088 321f07e0
|
||||
0x308c 97fff725 bl 0x2ff0 <-- the call the tombstone names as goodbye()+12
|
||||
0x3090 2a1f03e0
|
||||
0x3094 97fff71f bl 0x2ff8
|
||||
```
|
||||
|
||||
and the two helpers it tail-calls are trivial return-value stubs:
|
||||
|
||||
```
|
||||
0x2ff0 52801a40 mov w0, #0xd2 0x2ffc d65f03c0 ret
|
||||
0x2ff8 52800cc0 mov w0, #0x66 0x3000 321f0fe0 ...
|
||||
```
|
||||
|
||||
So probe v2 patches the **return values the branch reads** (`0x2ff0`, `0x2ff8`) as well as
|
||||
`goodbyev`'s entry, each rewritten to `mov w0, #0 ; ret` (+ nops, equal length), with an assertion on
|
||||
the expected original bytes before each write.
|
||||
|
||||
Patched condition:
|
||||
|
||||
```
|
||||
$ python b2_measure.py --js l3_hook_patch_v2.js --tag PATCHED --probe-seconds 25
|
||||
[i] spawned pid=10000 (paused)
|
||||
[i] resumed pid=10000
|
||||
[0.04s][i] libfoo.so base=0x764b89d000 size=90112
|
||||
[0.04s][i] goodbye entry = fd 7b bf a9 fd 03 00 91 e0 07 1f 32 25 f7 ff 97
|
||||
[0.04s][i] goodbye+12 = 25 f7 ff 97
|
||||
[0.04s][PATCH] 0x2ff0 (helper A -> return 0) 40 1a 80 52 c0 03 5f d6 -> 00 00 80 52 d6 03 5f c0
|
||||
[0.05s][PATCH] 0x2ff8 (helper B -> return 0) c0 0c 80 52 c0 03 5f d6 -> 00 00 80 52 d6 03 5f c0
|
||||
[0.05s][PATCH] 0x3080 (goodbyev entry -> no-op) fd 7b bf a9 ... -> 00 00 80 52 d6 03 5f c0 1f 20 03 d5 1f 20 03 d5
|
||||
[0.05s][i] sites written=3 skipped=0
|
||||
[i] patched=True
|
||||
3.22s pid=10000 alive=False
|
||||
V/UnCrackable3(10000): Tampering detected! Terminating...
|
||||
F/libc (10000): Fatal signal 6 (SIGABRT), code -6 (SI_TKILL) in tid 10032 (tg.uncrackable3), pid 10000
|
||||
F/DEBUG (10036): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
|
||||
I/Zygote( 1007): Process 10000 exited due to signal 6 (Aborted)
|
||||
[RESULT] tag=PATCHED spawned=10000 ttd_after_resume=3.22 survived=False
|
||||
```
|
||||
|
||||
Control condition (identical reads and writes, original bytes written back):
|
||||
|
||||
```
|
||||
$ python b2_measure.py --js l3_hook_control_v2.js --tag control_v2 --probe-seconds 22
|
||||
[0.04s][CONTROL] 0x2ff0 unchanged (wrote back 40 1a 80 52 c0 03 5f d6)
|
||||
[0.04s][CONTROL] 0x2ff8 unchanged (wrote back c0 0c 80 52 c0 03 5f d6)
|
||||
[0.04s][CONTROL] 0x3080 unchanged (wrote back fd 7b bf a9 fd 03 00 91)
|
||||
[0.04s][i] control sites ok=3
|
||||
V/UnCrackable3(9312): Tampering detected! Terminating...
|
||||
F/DEBUG ( 9348): #01 pc 000000000000308c .../lib/arm64/libfoo.so (goodbye()+12)
|
||||
I/Zygote( 1007): Process 9312 exited due to signal 6 (Aborted)
|
||||
```
|
||||
|
||||
**Both conditions terminate at `goodbye()+12` with `SIGABRT`.** The probes apply the patch
|
||||
successfully, inside the race window (0.04–0.05 s after resume against a ~3.2 s deadline), and the
|
||||
target still dies.
|
||||
|
||||
### 3.5 What this means, and what to do instead
|
||||
|
||||
`observed`:
|
||||
- `spawn_patch_detach.py` **can** deliver an early memory patch: the write landed 0.05 s after resume
|
||||
and survived the detach. The mechanism the row tests is real.
|
||||
- `hook_patch_only.js` is a **template with correct guard rails**, not a working probe.
|
||||
- The patch as configured is **insufficient**: three sites including the terminate routine's entry
|
||||
and both of its branch inputs do not stop the abort.
|
||||
|
||||
`inferred` (labelled, not executed): the terminate path is not confined to `goodbyev` and its two
|
||||
stubs. `goodbyev` is 24 bytes and its only two calls are the two stubs; the `bl` at `+12` is the
|
||||
observed faulting frame, and yet patching both the callee and the caller changed nothing. The most
|
||||
consistent explanation is that the tampering thread reaches the *same abort* through a second site
|
||||
that this static pass did not map — the region around `0x2f80–0x3080` is a dense run of
|
||||
`mov w0, #imm ; ret` pairs (values 0x11, 0x1b, 0x43, 0x5b, 0x67, 0x7b, 0x92, 0xa2, 0xa3, 0xae, 0xcc,
|
||||
0xd2, 0xe3, 0xe7, 0xe8, 0xed, 0xf6, 0xfd, …), i.e. a jump-table-shaped cluster of tiny accessors
|
||||
that a proper disassembly would resolve into a call graph, and `rand` (`_Z4randv`, 0xe4c, 36 B) is
|
||||
part of the same obfuscated scheme. **A real fix needs a full AArch64 disassembly of `.text`
|
||||
(~18 KB) rather than four hand-picked offsets.**
|
||||
|
||||
`observed` — the route that should be tried first next time: the abort is reachable from a **Java**
|
||||
layer that is already fully mapped. `verifyLibs()` computes `tampered` and `onCreate` branches on it,
|
||||
and the dialog's OK button is a plain `DialogInterface.OnClickListener` in `MainActivity$1`. Patching
|
||||
the dex in that class (`if-eqz v0 -> 0x11a418` → nops, forcing the non-tamper side) removes the
|
||||
dialog and lets `setContentView` run **without touching native code at all** — and unlike the native
|
||||
patch it is a route this repository already has measured tooling for (see B1). This was not executed
|
||||
in this pass; it is the recommended next experiment, not a result.
|
||||
|
||||
`observed` — a limitation the next pass must budget for: on this device the L3 **original** APK
|
||||
already fails its own CRC self-check, so the tamper state is on before any instrumentation. Whatever
|
||||
the follow-up tests must therefore be measured against *that* baseline, not against a clean one.
|
||||
|
||||
---
|
||||
|
||||
## 4. Task C — L2 (not run)
|
||||
|
||||
Not attempted: A and B consumed the pass. `UnCrackable-Level2.apk` is on disk
|
||||
(`tools/_work/bench/apks/`, 901022 B, sha256 prefix `4c7980ef1f6cc295`) and the `dynamic-frida.md`
|
||||
spawn route is untested for it. Recorded as **not run**, not as a negative result.
|
||||
|
||||
---
|
||||
|
||||
## 5. Defects found in the repository's own tooling
|
||||
|
||||
| Where | What | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| `scripts/repack.py` (`sign_apk`) | **Stale finding, re-opened and closed.** At the start of this pass it signed **only** via `java -jar <signer_jar>`; `--apksigner`/`--zipalign` were verification-only, so the "any zipalign+apksigner based signer works here" hint had no code path behind it | §2.3 exit 1 with `signer jar not found` | **fixed by another writer mid-pass**; re-measured in §2.4 — `[sign] route: apksigner`, `[result] OK`, byte-identical output |
|
||||
| `scripts/repack.py` | The `signer jar not found` message is still emitted by the jar-only `sign_apk()` and still claims a zipalign+apksigner route works. When no `--apksigner` was supplied (the first run in §2.3) the message is the *only* guidance a reader gets, and it points at a function that does not exist | §2.3 | documented; the message should name the apksigner route it now has |
|
||||
| `scripts/spawn_patch_detach.py` | Detached before the probe's write landed, so the write could be missed entirely while the run still reported `patched=False` and looked merely unlucky | §3.3 | **fixed** — resume, wait for `PATCHED`, report loudly, then detach |
|
||||
| `scripts/spawn_patch_detach.py` | `--activity` absent left the target un-launched after a spawn, so the captures were of the launcher | §3.3 | **fixed** — resolve the launcher via `cmd package resolve-activity` |
|
||||
| `scripts/spawn_patch_detach.py` | Unbounded `adb`/frida calls; `adb_run()`'s rc is never inspected for the launcher step | §3.3 | partially fixed (`--launch-timeout`, rc surfaced) |
|
||||
| `scripts/hook_patch_only.js` | Guard rails are correct (`PATCH_BYTES` empty → `PATCHFAIL`); it is a template, and the docs imply it is a runnable probe | §3.3 | documented |
|
||||
| `tools/_work/ENV.md` | Attributes the B1 signing problem to the apksigner/zipalign route; the blocker was the missing uber-apk-signer jar, and both apksigner and zipalign work | §2.3 | corrected here |
|
||||
| `tests/benchmark.md` B2 | Describes L3 as a self-destruct race against a pristine baseline; on this device the original APK is already in its tamper state | §3.2 | corrected here |
|
||||
|
||||
---
|
||||
|
||||
## 6. Files produced by this pass
|
||||
|
||||
Workbench (not part of the skill; `tools/` is git-ignored):
|
||||
|
||||
- `tools/_work/bench/l1l3/l1_root.spec.json`, `dex/classes.patched.dex`, `dex_orig/classes.dex`
|
||||
- `tools/_work/bench/l1l3/out/l1_control.apk`, `l1_patched.apk`, `l1_control_now.apk`,
|
||||
`l1_patched_final.apk` (+ unsigned/aligned intermediates)
|
||||
- `tools/_work/bench/l1l3/l1_probe.py`, `l1_type.py`, `l1_verify_field.py`, `l1_try_secret.py`,
|
||||
`l1_drive.js`, `l1_probe_full.js`, `attach_probe.py`
|
||||
- `tools/_work/bench/l1l3/l3_hook_patch_v2.js`, `l3_hook_control_v2.js`, `l3_read_crc.js`,
|
||||
`elf_l3.py`, `dis_l3.py`
|
||||
- `tools/_work/bench/l1l3/b2_measure.py`, `ttd.py`, `ttd_attach.py`
|
||||
- `tools/_work/bench/l1l3/evidence/**` (PNG captures: `00_original`, `01_control`,
|
||||
`02_patched_nosecret`, `09_success_branch`, `12_control_final`, `13_patched_final`)
|
||||
|
||||
A note on making §2.4 reproducible: `l1_probe.py` reuses the tag as the remote filename, so
|
||||
`--tag` must be unique per run. Reusing a tag after a failed `adb push` installs the *previous*
|
||||
remote file, which is exactly how the first attempt at the final patched verification showed
|
||||
`Root detected!` — the control build's hash, from the control run's leftover remote path. The hash
|
||||
comparison in the same script is what caught it; a screenshot alone would not have.
|
||||
@@ -0,0 +1,538 @@
|
||||
# Extension verification — extraction shells and the Dex-VMP boundary (bench rows B3 / B4)
|
||||
|
||||
Covers benchmark rows **B3** (extraction-shell skeleton detection) and **B4** (Dex-VMP
|
||||
boundary: refuse-to-repack and downgrade the deliverable). Strength labels are this
|
||||
directory's own three tiers: **observed** (an exact command and its output are quoted
|
||||
here), **inferred** (follows from observed facts; the step was not executed),
|
||||
**unverified** (assumed or externally reported, not reproduced here).
|
||||
|
||||
Everything below was produced on this repository's Windows 11 host with the rootless
|
||||
PC toolchain in `tools/_work/ENV.md`; the device results in §4 name a
|
||||
`<DEVICE>` and use `<PKG>` placeholders for the target package.
|
||||
|
||||
## 0. Headline
|
||||
|
||||
| Question | Answer | Strength |
|
||||
|---|---|---|
|
||||
| Does `stub%` separate a skeleton copy from a genuine dex? | **Only when the shell writes bare `return-void`.** Three other measured emptying shapes score 0.0 %–2.3 %, at or below the *untouched* control (1.9 %) | observed |
|
||||
| Was the pre-existing `trivial_ratio` detector correct? | **No — two defects**, one of which inverted the ranking and named a fully-nop-wiped skeleton the "most likely original" | observed |
|
||||
| Is `repos/CyReverse`'s shipped "extraction shell" asset usable as a shell fixture? | **No.** The pair differs by six bytes and one body; neither is a shell | observed |
|
||||
| Is `DexPatcher/assets/ezAndroid.apk` a Dex VMP? | **No.** It is a JNI-sinking target: 7 `native` declarations, an exported `Java_*` for each, 34,566 clean dalvik instruction decodes, zero structural errors | observed |
|
||||
| Can a static dex probe decide VMP vs not? | **It can decide "not dex-VMP"** when the bodies decode cleanly and each native declaration has an export. It **cannot** decide whether the native island performs runtime hardening | observed (negative half) / unverified (positive half) |
|
||||
| Does `dex_mem_scan.py` find and cut a dex out of a blob? | **Yes** — exact offset, no false hits in 2 MiB of noise, image accepted by the judge | observed |
|
||||
| Does it reach a dex in a live process's anonymous mapping? | **Not measured here.** The fixture is built and the commands staged; the device was held by another row throughout | unverified |
|
||||
|
||||
One claim in this pass was **retracted**: §3.4 records a Dex-VMP verdict drawn from a
|
||||
desynchronised decoder and the measurement that killed it.
|
||||
|
||||
---
|
||||
|
||||
## 1. B3 — the shipped CyReverse "extraction shell" assets are a 6-byte difference, not a shell
|
||||
|
||||
Source: `repos/CyReverse/app/src/main/assets/{classes3.dex, classes3_extracted.dex}`
|
||||
(12,536 B each).
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3
|
||||
== dex dump validation: 2 file(s), 2 parse, 0 rejected ==
|
||||
name size sha256[:12] ver cksum sig class noco code stub%
|
||||
cyrev-extracted.dex 12536 c2d7345e955a 037 ok BAD 4 0 20 10.0%
|
||||
cyrev-skeleton.dex 12536 ba9d5b120a3a 037 ok ok 4 0 20 10.0%
|
||||
```
|
||||
|
||||
A byte diff of the pair (`tools/_work/bench/unpack/b3/analyze_pair.py`) locates every
|
||||
difference:
|
||||
|
||||
```
|
||||
$ python analyze_pair.py
|
||||
len A=12536 len B=12536
|
||||
first diff offset = 8 (0x8)
|
||||
diff byte count = 6
|
||||
diff ranges:
|
||||
0x8 .. 0x8 (1 B) <- dex checksum (header offset 8)
|
||||
0xa .. 0xb (2 B) <- dex signature, first half (offset 12 is 4 B wide)
|
||||
0x121c .. 0x121c (1 B) <- INSNS of PluginClass->getString, units 0 and 2
|
||||
0x121e .. 0x121e (1 B)
|
||||
0x1220 .. 0x1220 (1 B)
|
||||
```
|
||||
|
||||
Per-method census of the pair shows the "extracted" copy is the *degraded* one:
|
||||
|
||||
```
|
||||
== cyrev-skeleton.dex == Lcom/cyrus/example/plugin/PluginClass;->getString insns=3 units=001a 0087 0011
|
||||
== cyrev-extracted.dex == Lcom/cyrus/example/plugin/PluginClass;->getString insns=3 units=0000 0000 0000
|
||||
```
|
||||
|
||||
`const-string v0, "…"; return-object v0` was overwritten with three `0x0000` units in
|
||||
exactly one of the twenty bodies, and the header was re-fixed. The shipped
|
||||
`classes3_extracted.dex` is **not** an extraction shell and **not** a recovered dex:
|
||||
it is a single-body demonstration of body clearing, with 19 of 20 bodies identical to
|
||||
the skeleton. Neither file can test extraction-shell routing — 10.0 % `stub%` on both
|
||||
sides is a coincidence of the same body set, not a signal.
|
||||
|
||||
**Consequence for the benchmark row:** B3 cannot be run against this asset pair. The
|
||||
skeleton measurement below had to be built from a real application dex instead.
|
||||
|
||||
## 2. B3 — measured `stub%` across every emptying shape, with a zero-change control
|
||||
|
||||
Fixture builder: `tools/_work/bench/unpack/b3/make_skeleton.py` derives each variant
|
||||
from a real 982,480-byte application dex (`src-r2pay.dex`, extracted from
|
||||
`tools/_work/bench/apks/r2pay-v1.0.apk`, sha256 `c89ff2364879…`). Every variant is
|
||||
**byte-length preserving**: only the `insns` array of a `code_item` is rewritten and
|
||||
the tail is nop-filled, so offsets, class table and string table are untouched and the
|
||||
header is re-fixed with `dexutil.fix_dex_header` (signature first, checksum last).
|
||||
|
||||
```
|
||||
$ python make_skeleton.py src-r2pay.dex skel
|
||||
source: src-r2pay.dex
|
||||
ctrl.dex fraction=0.00 mode=return emptied=0/5061
|
||||
skel-25.dex fraction=0.25 mode=return emptied=1265/5061
|
||||
skel-50.dex fraction=0.50 mode=return emptied=2531/5061
|
||||
skel-75.dex fraction=0.75 mode=return emptied=3796/5061
|
||||
skel-all.dex fraction=1.00 mode=return emptied=5061/5061
|
||||
pruned.dex fraction=1.00 mode=pruned emptied=5061/5061
|
||||
nopfilled.dex fraction=1.00 mode=nop emptied=5061/5061
|
||||
allstub.dex fraction=1.00 mode=allstub emptied=5061/5061
|
||||
throwstub.dex fraction=1.00 mode=throw emptied=4134/5061
|
||||
```
|
||||
|
||||
Verdicts from `dex_dump_validate.py` over the whole set — the decisive matrix:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skelall
|
||||
name size sha256[:12] ver cksum sig class noco code stub% erased% emptied%
|
||||
allstub.dex 982480 c8d7ca8fb163 035 ok ok 647 250 5061 100.0% 0.0% 100.0%
|
||||
ctrl.dex 982480 1b3a97896b40 035 ok ok 647 250 5061 1.9% 0.0% 1.9%
|
||||
nopfilled.dex 982480 de92dcea4eaf 035 ok ok 647 250 5061 0.0% 100.0% 100.0%
|
||||
pruned.dex 982480 58d7fa3dce48 035 ok ok 647 250 5061 93.2% 0.0% 93.2%
|
||||
skel-25.dex 982480 63346d9b41f5 035 ok ok 647 250 5061 1.7% 0.0% 1.7%
|
||||
skel-50.dex 982480 d84601b7aa47 035 ok ok 647 250 5061 1.7% 0.0% 1.7%
|
||||
skel-75.dex 982480 64114285656d 035 ok ok 647 250 5061 2.1% 0.0% 2.1%
|
||||
skel-all.dex 982480 6345daef0a3f 035 ok ok 647 250 5061 2.3% 0.0% 2.3%
|
||||
throwstub.dex 982480 cab8fd51a743 035 ok ok 647 250 5061 1.9% 0.0% 1.9%
|
||||
```
|
||||
|
||||
Read the `mode=` column against `stub%`:
|
||||
|
||||
| Emptying shape (what the shell leaves behind) | `stub%` | Separated from `ctrl` (1.9 %)? |
|
||||
|---|---|---|
|
||||
| bare `return-void` in every body (`allstub`) | **100.0 %** | yes, by 98 points |
|
||||
| body truncated to `const/4 v0,#0; return v0` + nop fill (`pruned`) | 93.2 % | yes |
|
||||
| nop fill, no return at all (`nopfilled`) | **0.0 %** | **no — scores *below* the untouched control** |
|
||||
| `new RuntimeException; throw` stub (`throwstub`) | 1.9 % | **no — indistinguishable from control** |
|
||||
| 25 % / 50 % / 75 % of bodies emptied with a legal minimal body | 1.7 / 1.7 / 2.1 % | **no** |
|
||||
|
||||
Three conclusions, all observed:
|
||||
|
||||
1. **The threshold is not a number, it is a shape.** `stub%` is bimodal — it is either
|
||||
≈ control (blind) or ≈ 100 % (sighted). There is no intermediate band where a
|
||||
threshold could sit, so the "tens of percent" calibration the reference file
|
||||
describes has no measured support.
|
||||
2. **Partial extraction is invisible.** `skel-75.dex` — three quarters of all bodies
|
||||
emptied — reports 2.1 % against a 1.9 % control, a 0.2-point difference inside the
|
||||
sampling noise of what a real app's own trivial bodies contribute.
|
||||
3. **The two shapes that defeat it are the two that real shells prefer.**
|
||||
`throw new RuntimeException` stubs are the classic 360/legu family shape, and a
|
||||
nop-wiped body is what a shell writes when it clears the slot before decrypting.
|
||||
|
||||
The false-negative on `nopfilled` was a **defect, not a boundary**, and is fixed in §3.
|
||||
The `throwstub` blind spot is a **boundary** and is documented as one.
|
||||
|
||||
## 3. B3 — defects found in `dex_dump_validate.py`, and the fix
|
||||
|
||||
### 3.1 Defect 1: a fully nop-filled body was classified `real`
|
||||
|
||||
`classify_body` skipped every `0x0000` unit and then fell through to `return "real"`
|
||||
when nothing else was found. Measured consequence: `nopfilled.dex` — **all 5,061
|
||||
bodies wiped** — reported `stub% = 0.0`, and the ranking named it the most likely
|
||||
original:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/skel/{nopfilled,ctrl}.dex
|
||||
ranking (most likely original first):
|
||||
1. nopfilled.dex stub%=0.0 cksum=ok sig=ok classes=647
|
||||
…
|
||||
verdict: nopfilled.dex is the most likely original (1 copy(ies) in this set)
|
||||
```
|
||||
|
||||
An agent routing on that line patches a wiped skeleton. That is the failure the file
|
||||
exists to prevent, so the defect is on the wrong side of "the tool still produces
|
||||
output".
|
||||
|
||||
**Fix.** `classify_body` gained two classes and a wider body scan:
|
||||
|
||||
- `erased` — `insns_size > 0` with every unit `0x0000`. A real compiler never emits a
|
||||
whole body of nops, so this is skeleton evidence.
|
||||
- `minimal` — a body truncated to `const/4 vR,#0; return vR` (legal, and the shape the
|
||||
detector is blind to by design). Reported separately so the blind spot is visible
|
||||
rather than hidden inside `real`.
|
||||
- The early-exit `meaningful > 1 → real` became `meaningful > 2`, so the two-unit
|
||||
minimal body reaches its own verdict instead of being mislabelled.
|
||||
|
||||
`trivial_ratio` keeps its original definition so previously recorded numbers stay
|
||||
comparable; `emptied_ratio = (stub + erased) / with_code` is the new skeleton signal,
|
||||
and `rank_key` sorts on it **low-first** with `bodies_erased` as a tiebreak.
|
||||
|
||||
### 3.2 Defect 2 (retracted in-flight): `0xNN0E` is not malformed
|
||||
|
||||
An intermediate version of the fix classified a lone `0x020E` unit as `malformed`,
|
||||
reading a non-zero high byte on a `0x0E` opcode as an illegal `return-void`. The
|
||||
control dump proved otherwise — 82 of the untouched dex's own bodies are exactly that
|
||||
shape — and `const/4 v0, #0; return v0` is a legal minimal non-void body. The class was
|
||||
renamed `minimal`, its comment corrected, and the fixture extended with `pruned.dex`
|
||||
to measure it deliberately (93.2 %, `stub%` sighted). Recorded here because the
|
||||
mislabel would have taught the next reader that ordinary short methods are damage.
|
||||
|
||||
### 3.3 Post-fix behaviour, and no regression on the recorded fixture
|
||||
|
||||
`nopfilled.dex` is now ranked last and flagged:
|
||||
|
||||
```
|
||||
8. nopfilled.dex stub%=0.0 erased%=100.0 cksum=ok sig=ok classes=647 <-- SKELETON: emptied%=100, extraction-shell shape
|
||||
9. …
|
||||
verdict: skel-25.dex is the most likely original (1 copy(ies) in this set)
|
||||
```
|
||||
|
||||
The pre-existing acceptance fixture from `EXTENSION-unpacking.md` §2 still produces
|
||||
its recorded verdict, with only the two new columns appended:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/t2-unpack --find 'Lcom/stub/'
|
||||
== dex dump validation: 7 file(s), 4 parse, 3 rejected ==
|
||||
01_original_shell.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4% 0.0% 3.4%
|
||||
02_duplicate.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4% 0.0% 3.4%
|
||||
03_broken_magic.dex 8923752 33eee53b1317 ? REJECTED: magic=b'XXXX'
|
||||
04_tampered_body.dex 8923752 b89bc8cc73d9 035 BAD BAD 4 160 29 3.4% 0.0% 3.4%
|
||||
05_truncated.dex 65536 e6687390a663 035 REJECTED: file_size=8923752 actual=65536
|
||||
06_extracted_skeleton.dex 8923752 85e56fed1d56 035 ok ok 4 160 29 100.0% 0.0% 100.0%
|
||||
07_tiny_garbage.dex 16 be45cb2605bf ? REJECTED: too small (16 B) to carry a dex header
|
||||
verdict: 01_original_shell.dex is the most likely original (2 copy(ies) in this set)
|
||||
```
|
||||
|
||||
Same winner, same three rejections, same dedupe group. The change is additive on that
|
||||
fixture and corrective on the new one.
|
||||
|
||||
### 3.3b The ranking was still pointing at a modified image (found by independent review)
|
||||
|
||||
The first fix above added the `emptied_ratio` signal — and then **kept ranking on a
|
||||
metric this same pass had just proved insensitive to skeletons**. Reproduced by
|
||||
leading the review on the variant set:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py tools/_work/bench/unpack/b3/skel
|
||||
ranking (most likely original first):
|
||||
1. skel-25.dex stub%=1.7 erased%=0.0 cksum=ok sig=ok classes=647
|
||||
2. skel-50.dex stub%=1.7 erased%=0.0 cksum=ok sig=ok classes=647
|
||||
3. ctrl.dex stub%=1.9 erased%=0.0 cksum=ok sig=ok classes=647
|
||||
…
|
||||
verdict: skel-25.dex is the most likely original (1 copy(ies) in this set)
|
||||
```
|
||||
|
||||
`skel-25.dex` — a dex with **25 % of its bodies removed** — was named the most likely
|
||||
original, and the genuine untouched control ranked third. The cause is the bimodality
|
||||
of §2: inside the low band the ratio is not a signal, and 1.7 % versus 1.9 % is noise
|
||||
(the extracted bodies are replaced by `const/4 + return`, which *lowers* the
|
||||
`return-void` count relative to the control). An agent routing on that line adopts a
|
||||
modified image as its baseline, and every later diff is then built on the wrong
|
||||
artifact — the same class of failure as the defect in §3.1, reached by a different
|
||||
path.
|
||||
|
||||
**Fix.** `rank_key` became three-level, with an explicit eviction level so that either
|
||||
signal can disqualify an image instead of the two merely tying:
|
||||
|
||||
1. `emptied_ratio >= 0.5` sorts last. Nothing mostly emptied is a candidate however
|
||||
else it scores. This level exists because the count below cannot see the
|
||||
`return-void`-in-every-body skeleton: that image has `minimal = erased = 0`, so a
|
||||
pure-count key ranked it **first** — measured, and it is why this level was added
|
||||
rather than the count being promoted alone.
|
||||
2. `bodies_minimal + bodies_erased` low-first. This orders the low band, where the
|
||||
count is monotone: 82 untouched, then 1299 / 2524 / 3736 / 4946 at 25 / 50 / 75 /
|
||||
100 %.
|
||||
3. `emptied_ratio` low-first, then header verdicts, then name.
|
||||
|
||||
Result on the same command:
|
||||
|
||||
```
|
||||
ranking (most likely original first):
|
||||
1. ctrl.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||||
2. only.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||||
3. throwstub.dex stub%=1.9 erased%=0.0 min/erased=82 cksum=ok sig=ok
|
||||
4. skel-25.dex stub%=1.7 erased%=0.0 min/erased=1299 cksum=ok sig=ok
|
||||
5. skel-50.dex stub%=1.7 erased%=0.0 min/erased=2524 cksum=ok sig=ok
|
||||
6. skel-75.dex stub%=2.1 erased%=0.0 min/erased=3736 cksum=ok sig=ok
|
||||
7. skel-all.dex stub%=2.3 erased%=0.0 min/erased=4946 cksum=ok sig=ok
|
||||
8. allstub.dex stub%=100.0 erased%=0.0 min/erased=0 cksum=ok sig=ok <-- SKELETON
|
||||
9. pruned.dex stub%=93.2 erased%=0.0 min/erased=344 cksum=ok sig=ok <-- SKELETON
|
||||
10. nopfilled.dex stub%=0.0 erased%=100.0 min/erased=5061 cksum=ok sig=ok <-- SKELETON
|
||||
verdict: ctrl.dex is the best-supported candidate for the original (1 copy(ies) in this set)
|
||||
```
|
||||
|
||||
The control wins and every skeleton sorts behind it. The verdict line now states the
|
||||
key and its limits instead of declaring a winner outright, and phrases the result as
|
||||
"best-supported candidate" with an instruction to confirm before patching. `--trim`,
|
||||
`--json` and `--find` are unchanged (verified: the recorded fixture still returns
|
||||
`01_original_shell.dex` first, `--json` only gained keys). Note the residual limit the
|
||||
report prints rather than hides: **`throwstub.dex` still ranks third**, because no body
|
||||
statistic here sees a `throw`-stub skeleton. That is a boundary, not a bug — it is
|
||||
listed in §7 with the reproduction.
|
||||
|
||||
### 3.4 A retracted Dex-VMP verdict — the measurement that killed it
|
||||
|
||||
An intermediate workbench script of mine
|
||||
(`tools/_work/bench/unpack/b4/vmp_probe.py`, `vmp_methods.py`) decoded instruction
|
||||
widths from a hand-written table and reported:
|
||||
|
||||
```
|
||||
code_items=22424 desync=1393 (6.21%) bad_op=0xf8 / 0xef / 0xea / …
|
||||
```
|
||||
|
||||
I read that as "bodies present but not decodable → private opcodes → Dex VMP". **The
|
||||
reading was wrong, and the error was my table's.** The official decoder settles it:
|
||||
|
||||
```
|
||||
$ E:\tools\android-14\dexdump.exe -d tools/_work/bench/unpack/b4/orig/classes.dex
|
||||
Processing 'orig\classes.dex'...
|
||||
Opened 'orig\classes.dex', DEX version '035'
|
||||
… 943,223 lines …
|
||||
instruction lines (hex:...) : 348566
|
||||
code items resolved ('insns size :') : 22424
|
||||
code '(none)' : 1179
|
||||
```
|
||||
|
||||
One of the methods my script called a desync decodes cleanly under `dexdump`:
|
||||
|
||||
```
|
||||
--- androidx.appcompat.app.AlertController$ButtonHandler.<init>:(Landroid/content/DialogInterface;)V
|
||||
insns size : 11 16-bit code units
|
||||
1fd564: 7010 9b05 0100 |0000: invoke-direct {v1}, Landroid/os/Handler;.<init>:()V
|
||||
1fd56a: 2200 040e |0003: new-instance v0, Ljava/lang/ref/WeakReference;
|
||||
1fd56e: 7020 fb76 2000 |0005: invoke-direct {v0, v2}, Ljava/lang/ref/WeakReference;.<init>:(Ljava/lang/Object;)V
|
||||
1fd574: 5b10 ed08 |0008: iput-object v0, v1, …$ButtonHandler;.mDialog:…;
|
||||
1fd578: 0e00 |000a: return-void
|
||||
```
|
||||
|
||||
My script consumed 9 of 11 units and stopped on `0xed`; `0xed` is a legal
|
||||
`invoke-*/range` opcode. **The lesson is the one the reference file already states and
|
||||
this pass then failed to apply: a hand-rolled opcode table is not a VMP detector, and
|
||||
"my decoder desynchronised" is a statement about my decoder.** Any future
|
||||
"rare-opcode density" metric must be validated against `dexdump` on the same file
|
||||
before it is allowed to inform a verdict.
|
||||
|
||||
The corrected count of bodies that fail to decode as dalvik in this dex is **0**.
|
||||
Correction confirmed by an independent path: `dexdump` emits 34,566 instruction lines
|
||||
and reports no structural error for any of the 22,424 code items.
|
||||
|
||||
## 4. B4 — the Dex-VMP judgement chain, re-opened
|
||||
|
||||
### 4.1 Static findings (`observed`)
|
||||
|
||||
Target: `repos/DexPatcher/assets/ezAndroid.apk`, 2,984,841 B, sha256
|
||||
`76e3fa8119cf8b7c…`, package `<PKG>` (the `ezAndroid` CTF sample shipped in DexPatcher).
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/dex_dump_validate.py …/b4/orig/classes.dex
|
||||
classes=2905 methods_with_code=22424 stub%=1.9 cksum=ok sig=BAD
|
||||
```
|
||||
|
||||
- **Native declarations: 7**, all in the app's own obfuscated classes, each with no
|
||||
code (`code : (none)`), enumerated from `dexdump`:
|
||||
`Lcom/example/ezandroid/C;->cf(Ljava/lang/String;I)Z`,
|
||||
`Lcom/example/ezandroid/MU;->GMS(Ljava/lang/String;)Ljava/lang/String;`,
|
||||
`Lcom/example/ezandroid/S;->Stringfjni()Ljava/lang/String;`,
|
||||
`Lcom/example/ezandroid/S;->onCreate`, `Lcom/example/ezandroid/S2;->onCreate`,
|
||||
`Lcom/example/ezandroid/S$1;->onClick`, `Lcom/example/ezandroid/S2$1;->onClick`.
|
||||
- **Two JNI exports** in `lib/arm64-v8a/libezandroid.so` (206,904 B, aarch64):
|
||||
`Java_com_example_ezandroid_S_Stringfjni` @ `0xea6c` (184 B) and
|
||||
`Java_com_example_ezandroid_MainActivity_stringFromJNI` @ `0xe91c` (184 B).
|
||||
Both are thin JNI wrappers that construct a `std::string` and dispatch through a
|
||||
virtual call — the body of the check is behind the vtable, not in the wrapper.
|
||||
- **No `JNI_OnLoad` and no `RegisterNatives` symbol** anywhere in the library; the
|
||||
string `JNI_OnLoad` does not occur in the file at all.
|
||||
- **`assets/` holds six small opaque files** (`0OooOO` 118 B, `O0ooOO` 126 B,
|
||||
`oo0Ooo` 86 B, `ooO0oo` 132 B, `OOoo0O` 398 B, `OOooO0` 260 B). Each begins with a
|
||||
three-unit header (`05 00 38 00 02 00 …`) followed by 2-byte-aligned units.
|
||||
- **No dex string references those six filenames.** Grep over the full 943,223-line
|
||||
`dexdump` output for `0OooOO|O0ooOO|oo0Ooo|ooO0oo|OOoo0O|OOooO0|assets/` returns
|
||||
**0 hits**.
|
||||
|
||||
### 4.2 Verdict: JNI sinking, not Dex VMP (`observed`)
|
||||
|
||||
The reference file's Dex-VMP test is "the dump's method bodies are intact but decode
|
||||
as private opcodes — rare-opcode density off the charts, unknown opcode slots". On this
|
||||
target **zero method bodies fail to decode**, and every `native` declaration has a
|
||||
matching exported `Java_*`. The dex is not carrying an encoded program; the code left
|
||||
the dex for the `.so`, which is `java2c-and-jni-sinking.md`'s territory, not this
|
||||
file's. The two must not be conflated: an agent that reads "bodies decode as nonsense"
|
||||
here will go hunting a private disassembler for code that is ordinary ARM64 in a
|
||||
206 KB library.
|
||||
|
||||
The six `assets/` files are a separate, **unresolved** observation: they are not
|
||||
referenced by any dex string, so they cannot be load-bearing at the dex layer. Whether
|
||||
the native island reads them by constructing the path at runtime, and what they
|
||||
contain, is **unverified** here — it needs a dynamic run (§4.3), not more static
|
||||
searching.
|
||||
|
||||
### 4.3 What static analysis structurally cannot see (the B4 boundary)
|
||||
|
||||
`java2c`'s independent pass reached the same classification on this sample and recorded
|
||||
that the hardening takes effect **at runtime only**. That is the general shape, and it
|
||||
is the half of B4 that no dex probe can settle:
|
||||
|
||||
| Question | Answerable statically? | Why |
|
||||
|---|---|---|
|
||||
| Are the bodies real dalvik or private opcodes? | yes | `dexdump` decodes them or does not; measured 0 failures here |
|
||||
| Did the code leave the dex for native? | yes | `native` access flags + `Java_*` exports / `JNI_OnLoad` |
|
||||
| Does the native island check the signature / root / debugger? | **no** | the logic is ARM64 behind a vtable; the check may not exist in any dex |
|
||||
| Do those `assets/` blobs get decrypted and used? | **no** | the path may be assembled at runtime; grep for the literal names finds nothing |
|
||||
| Does a repack actually fail, and why? | **no** | needs the repack installed and run against a control (G4) |
|
||||
|
||||
**Rule this pass proposes for the reference file:** a static dex probe may return
|
||||
"this is *not* dex-VMP" when the bodies decode cleanly and every native declaration has
|
||||
an export. It may **not** return "this is not hardened" from the same evidence —
|
||||
those are different claims, and the second one is the misdiagnosis
|
||||
`java2c-and-jni-sinking.md` was written to stop.
|
||||
|
||||
### 4.4 Repack control — `observed` up to the install, `unverified` beyond
|
||||
|
||||
G4 requires a zero-change control through the same pipeline. Built with the
|
||||
repository's own repacker against an explicit toolchain (this host has no uber-apk-signer;
|
||||
`keytool` lives in the JDK that `ENV.md` does not list):
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py \
|
||||
--apk tools/_work/bench/unpack/b4/ezAndroid.apk \
|
||||
--out tools/_work/bench/unpack/b4/ez-ctrl.apk \
|
||||
--ks tools/_work/bench/unpack/b4/ctrl.jks --signer apksigner \
|
||||
--apksigner E:\tools\android-14\apksigner.bat \
|
||||
--zipalign E:\tools\android-14\zipalign.exe \
|
||||
--keytool "C:\Program Files\Java\jdk-17.0.4.1\bin\keytool.exe"
|
||||
[in ] E:\apk-reverse\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.** `E:\tools\android-14` ships `d8`, `aapt2`,
|
||||
`apksigner` and `zipalign` but **no `android.jar`**, and no `android.jar` exists
|
||||
anywhere on the machine (searched `E:\tools`, `C:\Program Files`, `E:\Android`). 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).
|
||||
@@ -0,0 +1,316 @@
|
||||
# Extension verification — Java2C identification and routing
|
||||
|
||||
Covers the 2026-09 extension pass that added `references/java2c-and-jni-sinking.md` and
|
||||
`scripts/java2c_probe.py`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output here), **inferred** (follows from observed facts,
|
||||
step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed).
|
||||
|
||||
Reference environment for this pass: Windows 11 host, Python 3.14.0, JDK 17,
|
||||
build-tools (`d8`/`aapt2`) from a local Android 14 SDK, `gcc` from MinGW only.
|
||||
**No Android NDK, no host `clang`, no `wsl` (HCS unavailable), and no device-side
|
||||
`clang`/Termux.** A rooted Android 11 / API 30 / arm64-v8a `<DEVICE>` was attached and
|
||||
reachable but **was not used at all** in this pass: every result below is static. No
|
||||
package name, project name or device serial appears in this record.
|
||||
|
||||
Samples are identified by hash rather than by name. They are public MASTG training apps
|
||||
(a no-native baseline, and two carrying a JNI sink), one VMP-labelled app taken from
|
||||
`tools/_work/bench/repos/`, one real third-party aarch64 ELF already on the host, and one
|
||||
locally synthesised Java2C-shaped dex.
|
||||
|
||||
---
|
||||
|
||||
## 1. The compile chain could not be closed — observed
|
||||
|
||||
The intended route was to build a real Java2C `.so` with `amimo/dcc` and probe it. `dcc`
|
||||
is present at `tools/_work/bench/repos/dcc/` and its Python dependencies are satisfied.
|
||||
The build stops at the C compiler, and every alternative was tried exactly once before
|
||||
being written off (two-strike rule — no third variant was attempted):
|
||||
|
||||
```
|
||||
$ which gcc clang
|
||||
gcc.exe C:\MinGW\bin\gcc.exe # MinGW targets PE/COFF, not ELF
|
||||
gcc/clang for Android: none
|
||||
|
||||
$ wsl -l -q ; wsl -e bash -lc "which gcc"
|
||||
Ubuntu-E
|
||||
(empty) -> exit 1 with: Wsl/Service/CreateInstance/CreateVm/HCS/HCS_E_SERVICE_NOT_AVAILABLE
|
||||
|
||||
$ adb shell "which clang clang++ gcc ; ls /data/data/com.termux"
|
||||
(no output for which)
|
||||
ls: /data/data/com.termux: No such file or directory
|
||||
```
|
||||
|
||||
Related **observed** fact: the `dcc` checkout ships **no prebuilt binary at all** — a
|
||||
recursive search for `*.so`, `*.apk`, `*.a` over the whole repo returns zero files. There
|
||||
is no ready-made Java2C library to fall back on.
|
||||
|
||||
Consequence: **no ELF file anywhere in this pass was produced by a Dex-to-C compiler.**
|
||||
Every Java2C-specific criterion in the reference document is therefore **inferred**.
|
||||
|
||||
## 2. Degenerate route A — `dcc` generates the C without an NDK — observed
|
||||
|
||||
`dcc.py` takes `--no-build`, and `dcc_main(..., do_compile=False)` writes the generated
|
||||
sources and the JNI project *before* `build_project(project_dir)` would be called. With
|
||||
`--no-build` and no `-o`, the apktool repack path is skipped entirely (it is guarded by
|
||||
`if is_apk(apkfile) and outapk`). So the translation stage runs to completion on a machine
|
||||
with no NDK.
|
||||
|
||||
```
|
||||
$ cd tools/_work/bench/repos/dcc
|
||||
$ python dcc.py <SAMPLE_DIR>/baseline.apk --no-build --dynamic-register \
|
||||
--source-dir <WORK>/dcc-out
|
||||
# (emits only Python 3.14 SyntaxWarnings from the bundled androguard)
|
||||
|
||||
$ find <WORK>/dcc-out -type f
|
||||
jni/Android.mk
|
||||
jni/Application.mk
|
||||
jni/nc/Dex2C.cpp jni/nc/Dex2C.h
|
||||
jni/nc/DynamicRegister.cpp jni/nc/DynamicRegister.h
|
||||
jni/nc/ScopedLocalRef.h jni/nc/ScopedPthreadMutexLock.h
|
||||
jni/nc/well_known_classes.cpp jni/nc/well_known_classes.h
|
||||
jni/nc/compiled_methods.txt
|
||||
jni/nc/Java_<PKG>_<Class>_<method>__<mangled-proto>.cpp
|
||||
```
|
||||
|
||||
This is the mapping evidence the pass needed, and it is exact rather than imagined:
|
||||
|
||||
`compiled_methods.txt` is a one-line dex signature, and it produced exactly one C file
|
||||
whose name is the same identifier in JNI long form:
|
||||
|
||||
```
|
||||
L<PKG>/MainActivity;onCreate(Landroid/os/Bundle;)V
|
||||
-> Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2.cpp
|
||||
```
|
||||
|
||||
The generated function opens with the parameter shape the reference document cites as the
|
||||
`.so`-side signature of a translated method:
|
||||
|
||||
```c
|
||||
/* L<PKG>/MainActivity;->onCreate(Landroid/os/Bundle;)V */
|
||||
void Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2(JNIEnv *env, jobject thiz, jobject p2){
|
||||
jobject v0 = NULL;
|
||||
...
|
||||
jclass cls0 = NULL,cls1 = NULL,cls2 = NULL,cls3 = NULL;
|
||||
```
|
||||
|
||||
And the registration half is generated as real text, which is the strongest single piece
|
||||
of evidence produced in this pass that the "no `Java_*` symbol to search for" path is not
|
||||
hypothetical:
|
||||
|
||||
```c
|
||||
// DynamicRegister.cpp
|
||||
const char *dynamic_register_compile_methods(JNIEnv *env) {
|
||||
jclass clazz;
|
||||
clazz = env->FindClass("<PKG>/MainActivity");
|
||||
if (clazz == nullptr)
|
||||
return "Class not found: <PKG>/MainActivity";
|
||||
const JNINativeMethod export_method_0[] = {
|
||||
{"onCreate", "(Landroid/os/Bundle;)V", (void *)Java_<PKG>_MainActivity_onCreate__Landroid_os_Bundle_2}
|
||||
};
|
||||
env->RegisterNatives(clazz, export_method_0, 1);
|
||||
env->DeleteLocalRef(clazz);
|
||||
return nullptr;
|
||||
}
|
||||
```
|
||||
|
||||
Two further facts read out of the generated `jni/Application.mk`, both **observed** and
|
||||
both load-bearing for the JNI-boundary section of the reference document:
|
||||
|
||||
```
|
||||
APP_STL := c++_static
|
||||
APP_CPPFLAGS += -fvisibility=hidden
|
||||
APP_ABI := armeabi-v7a arm64-v8a
|
||||
```
|
||||
|
||||
`-fvisibility=hidden` means the shipped library can present as "exports no `Java_*`
|
||||
symbol" **even when the translation used static naming** — the second, independent reason a
|
||||
symbol search returns nothing. `c++_static` is the origin of the `libc++` string criterion,
|
||||
and is also why that criterion is weak: it is an ordinary NDK setting.
|
||||
|
||||
## 3. The probe against real JNI-sinking targets — observed
|
||||
|
||||
This is the discriminating measurement the reference document is built on. The tool was
|
||||
run unmodified:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/jni-a.apk
|
||||
classes.dex: 720084 B, 625 classes, 5081 methods, native 2 (0.04%),
|
||||
native-dominated classes 0, fully native 0
|
||||
libfoo.so: 14176 B, aarch64, dynsym 20 entries, Java_* 2, JNI_OnLoad False
|
||||
libfoo.so: 13948 B, arm, dynsym 26 entries, Java_* 2, ...
|
||||
libfoo.so: 13788 B, x86, dynsym 16 entries, Java_* 2, ...
|
||||
libfoo.so: 14440 B, x86_64, dynsym 16 entries, Java_* 2, ...
|
||||
verdict : jni-sinking
|
||||
confidence : medium
|
||||
```
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/jni-b.apk
|
||||
classes.dex: 1934860 B, 1396 classes, 11182 methods, native 3 (0.03%),
|
||||
native-dominated classes 0, fully native 0
|
||||
libfoo.so (arm/arm64: 3 Java_* symbols, x86/x86_64: 3 Java_* symbols)
|
||||
verdict : jni-sinking
|
||||
confidence : medium
|
||||
```
|
||||
|
||||
Two independent real targets, and both give the same two results: **native density three
|
||||
orders of magnitude below the Java2C shape**, and **an exact 1:1 between the dex native
|
||||
declarations and the exported `Java_*` symbols** (2:2 and 3:3, across four ABIs each).
|
||||
|
||||
That 1:1 is the point. It is *not* a Java2C signal on its own — both shapes produce it —
|
||||
which is why the density measurement and the native layer must be read together. A tool
|
||||
that reported on either alone would misroute one of these two samples.
|
||||
|
||||
Baseline control, same command, no native code at all:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --apk <SAMPLE_DIR>/baseline.apk
|
||||
classes.dex: 5528 B, 7 classes, 15 methods, native 0 (0.00%), ...
|
||||
verdict : no-hardening-signal
|
||||
confidence : medium
|
||||
```
|
||||
|
||||
## 4. The probe against a Java2C-shaped dex — observed
|
||||
|
||||
No Java2C library could be built, so the dex half of the shape was synthesised. A Java
|
||||
source declaring two classes whose methods are almost entirely `native` (plus one mixed
|
||||
class, standing in for an ordinary JNI boundary) was compiled with `javac --release 8` and
|
||||
assembled with `d8 --min-api 19`:
|
||||
|
||||
```
|
||||
$ javac --release 8 -d classes src/<PKG>/Calc.java # exit 0
|
||||
$ d8 --min-api 19 --output out classes/<PKG>/*.class # exit 0
|
||||
out/classes.dex 1752 B sha256 59e0b1d34f00578e1343ee5f344d0e58829f482b643a7843c8ad280311ce9457
|
||||
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --dex out/classes.dex
|
||||
classes.dex: 1752 B, 3 classes, 29 methods, native 24 (82.76%),
|
||||
native-dominated classes 2, fully native 1
|
||||
verdict : java2c-suspect-needs-so
|
||||
confidence : low
|
||||
```
|
||||
|
||||
The low confidence is the correct answer, not a shortcoming: the tool refuses to claim
|
||||
Java2C from the dex alone, and says so in its route block. That refusal is the guard
|
||||
against the misdiagnosis this pass exists to prevent.
|
||||
|
||||
Combining that dex with a real library that registers dynamically makes the `java2c` branch
|
||||
reachable, which is what proves the branch is not dead code — the input is composite and is
|
||||
labelled as such:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/java2c_probe.py --dex out/classes.dex \
|
||||
--so <WORK>/libandroid.so
|
||||
verdict : java2c
|
||||
confidence : medium
|
||||
evidence: (strong) native layer: dynamic registration in libandroid.so
|
||||
(JNI_OnLoad exported, zero Java_* symbols)
|
||||
```
|
||||
|
||||
## 5. The JNI boundary — why `RegisterNatives` cannot be found — observed
|
||||
|
||||
The reference document claims a symbol search fails silently and that looking for a
|
||||
`RegisterNatives` symbol is structurally unable to work. Both are measured here.
|
||||
|
||||
A real 273,120 B aarch64 system library pulled from one of the samples exports a JNI entry
|
||||
point and no `Java_*` symbols, and the probe flags it as dynamic registration — the
|
||||
affirmative check that does work:
|
||||
|
||||
```
|
||||
libandroid.so: 273120 B, aarch64, dynsym 504 entries, Java_* 0,
|
||||
JNI_OnLoad True, RegisterNatives-import False
|
||||
(strong) native layer: dynamic registration in libandroid.so
|
||||
(JNI_OnLoad exported, zero Java_* symbols)
|
||||
```
|
||||
|
||||
Independent confirmation that the absence is real and not a parser gap — the same check run
|
||||
outside the script:
|
||||
|
||||
```
|
||||
$ python -c "d=open('gadget.so','rb').read(); [print(k, d.count(k.encode())) for k in
|
||||
['JNI_OnLoad','RegisterNatives','Java_','Dex2C','libc++_shared.so','frida']]"
|
||||
JNI_OnLoad 0
|
||||
RegisterNatives 0
|
||||
Java_ 0
|
||||
Dex2C 0
|
||||
libc++_shared.so 0
|
||||
frida 311
|
||||
```
|
||||
|
||||
A 25.8 MB real third-party aarch64 ELF with 311 occurrences of its own name contains zero
|
||||
`JNI_OnLoad` and zero `Java_`. The probe reported the same (0 `Java_*`, `JNI_OnLoad False`),
|
||||
so the two readings agree and the probe is not reporting a parse failure as an absence.
|
||||
|
||||
**Why the `RegisterNatives` search cannot fire** `[observed]`: the library above registers
|
||||
dynamically and contains **zero literal `RegisterNatives` symbols**. The cause is a
|
||||
documented property of the NDK C++ headers `[inferred]`: `jni.h` declares `RegisterNatives`
|
||||
as an inline member of `JNIEnv` that dispatches through the function table, so a call site
|
||||
emits no external symbol. The observed half is the missing symbol; the explanation is
|
||||
inferred from the mechanism.
|
||||
|
||||
## 6. Coverage of the probe's code paths — observed
|
||||
|
||||
| Path | Exercised by | Result |
|
||||
|---|---|---|
|
||||
| dex header + class_defs + class_data (ULEB128) | 5 real dex images + 1 synthetic | Parsed; header size match reported |
|
||||
| `direct_methods` / `virtual_methods` decoding | the synthetic fixture (has both) | Correct after the defect in §7 |
|
||||
| native density, native-dominated classes | all six | 0.03–0.04 % real vs 82.76 % synthetic |
|
||||
| no-`code_item` and trivial-body ratio | all six | 0 on the fixture, 4–5 % on the real apps |
|
||||
| ELF header, arch, section table | 10 real ELFs (4 ABI variants of two `libfoo.so`, 2 app libs, 1 gadget) | aarch64/arm/x86/x86_64 all identified |
|
||||
| `.dynsym` parse, `Java_*` extraction | real `libfoo.so` ×8 | 2 and 3 symbols, matching the dex exactly |
|
||||
| `JNI_OnLoad` export detection | real system library | True |
|
||||
| dynamic-registration verdict | same library | Fired |
|
||||
| string-signature scan | 25.8 MB ELF, all samples | Weak hits only (`dcc` ×6 on the gadget); no false strong hit |
|
||||
| `--json` | synthetic fixture | Valid JSON, metrics block parses |
|
||||
| `--dir`, `--apk`, `--dex`, `--so` | all four modes | OK |
|
||||
| `--help` | direct | OK |
|
||||
|
||||
The string scan produced exactly one class of hit across every sample: the weak `dcc`
|
||||
substring (6 occurrences inside a 25.8 MB binary, i.e. coincidental). **No strong marker was
|
||||
ever produced by any real sample**, which is why the Java2C verdict rests on structure
|
||||
rather than on strings.
|
||||
|
||||
## 7. Not exercised — and why the Java2C verdict stays inferred
|
||||
|
||||
- **A real Java2C `.so`.** Never produced; the compile chain is dead (§1). Everything about
|
||||
how a *compiled* Java2C library presents — whether the generated functions keep their
|
||||
names under `-fvisibility=hidden`, whether `Dex2C` runtime strings survive into `.rodata`
|
||||
— is **inferred** from the generated sources and the build flags, not observed.
|
||||
- **The runtime claim that no dex bytecode exists in memory.** This is the mechanism the
|
||||
whole document turns on and it is **inferred from the translation model**. No live Java2C
|
||||
app was run. It is falsifiable and cheap to test on a live sample; that test is named in
|
||||
the reference document.
|
||||
- **VMP identification by static dex metrics.** One VMP-labelled sample (sha256
|
||||
`76e3fa8119cf8b7c4b4768be557d71863433a8ae4d2ad40901eae6bb829349bc`, a 2,984,841 B APK
|
||||
from the local bench repos, identified by hash only)
|
||||
was measured and reported **`jni-sinking`**: its static dex has 23,603 methods of which 7
|
||||
are native, with a 4 % stub ratio, and the archive contains no additional encrypted
|
||||
payload. **This is a recorded boundary, not a success**: whatever hardening that sample
|
||||
carries is applied at runtime, and a static dex probe is structurally unable to see it.
|
||||
Do not read this row as "the sample is unhardened".
|
||||
- **Anything on a device.** The `<DEVICE>` was not used; there is no dynamic evidence in
|
||||
this record.
|
||||
|
||||
## 8. Two defects this pass found in its own tool — observed
|
||||
|
||||
Both were found by running the tool against the synthetic fixture and checking the numbers
|
||||
by hand, and both are fixed in the shipped script.
|
||||
|
||||
1. **`direct_methods` and `virtual_methods` were decoded as one continuous run.** They are
|
||||
two independent encoded lists; each one's first `method_idx_diff` is relative to 0.
|
||||
Walking them as one run mis-attributed every virtual method to the following class. The
|
||||
fixture exposed it as `Codec: 5 methods` where the source declares 4. Fix: decode them as
|
||||
two passes. After the fix the per-class counts match the source exactly (Calc 20/21
|
||||
native, Codec 3/3, Helper 1/2).
|
||||
|
||||
2. **The "whole class is native" criterion was unreachable.** `javac` — and, per the
|
||||
generated output, a translation pass — always leaves `<init>`/`<clinit>` behind, so no
|
||||
class is ever literally all-native unless a constructor is counted as native. The
|
||||
fixture showed `fully native 0` while two of its classes were plainly native-shaped. Fix:
|
||||
constructors are excluded and the test is now ">= 70 % of >= 3 named methods native",
|
||||
reported alongside the literal count. After the fix: `native-dominated classes 2,
|
||||
fully native 1`.
|
||||
|
||||
Both defects produced *plausible* output — a wrong count and a zero that reads as "nothing
|
||||
found" — which is the failure shape this whole repository is organised against. Recording
|
||||
them here because a reader relying on either number would have drawn a wrong conclusion.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Extension verification — kernel-level weapons (templates, gates, and a closed door)
|
||||
|
||||
Covers the extension pass that added `scripts/kernelsu_syscall_mask.py` and the `§4a` section of
|
||||
`references/kernel-and-environment-hardening.md`. Strength labels as defined in this directory's
|
||||
README: **observed** (exact command + output here), **inferred** (follows from observed facts,
|
||||
step not executed), **unverified** (assumed or externally reported, not independently confirmed).
|
||||
|
||||
Reference environment: Windows 11 host, Python 3.14.0; `<DEVICE>` = rooted Android 11 (API 30)
|
||||
arm64-v8a, Magisk alpha, no APatch/KernelPatch.
|
||||
|
||||
**Headline: the generator, the gate table and the userspace module skeleton are measured. No
|
||||
kernel-side artifact was compiled or loaded anywhere, and every route is closed on the reference
|
||||
device. That is the deliverable — a template plus a measured statement of the door.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Kernel gate, measured on the device — observed
|
||||
|
||||
```
|
||||
$ adb shell 'uname -r; cat /proc/version'
|
||||
4.14.186+
|
||||
Linux version 4.14.186+ (nobody@android-build) (Android (6443078 based on r383902) clang version
|
||||
11.0.1 (https://android.googlesource.com/toolchain/llvm-project b397f81060ce6d701042b782172ed13bee898b79),
|
||||
LLD 11.0.1 (/buildbot/tmp/tmp6_m7QH ...)) #1 SMP PREEMPT Wed Mar 30 23:32:42 CST 2022
|
||||
```
|
||||
|
||||
4.14.186 is below the 5.10 GKI gate for the eBPF route, and the device carries no
|
||||
KernelPatch-patched image. Read-only query; no device lock was needed and no state was changed.
|
||||
|
||||
## 2. Host toolchain probe — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py gates
|
||||
== kernel-side routes and their gates ==
|
||||
|
||||
eBPF tracepoint/kprobe GKI kernel 5.10+ (Android 12+)
|
||||
why: Android's BPF/tracing support as bpftrace-class tooling and
|
||||
stackplz expect it is a GKI-era feature; BTF is required for
|
||||
CO-RE style probes.
|
||||
check: uname -r -> expect 5.10.x or newer; ls /sys/kernel/btf/vmlinux
|
||||
|
||||
KernelPatch / APatch KPM KernelPatch-patched boot image + kpm toolchain
|
||||
why: KPMs load through kpimg injected into the kernel image's
|
||||
payload segment; building one needs a bare-metal ARM64
|
||||
compiler (aarch64-none-elf-gcc), not the NDK.
|
||||
check: ls /data/adb/ | grep -i apatch ; which aarch64-none-elf-gcc
|
||||
|
||||
Out-of-tree LKM Kernel source matching the device's exact version + vermagic
|
||||
why: A module built against a different kernel revision is refused
|
||||
at load time on a mismatched vermagic, and the device's kernel
|
||||
source is frequently not published at all.
|
||||
check: uname -r ; look for a matching kernel source tree for the device
|
||||
|
||||
seccomp-BPF (for contrast) any modern kernel, but it is not this route
|
||||
why: seccomp can make a syscall FAIL (SECCOMP_RET_ERRNO/TRAP); it
|
||||
cannot rewrite the CONTENT a successful read returns. It closes
|
||||
doors, it does not paint them -- which is the whole requirement
|
||||
for a spoofed /proc read.
|
||||
check: n/a -- this is a capability ceiling, not a version check
|
||||
|
||||
== host probe (what can be checked from here) ==
|
||||
|
||||
bare-metal ARM64 compiler: NOT FOUND
|
||||
the KPM route cannot be built from this host as configured
|
||||
ndk-build: NOT FOUND
|
||||
make: NOT FOUND
|
||||
|
||||
A kernel source tree, a patched boot image and the device's own kernel headers cannot be
|
||||
checked from the host; they are device facts.
|
||||
```
|
||||
|
||||
The three `NOT FOUND` lines are the measured reason the KPM route cannot be built from here, and
|
||||
they are also consistent with `ENV.md`'s record that the machine has no Android NDK. The LKM route
|
||||
additionally needs kernel source that does not exist for this device.
|
||||
|
||||
## 3. `generate` — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py generate \
|
||||
--out tools/_work/t5-vmp/sysmask --package '<PKG>'
|
||||
generated module scaffold in tools/_work/t5-vmp/sysmask
|
||||
module.prop 165 B
|
||||
customize.sh 166 B
|
||||
post-fs-data.sh 830 B
|
||||
service.sh 467 B
|
||||
uninstall.sh 298 B
|
||||
config/syscall_mask.json 1079 B
|
||||
kpm/syscall-mask.c 8096 B
|
||||
kpm/Makefile 880 B
|
||||
lkm/syscall-mask.c 2434 B
|
||||
ebpf/syscall_mask.bpf.c 4338 B
|
||||
README.md 3752 B
|
||||
|
||||
STATUS: the userspace module is installable; every kernel-side file is an UNVERIFIED template.
|
||||
No kernel code was compiled or loaded.
|
||||
```
|
||||
|
||||
`module.prop`, as generated (the package identity is the `<PKG>` placeholder by design — this file
|
||||
carries no real package name):
|
||||
|
||||
```
|
||||
id=syscall-mask
|
||||
name=Syscall Mask
|
||||
version=0.1.0
|
||||
versionCode=1
|
||||
author=apk-reverse
|
||||
description=config-driven syscall return masking (openat/read/stat on /proc/self/*)
|
||||
```
|
||||
|
||||
The configuration table rendered into the KPM template from `config/syscall_mask.json`, so the
|
||||
rule set is data rather than hand-edited C:
|
||||
|
||||
```
|
||||
static const struct mask_rule rules[] = {
|
||||
{ "/proc/self/maps", 0, MASK_DENY, -2, 0 },
|
||||
{ "/proc/self/maps", 0, MASK_FAKE_SIZE, -2, 0 },
|
||||
{ "/proc/self/status", "TracerPid", MASK_FILTER_LINE, -2, 0 },
|
||||
{ "/proc/self/status", "TracerPid", MASK_FILTER_LINE, -2, 0 },
|
||||
};
|
||||
```
|
||||
|
||||
And the KPM lifecycle surface, which is the part an interface drift would break first:
|
||||
|
||||
```
|
||||
int kfunc_def(strncpy_from_user)(char *dst, const char __user *src, long count);
|
||||
/* fp_hook_syscalln(__NR_openat, before_openat, 0, 0); */
|
||||
/* fp_hook_syscalln(__NR_newfstatat, before_newfstatat, 0, 0); */
|
||||
/* fp_hook_syscalln(__NR_read, before_read, 0, 0); */
|
||||
KPM_INIT(mask_init);
|
||||
KPM_EXIT(mask_exit);
|
||||
```
|
||||
|
||||
Those names (`KPM_NAME` / `KPM_INIT` / `KPM_EXIT`, `kfunc_def`, `fp_hook_syscalln`,
|
||||
`hook_fargs4_t`, `syscall_argn`, `args->skip_origin`, `args->ret`) are taken from a public KPM
|
||||
development write-up rather than invented — blackr0ck, *APatch KPM 开发:绕过 DirtySepolicy /
|
||||
Duck Detector 的 SELinux Root 检测*, [bbs.kanxue.com/thread-291665.htm](https://bbs.kanxue.com/thread-291665.htm),
|
||||
fetched 2026-02. **They are still `unverified` here**: nobody compiled against a `kpmodule.h` in
|
||||
this pass, and the generated template says so in its own header for exactly that reason.
|
||||
|
||||
The registration calls are left **commented out** in the template. A generated file that arms a
|
||||
kernel hook by default is a file that arms a kernel hook the first time somebody runs `make` in a
|
||||
hurry.
|
||||
|
||||
## 4. `verify` — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/kernelsu_syscall_mask.py verify --dir tools/_work/t5-vmp/sysmask
|
||||
== verifying tools/_work/t5-vmp/sysmask ==
|
||||
module.prop: author=apk-reverse, description=config-driven syscall return masking
|
||||
(openat/read/stat on /proc/self/*), id=syscall-mask, name=Syscall Mask,
|
||||
version=0.1.0, versionCode=1
|
||||
config: 4 rule(s), actions=['deny', 'fake_size', 'filter_line']
|
||||
kpm target present: Makefile, syscall-mask.c
|
||||
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
|
||||
lkm target present: syscall-mask.c
|
||||
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
|
||||
ebpf target present: syscall_mask.bpf.c
|
||||
status: UNVERIFIED TEMPLATE -- not compiled, not loaded
|
||||
kpm/syscall-mask.c: KPM_* lifecycle macros present, kfunc_def not extern
|
||||
|
||||
== 0 problem(s): structure is consistent ==
|
||||
(this says nothing about whether the kernel side compiles or loads)
|
||||
```
|
||||
|
||||
`verify` is deliberately read-only and deliberately narrow: it checks `module.prop` fields, the rule
|
||||
JSON, and the one static trap in the KPM template that has a documented consequence (an `extern`
|
||||
`kfunc_def` becomes `*UND*` and the loader refuses the module). It states on its last line what it
|
||||
does **not** establish, because "0 problems" on a template is the easiest sentence in this file to
|
||||
over-read.
|
||||
|
||||
### 4a. A false positive this check produced, and its fix — observed
|
||||
|
||||
The first run reported:
|
||||
|
||||
```
|
||||
== 1 problem(s) ==
|
||||
- syscall-mask.c declares kfunc_def with `extern`, which the loader rejects as an unknown symbol
|
||||
verify rc=1
|
||||
```
|
||||
|
||||
The template does not declare it with `extern` — its comment *explains* why `extern` is forbidden,
|
||||
and the check's own regex ran from that prose across to the real declaration's semicolon. A linter
|
||||
that reads comments as code produces reports nobody can act on. The fix strips `/* */` and `//`
|
||||
comments before any token check (`_strip_c_comments`). Both the false positive and the fix are
|
||||
recorded here rather than quietly repaired, because the failure mode — a check that fires on its
|
||||
own documentation — is one a reader will meet again.
|
||||
|
||||
## 5. The userspace module's ceiling — observed by construction, and worth stating plainly
|
||||
|
||||
`post-fs-data.sh` and `service.sh` in the generated skeleton do not load anything. That is not an
|
||||
omission: a KernelSU/Magisk/APatch-userspace module runs as root in the normal world and cannot
|
||||
change what a syscall returns. The generated `README.md` says this in its first paragraph, and
|
||||
`post-fs-data.sh` carries the reasoning in a comment:
|
||||
|
||||
```
|
||||
# The kernel side (if you built and loaded one) owns the syscall table; this
|
||||
# script's whole job is to make the configuration visible to it and to leave a
|
||||
# record that the module actually ran. Do not put a syscall hook here: this is
|
||||
# userspace, and nothing here can change what openat/read/stat return.
|
||||
```
|
||||
|
||||
The uninstall hook likewise refuses to unload a kernel module, because a silent unload during
|
||||
package removal is how a device ends up in a boot loop.
|
||||
|
||||
## 6. eBPF route — gate table and the capability ceiling
|
||||
|
||||
| Route | Gate | Check on the device | Status here |
|
||||
|---|---|---|---|
|
||||
| eBPF tracepoint/kprobe | GKI 5.10+ with tracing/BTF | `uname -r`; `ls /sys/kernel/btf/vmlinux` | **closed** — device is 4.14.186 |
|
||||
| seccomp-BPF | any modern kernel | n/a | not this route: it can only fail a syscall, not rewrite a successful read |
|
||||
| KernelPatch / APatch KPM | patched boot image + `aarch64-none-elf-gcc` | `ls /data/adb/ \| grep -i apatch`; `which aarch64-none-elf-gcc` | **closed** — neither present; patch-the-boot-image risk declined |
|
||||
| Out-of-tree LKM | kernel source matching vermagic | `uname -r` + a matching source tree | **closed** — no source for this device |
|
||||
|
||||
The probe template (`ebpf/syscall_mask.bpf.c`) is a `sys_enter_openat` tracepoint observer with a
|
||||
uid filter map and a ringbuf event, and it documents two dead ends in the file itself:
|
||||
`bpf_override_return()` only applies to `ALLOW_ERROR_INJECTION` functions, which raw syscall
|
||||
entries are not, and a tracepoint cannot rewrite a result at all. **A tracepoint observes; it does
|
||||
not spoof.** The honest eBPF shape for this job is therefore observation — which is still worth
|
||||
having, because `§5` of the reference file requires you to establish whether the target uses a raw
|
||||
`svc` or a libc call *before* designing any spoof.
|
||||
|
||||
## 7. What was not done
|
||||
|
||||
- **No kernel-side code was compiled.** Not the KPM, not the LKM, not the eBPF program. No
|
||||
`kpmodule.h`, no kernel headers, no `bpftool`, no bare-metal ARM64 compiler on this host.
|
||||
- **Nothing was loaded, flashed or activated on `<DEVICE>`.** No boot-image patch, no module load,
|
||||
no kernel modification. The task forbids it for good reason: a kernel module that faults takes
|
||||
the device down before it can report why.
|
||||
- **The KPM API surface is `unverified`.** The macro and helper names follow a public write-up; the
|
||||
argument arity of `fp_hook_syscalln` in particular is marked `TODO(verify-on-device)` in the
|
||||
generated source rather than guessed, because a wrong arity in a hook registration is a
|
||||
load-time failure at best.
|
||||
- **The struct-stat size offset is a TODO, not a value.** The `newfstatat` handler deliberately does
|
||||
not carry a magic offset: it is ABI-specific and copying one from a different architecture is how
|
||||
a spoof corrupts an unrelated field.
|
||||
- **`verify` establishes structure, not capability.** Its own output says so.
|
||||
@@ -0,0 +1,383 @@
|
||||
# Extension verification — schema-free protobuf decoding
|
||||
|
||||
Covers the pass that added `skills/apk-reverse/scripts/protobuf_decode_raw.py` and the measured half
|
||||
of `references/protocol-reverse.md` §1 ("The decoder that ships here"). Strength labels as defined in
|
||||
this directory's README: **observed** (exact command + output recorded here), **inferred** (follows
|
||||
from observed facts, step not executed), **unverified** (assumed or externally reported, not
|
||||
independently confirmed here).
|
||||
|
||||
Reference environment for this pass: Windows host, Python 3.14.0, `protobuf` **6.33.6**. No device
|
||||
was used (see §7) — every result below is host-side, offline, and reproducible with the standard
|
||||
library alone. Workbench artifacts live in `tools/_work/t5-proto/` (not part of the skill, and
|
||||
`tools/` is gitignored).
|
||||
|
||||
The delivered script imports **no** third-party module. `google.protobuf` is used here for one
|
||||
purpose only: to *generate* reference bytes from a real schema, so the hand-written decoder is
|
||||
checked against the reference implementation instead of against itself.
|
||||
|
||||
---
|
||||
|
||||
## 0. Summary of what this pass established
|
||||
|
||||
| Claim | Label |
|
||||
|---|---|
|
||||
| `protobuf_decode_raw.py` `--help`, `--selftest`, hex/file/stdin inputs, `--json`, `--max-depth`, `--reencode` all run on this host | observed |
|
||||
| 26/26 built-in known-answer checks pass (field decode **and** byte-exact round trip) | observed |
|
||||
| The decoder agrees field-by-field with a message serialized by `google.protobuf` 6.33.6, and its re-encode is byte-identical to `SerializeToString()` | observed |
|
||||
| A real AndroidX DataStore file written by `scripts/datastore_inject.py` decodes to the correct structure and round-trips byte-identically | observed |
|
||||
| Editing one value in the JSON tree and re-encoding produced a file that `datastore_inject.py` read back as the edited value | observed |
|
||||
| proto3 zero semantics: an implicit (no-presence) field set to 0 writes **nothing**; a presence-carrying field set to 0 writes its tag plus a zero byte | observed |
|
||||
| A packed field and a nested message are **both** legal readings of the same length-delimited bytes (measured on real encoded bytes, not argued) | observed |
|
||||
| `--split varint-length` is protobuf's `writeDelimitedTo` framing; handed a gRPC frame it silently produced 5 frames instead of failing | observed |
|
||||
| Decoding a **device-side** protobuf file (real app data read over adb) | **not done** (device lock held by another agent; §7) |
|
||||
| Any protobuf payload recovered from the benchmark APKs | **not done** — the one APK scanned carries no `.pb`/`.proto` resource (§7) |
|
||||
| `blackboxprotobuf` / `protobuf-inspector` behaviour | unverified (not installed on this host; the reference still says so) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Input forms, measured
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --help
|
||||
usage: protobuf_decode_raw.py [-h] [--hex STR] [--json] [--max-depth N]
|
||||
[--no-expand] [--split {none,varint-length}]
|
||||
[--reencode JSON] [--out PATH] [--check PATH]
|
||||
[--selftest]
|
||||
[input]
|
||||
... exit = 0
|
||||
```
|
||||
|
||||
Hex text, a binary file and stdin are all accepted, and the two stdin shapes are told apart without
|
||||
a flag:
|
||||
|
||||
```
|
||||
$ '0a220a176578616d706c655f6578706972795f65706f63685f6d7312072080b08fe6b277' \
|
||||
| python skills/apk-reverse/scripts/protobuf_decode_raw.py -
|
||||
input: stdin (hex text), 36 byte(s)
|
||||
frame 0 bytes 0..36 (36 B) status=end_of_buffer
|
||||
f1 len-delimited(34) view=nested_message
|
||||
...
|
||||
|
||||
$ cmd /c "python skills\apk-reverse\scripts\protobuf_decode_raw.py - < tools\_work\t5-proto\ds.preferences_pb"
|
||||
input: stdin (raw bytes), 81 byte(s)
|
||||
frame 0 bytes 0..81 (81 B) status=end_of_buffer
|
||||
```
|
||||
|
||||
Separators and escapes, in one command (two frames, `0x` prefixes, spaces, a `\xNN` escape):
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --hex "0x08 0x96 0x01 | \x12\x07testing"
|
||||
frame 0 bytes 0..3 (3 B) status=end_of_buffer
|
||||
f1 varint 150
|
||||
frame 1 bytes 3..12 (9 B) status=end_of_buffer
|
||||
f2 len-delimited(7) view=utf8_string
|
||||
...
|
||||
string: 'testing'
|
||||
```
|
||||
|
||||
`08,96,\n01` (commas and a newline) and the single token `0x089601` both decode to the same field.
|
||||
**This command is the one that found the script's first real defect — see §6.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Known-answer fixtures: `--selftest`
|
||||
|
||||
Twelve fixtures are built byte by byte inside the script (no external file, no schema), covering a
|
||||
canonical varint, a multi-byte varint, `uint64` maximum, two-level nesting, a packed field, fixed64
|
||||
and fixed32, an explicit zero, a deprecated group, a non-canonical varint, a 10-byte overflow, two
|
||||
top-level messages in one stream, and varint-length framing. Each is decoded with an expected value
|
||||
**and** passed through the round trip.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --selftest
|
||||
== known-answer fixtures (bytes produced by hand in this script) ==
|
||||
canonical varint 150 089601
|
||||
field 1 varint value PASS 150
|
||||
unsigned max uint64 08ffffffffffffffffff01
|
||||
field 1 varint value PASS 18446744073709551615
|
||||
nested two levels 089601120774657374696e671a060801120210072206038e029ea705...
|
||||
field 3 nested child count PASS 2
|
||||
deprecated group 3/4 43080544
|
||||
field 8 has children PASS 1
|
||||
non-canonical varint 08968100
|
||||
field 1 varint value PASS 150
|
||||
10-byte varint overflow 08ffffffffffffffffff7f
|
||||
field 1 varint overflow PASS True
|
||||
...
|
||||
== round trip: decode -> re-encode -> byte comparison ==
|
||||
canonical varint 150 MATCH (1 fields, 1 identical)
|
||||
nested two levels MATCH (7 fields, 7 identical)
|
||||
non-canonical varint MATCH (1 fields, 0 identical) re-encode emits the minimal
|
||||
form 089601, original 08968100, and the report states why
|
||||
varint length framing MATCH (7 fields, 7 identical)
|
||||
|
||||
== the two wire-format traps ==
|
||||
explicit zero bytes 2800 decodes to value 0; absent field decodes to no fields at all: PASS
|
||||
packed payload 038e029ea705 offers live=['bytes', 'packed_varint'], rejected=['nested_message',
|
||||
'utf8_string'] -> view=packed_varint : PASS
|
||||
|
||||
selftest: 26/26 PASS exit = 0
|
||||
```
|
||||
|
||||
The non-canonical fixture is the one deliberate exception: the re-encode is *expected* to differ
|
||||
(`08968100` -> `089601`, the minimal form), and the tool reports the difference with its reason
|
||||
rather than normalising it away silently. That is the honest form of "round trip": a difference that
|
||||
is explained is evidence; a difference that is not is a bug.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-check against the official protobuf runtime
|
||||
|
||||
`tools/_work/t5-proto/official_ref.py` builds a real `FileDescriptorProto` (`Outer` with int64 /
|
||||
string / message / repeated int32 (packed) / fixed64 / float / enum / proto3-optional / bytes /
|
||||
fixed32 fields, plus a nested `Inner`), serializes it with `SerializeToString(deterministic=True)`,
|
||||
and then asks the hand-written decoder to reproduce it.
|
||||
|
||||
```
|
||||
$ python tools\_work\t5-proto\official_ref.py
|
||||
== official runtime bytes (protobuf 6.33.6) ==
|
||||
58 B: 089601120774657374696e671a0508011201782206038e029ea705290807060504030201350000c03f380240004a06038e029ea70555efbeadde
|
||||
|
||||
== field-by-field agreement with the schema ==
|
||||
field 1 count == 150 PASS decoded 150
|
||||
field 2 name == "testing" PASS decoded 'testing'
|
||||
field 3 inner walked as a nested message PASS view=nested_message children=2
|
||||
field 4 nums read as packed [3,270,86942] PASS view=packed_varint values=[3, 270, 86942]
|
||||
field 5 stamp fixed64 hex 0807060504030201 PASS 0807060504030201
|
||||
field 6 ratio fixed32 hex 0000c03f (1.5f) PASS 0000c03f
|
||||
field 7 color enum == 2 PASS
|
||||
field 8 explicit_zero is present and 0 PASS
|
||||
field 9 blob hex 038e029ea705 PASS
|
||||
field 10 crc fixed32 hex efbeadde PASS efbeadde
|
||||
|
||||
== round trip against the official bytes ==
|
||||
re-encode is byte-identical to SerializeToString() PASS 10/10 fields identical
|
||||
no field was rewritten PASS []
|
||||
|
||||
official-runtime cross-check: 21/21 PASS exit = 0
|
||||
```
|
||||
|
||||
Field 5 is the useful false-friend: the decoder offers `as_double_repr` for those 8 bytes, and the
|
||||
official value is a `fixed64` integer. Nothing on the wire distinguishes them, which is why the
|
||||
candidate list exists instead of a single typed value.
|
||||
|
||||
### 3.1 The four-way tie, measured on real bytes
|
||||
|
||||
A really-encoded nested message — `Inner{id:1, tag:"x"}` inside field 3, inner payload `0801120178`
|
||||
— is reported as:
|
||||
|
||||
```
|
||||
nested message payload 0801120178 -> {'nested_message': 0.5, 'packed_varint': 0.5,
|
||||
'utf8_string': 0.35, 'bytes': 0.2}
|
||||
```
|
||||
|
||||
`0801120178` is simultaneously a valid two-field message **and** a valid five-element packed varint
|
||||
array. The decoder refuses to choose; the `tie:` line states that the displayed `view` is a display
|
||||
default. A real packed field (`2206038e029ea705`) yields `packed_varint 0.55` with
|
||||
`nested_message` **rejected** — the rejection is printed with its reason instead of being omitted.
|
||||
|
||||
### 3.2 proto3 zero — the reference sentence, corrected by measurement
|
||||
|
||||
The reference previously stated that `28 00` is byte-identical to an absent field. The runtime shows
|
||||
that the sentence needs splitting into two halves:
|
||||
|
||||
```
|
||||
implicit int64 count = 0 : 120178 (3 B)
|
||||
count never touched : 120178 (3 B)
|
||||
implicit (no presence) zero is byte-identical to absent PASS
|
||||
that byte string contains no tag for field 1 PASS
|
||||
optional int32 explicit_zero=0: 1201784000 (5 B)
|
||||
a presence-carrying field DOES put tag+0x00 on the wire PASS 1201784000
|
||||
the optional zero costs 2 bytes the implicit zero never costs PASS 5 B vs 3 B
|
||||
the decoder sees that 0 and flags the ambiguity PASS ["proto3_explicit_zero"]
|
||||
```
|
||||
|
||||
Read it as two separate facts:
|
||||
|
||||
1. For a field **without** presence, `value = 0` and "never set" are the same bytes (no bytes at
|
||||
all), so a field **missing** from a decode is not evidence that the value in use was 0.
|
||||
2. A `0` that **is** on the wire was emitted on purpose — a presence-carrying field (`40 00` here,
|
||||
field 8) or a hand-rolled writer. The decoder cannot tell which, so it flags the case.
|
||||
|
||||
`references/protocol-reverse.md` §1 consequence 3 now carries that correction.
|
||||
|
||||
---
|
||||
|
||||
## 4. A real AndroidX DataStore file
|
||||
|
||||
`scripts/datastore_inject.py` writes the real AndroidX Preferences protobuf container
|
||||
(`map<string, Value>`); the decoder has never seen that schema.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/datastore_inject.py --file tools\_work\t5-proto\ds.preferences_pb \
|
||||
--key example_expiry_epoch_ms --type long --value 4102444800000 \
|
||||
--extra example_flag=true:bool --extra example_name=hello:string
|
||||
[ok] wrote tools\_work\t5-proto\ds.preferences_pb (81 bytes)
|
||||
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\ds.preferences_pb
|
||||
input: tools\_work\t5-proto\ds.preferences_pb, 81 byte(s)
|
||||
frame 0 bytes 0..81 (81 B) status=end_of_buffer
|
||||
f1 len-delimited(34) view=nested_message
|
||||
...
|
||||
f1 len-delimited(23) view=utf8_string
|
||||
string: 'example_expiry_epoch_ms'
|
||||
f2 len-delimited(7) view=nested_message
|
||||
...
|
||||
f4 varint 4102444800000
|
||||
f1 len-delimited(18) view=nested_message <- the second map entry
|
||||
...
|
||||
```
|
||||
|
||||
The map-entry structure, the key strings and the `long` value all come out correctly with no schema;
|
||||
every length-delimited field still shows its ties (`nested_message` vs `packed_varint` vs
|
||||
`utf8_string`) because those ties are real.
|
||||
|
||||
### 4.1 Round trip, and an edit confirmed by the other tool
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\ds.preferences_pb --json > tools\_work\t5-proto\ds.tree.json
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --reencode tools\_work\t5-proto\ds.tree.json \
|
||||
--out tools\_work\t5-proto\ds.reenc.bin --check tools\_work\t5-proto\ds.preferences_pb
|
||||
re-encoded 81 byte(s) from 3 field(s)
|
||||
fields re-encoded byte-identically: 3/3
|
||||
written to tools\_work\t5-proto\ds.reenc.bin
|
||||
check against tools\_work\t5-proto\ds.preferences_pb (81 B, the whole file): MATCH exit = 0
|
||||
```
|
||||
|
||||
Then one value is edited in the JSON tree and re-encoded:
|
||||
|
||||
```
|
||||
editing field 4 from 4102444800000
|
||||
re-encoded 80 byte(s) from 3 field(s)
|
||||
fields re-encoded byte-identically: 2/3
|
||||
CHANGED field 1: expected 0a22...80b08fe6b277, got 0a21...20ffc7afa025 (the tree was edited)
|
||||
|
||||
$ python skills/apk-reverse/scripts/datastore_inject.py --in tools\_work\t5-proto\ds.edited.bin --list
|
||||
[in] tools\_work\t5-proto\ds.edited.bin (80 bytes)
|
||||
example_expiry_epoch_ms long=9999999999 (value=6 bytes)
|
||||
example_flag bool=True (value=2 bytes)
|
||||
example_name string='hello' (value=7 bytes)
|
||||
```
|
||||
|
||||
Two independent facts in one run: the file shrank 81 -> 80 B because a 6-byte varint became a
|
||||
5-byte one **and the enclosing length prefix was recomputed** (the re-encode rebuilds lengths, it
|
||||
does not copy them), and a different tool of this repository read the edited bytes back as the new
|
||||
value. A round trip that only agrees with itself would not have caught either.
|
||||
|
||||
---
|
||||
|
||||
## 5. Framing: `writeDelimitedTo` is not a gRPC frame
|
||||
|
||||
`tools/_work/t5-proto/framing_check.py`:
|
||||
|
||||
```
|
||||
message (20 B): 089601120774657374696e672206038e029ea705
|
||||
writeDelimitedTo form (21 B): 14089601120774657374696e672206038e029ea705
|
||||
varint-length framing: exactly one frame PASS got 1
|
||||
a second message makes two frames PASS
|
||||
gRPC frame (25 B): 0000000014089601120774657374696e672206038e029ea705
|
||||
decoded with --split varint-length: 5 frame(s): [(1, 1, 'end_of_buffer'), (2, 2, ...),
|
||||
(3, 3, ...), (4, 4, ...), (5, 25, ...)]
|
||||
gRPC frames are NOT varint-length framed (the split misfires) PASS got 5 frame(s)
|
||||
stripping the 5-byte gRPC prefix decodes the message correctly PASS field 1=150, field 4=[3, 270, 86942]
|
||||
'|' separates two independently framed messages PASS frames=2
|
||||
|
||||
framing check: 8/8 PASS exit = 0
|
||||
```
|
||||
|
||||
| Framing | Bytes in front of the message | `--split varint-length` |
|
||||
|---|---|---|
|
||||
| protobuf `writeDelimitedTo` | varint length | correct — 1 frame per message |
|
||||
| gRPC | 1 compression-flag byte + **4-byte big-endian** length | **wrong, and it does not fail**: the 4 zero bytes become four empty frames |
|
||||
|
||||
The same gRPC bytes decoded with **no** splitting reported `stray_end_group` (exit 1), and a
|
||||
`writeDelimitedTo` file decoded with no splitting reported the same error — that error is a *refusal*,
|
||||
whereas the wrongful split is a *silent reinterpretation*. Strip the 5-byte gRPC prefix yourself
|
||||
(`references/protocol-reverse.md` §3), and check the frame count either way.
|
||||
|
||||
A `--split varint-length` round trip compares **frame bodies only**, and says so:
|
||||
|
||||
```
|
||||
check against tools\_work\t5-proto\two_frames.bin (26 B, frame bodies only: 2 of 26 byte(s) are
|
||||
length prefixes (framing, not message data)): MATCH
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Defects these checks found (in the code, not in the target)
|
||||
|
||||
Four defects, three of them in the delivered script, all found by the checks above rather than by
|
||||
reading the code.
|
||||
|
||||
| # | Defect (observed) | How it surfaced | Fix |
|
||||
|---|---|---|---|
|
||||
| 1 | When two candidate readings tied, both were lowered to the same confidence as the `bytes` fallback, and the tie was then broken by **alphabetical order** — `bytes` sorts before `nested_message`, so a real nested message was never expanded and `--reencode` copied it as an opaque blob | `--selftest`: `nested two levels / field 3 nested child count` returned `0` instead of `2` | `bytes` now scores 0.2 and ties are broken by an explicit `VIEW_PRIORITY`, structural candidates first |
|
||||
| 2 | In the `\xNN` path, whitespace and commas were kept as data bytes, so `"08 96 01 | \x12\x07testing"` decoded the space after the bar as `field 4 varint 18` and then hit `invalid_wire_type` | the input-form check in §1 | whitespace and commas are stripped before unescaping, in that branch too |
|
||||
| 3 | Two fixtures were mis-designed, not the code: a bare packed stream and a length-prefixed frame were round-tripped as if each were one top-level message, which reported `FAIL` for correct behaviour | `--selftest` round-trip section | the packed fixture is now a field *value*; the framing fixture compares frame bodies |
|
||||
| 4 | The cross-check's own assertion expected `0800` for the explicit zero, but the field under test is number 8, whose tag is `0x40` — the official bytes were `...4000`, and the assertion, not the decoder, was wrong | `official_ref.py` | assertion compares `4000` and the 2-byte cost |
|
||||
| 5 | `--no-expand` did nothing: the expansion branch never consulted the flag, so a 7-level payload was still walked to the bottom | the deep-payload check below | the branch honours `--no-expand` and records `expansion: suppressed by --no-expand` on the field |
|
||||
| 6 | Hitting `--max-depth` was invisible: the depth cut-off only appeared as a `rejected` note inside one candidate, so the report showed a shallow tree with no statement that anything had been left unexamined | the same check | a `depth_limit` ambiguity is emitted, naming the path and the limit |
|
||||
|
||||
Defect 1 is worth its own line in any future reader's mind: a *heuristic* tie-break silently changed
|
||||
which bytes the tool re-emitted, and it was caught only because a fixture asserted a child count.
|
||||
Defects 5 and 6 are the other direction — the tool silently *did more* than asked, and silently
|
||||
*stopped* without saying so; both are ways a report can be read as complete when it is not.
|
||||
|
||||
### 6.1 The deep-payload check
|
||||
|
||||
A 7-level nesting (`08 01` wrapped six times in field 1, 14 bytes) is the fixture:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py tools\_work\t5-proto\deep.bin
|
||||
frame 0 bytes 0..14 (14 B) status=end_of_buffer
|
||||
[depth_limit] frame0.1.1.1.1.1.1 @2: a nested reading of this field was not attempted:
|
||||
--max-depth 5 reached. The bytes here may well be a message; re-run with a larger --max-depth
|
||||
before concluding that they are not
|
||||
|
||||
$ python ... tools\_work\t5-proto\deep.bin --max-depth 2
|
||||
[depth_limit] frame0.1.1.1 @2: ... --max-depth 2 reached. ...
|
||||
|
||||
$ python ... tools\_work\t5-proto\deep.bin --no-expand
|
||||
expansion: suppressed by --no-expand
|
||||
```
|
||||
|
||||
The truncation is reported as an ambiguity with the path of the field it stopped at, so "no nested
|
||||
message here" can never be read off a truncated tree.
|
||||
|
||||
---
|
||||
|
||||
## 7. What was **not** done
|
||||
|
||||
* **No device was used.** The pass intended to pull a real app's `.preferences_pb` from
|
||||
`<DEVICE>`: `python tools\_work\devlock.py acquire proto --timeout 1800` waited 120 s and timed out
|
||||
with the lock held by another agent (`held by bench for 297s`, note "L1 repack control+patched
|
||||
install & screen; L3 frida"). Per the two-strike rule the device source was dropped rather than
|
||||
contended for, and the real-file evidence comes from the DataStore file this repository's own tool
|
||||
writes (§4). Reading a **device-side** protobuf file with this decoder is therefore **unverified**.
|
||||
Device facts used elsewhere in this repository (attach-by-name, the broken `logd` channel) were
|
||||
not re-tested here.
|
||||
* **No protobuf payload was recovered from the benchmark APKs.** Scanning the available sample
|
||||
(`<PKG>`-style de-identified, 4,293,620 B, 435 zip entries) for `.pb` / `.proto` / `protobuf`
|
||||
resources found none; the MASTG crackmes carry no protobuf at all. So no wild-captured payload was
|
||||
decoded in this pass — `inferred` for any claim that a real traffic body looks like these fixtures,
|
||||
though §3's bytes come from the reference implementation and §4's from a real AndroidX container.
|
||||
* **No live traffic.** Nothing was proxied, so the gRPC section of the reference is exercised only
|
||||
as *framing* (§5), not as captured HTTP/2.
|
||||
* `blackboxprotobuf` / `protobuf-inspector` were not installed or compared; the reference still
|
||||
labels them unverified.
|
||||
* `--max-depth` is measured up to a 7-level fixture (§6.1); no real payload deeper than that was
|
||||
decoded, and no payload was decoded whose *only* reading is a deep message.
|
||||
|
||||
## 8. Artifacts produced by this pass
|
||||
|
||||
`tools/_work/t5-proto/` (not committed): `official_ref.py` (official-runtime cross-check, 21 checks),
|
||||
`framing_check.py` (8 checks), `ds.preferences_pb` (81 B, real DataStore container),
|
||||
`ds.tree.json` / `ds.edited.json` (decoded trees), `ds.reenc.bin` (byte-identical round trip),
|
||||
`ds.edited.bin` (80 B, edited value read back by `datastore_inject.py`), `two_frames.bin`,
|
||||
`frames.tree.json`, `frames.reenc.bin`.
|
||||
|
||||
Files changed in the repository by this pass: `skills/apk-reverse/scripts/protobuf_decode_raw.py`
|
||||
(new), and `skills/apk-reverse/references/protocol-reverse.md` (a new measured subsection under §1,
|
||||
the corrected proto3-zero wording in §1 consequence 3, and a new framing failure mode in §6).
|
||||
`SKILL.md` and `README.md` were deliberately not touched; the index rows for the new script are
|
||||
handed to the lead to register.
|
||||
@@ -0,0 +1,337 @@
|
||||
# Extension verification — split APK / App Bundle sets
|
||||
|
||||
Covers the pass that added split-set support to `scripts/repack.py` and wrote
|
||||
`references/split-apk.md`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output reproduced here), **inferred** (follows from observed
|
||||
facts, step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed).
|
||||
|
||||
Reference environment for this pass: Windows 11 host, Python 3.14.0, Java 17.0.4.1
|
||||
(`keytool` NOT on PATH — it was passed explicitly as
|
||||
`<jdk>/bin/keytool.exe`), `apksigner` 0.9 run from `lib/apksigner.jar`, `zipalign.exe` from the
|
||||
same build-tools layout. **No `uber-apk-signer` anywhere on the machine**, which is the condition
|
||||
that made the signing-route fallback a requirement rather than a nicety. Target: one rooted physical
|
||||
Android 11 (API 30) arm64-v8a device, written `<DEVICE>` throughout.
|
||||
|
||||
All package names, application directory names and file names are replaced with placeholders
|
||||
(`<PKG>`, `<SET>`, `<SET_A>`, `<SET_B>`). The measurements, byte counts, entry counts and outputs
|
||||
are unchanged; only the identifiers are.
|
||||
|
||||
Fixtures: two **real split sets already installed on the device**, copied out read-only
|
||||
(`adb pull` of the system directory; nothing on the device was uninstalled, replaced or modified).
|
||||
They were chosen because one exercises each side of the merge/resign decision:
|
||||
|
||||
| | Members | Shape |
|
||||
|---|---|---|
|
||||
| Set A | 8 | base + 7 density splits, every density split carrying its own resources |
|
||||
| Set B | 9 | base + 1 ABI split (native libraries only) + 7 density splits that carry a placeholder table and nothing else |
|
||||
|
||||
Neither set is third-party: `pm list packages -3` reported **zero** split sets among ~40 installed
|
||||
third-party packages, while four preinstalled Google components were split. That distribution is
|
||||
recorded because "pick a third-party split app off the device" is not reliably available.
|
||||
|
||||
## 1. Reading a set: what the members actually contain — observed
|
||||
|
||||
`pm path <PKG>` on each package returned one line per member (8 and 9 lines respectively), which is
|
||||
how the targets were identified in the first place:
|
||||
|
||||
```
|
||||
$ adb shell 'pm path <PKG>'
|
||||
package:/system/priv-app/<DIR>/<SET>.apk
|
||||
package:/system/priv-app/<DIR>/<SET>-arm64_v8a.apk
|
||||
package:/system/priv-app/<DIR>/<SET>-hdpi.apk
|
||||
... (one line per member)
|
||||
```
|
||||
|
||||
`scripts/repack.py --split-mode analyze` over set A (identifiers replaced):
|
||||
|
||||
```
|
||||
== split set: 8 apk(s), 6629510 bytes total
|
||||
density <SET_A>-hdpi.apk 49623 B split=config.hdpi res=39 entries arsc=9392
|
||||
density <SET_A>-ldpi.apk 45501 B split=config.ldpi res=39 entries arsc=9392
|
||||
density <SET_A>-mdpi.apk 45501 B split=config.mdpi res=39 entries arsc=9392
|
||||
density <SET_A>-tvdpi.apk 74227 B split=config.tvdpi res=78 entries arsc=15124
|
||||
density <SET_A>-xhdpi.apk 53754 B split=config.xhdpi res=39 entries arsc=9432
|
||||
density <SET_A>-xxhdpi.apk 57878 B split=config.xxhdpi res=39 entries arsc=10324
|
||||
density <SET_A>-xxxhdpi.apk 57876 B split=config.xxxhdpi res=39 entries arsc=11200
|
||||
base <SET_A>.apk 6245150 B split=- dex=classes.dex res=581 entries arsc=1924488 assets=1
|
||||
[plan] base = <SET_A>.apk (package=<PKG>)
|
||||
[plan] MERGE BLOCKED: merging is not legal for 7 of 7 splits: config.hdpi, config.ldpi, config.mdpi,
|
||||
config.tvdpi, config.xhdpi, config.xxhdpi, config.xxxhdpi carry their own resources, and folding those in
|
||||
means merging resources.arsc (a resource-compiler job). Use --split-mode resign, or pass
|
||||
--drop-split-resources to accept losing exactly those resources
|
||||
[plan] resign = legal: one keystore for all 8 members, install the set with
|
||||
[result] analyze only: nothing was written. Pass --split-mode merge|resign|auto to act on this set.
|
||||
```
|
||||
|
||||
And over set B:
|
||||
|
||||
```
|
||||
== split set: 9 apk(s), 1411272 bytes total
|
||||
abi <SET_B>-arm64_v8a.apk 255861 B split=config.arm64_v8a libs=arm64-v8a
|
||||
density <SET_B>-hdpi.apk 16797 B split=config.hdpi arsc=40
|
||||
... (6 more density members, all 16797 B, all arsc=40)
|
||||
base <SET_B>.apk 1037832 B split=- dex=classes.dex res=2 entries arsc=71316 assets=1
|
||||
[plan] base = <SET_B>.apk (package=<PKG>)
|
||||
[plan] note: every non-base split carries only code/native libraries, so a merged single APK is legal
|
||||
[plan] note: config.arm64_v8a provides libs for arm64-v8a -- keep only the ABIs the target device runs,
|
||||
selected with --abi
|
||||
[plan] merge = legal: 8 member(s) fold into the base
|
||||
```
|
||||
|
||||
Four facts this pass established with those numbers, and that the reference file now depends on:
|
||||
|
||||
- **The ABI member carries no `resources.arsc` at all** (set B: `libs=arm64-v8a`, no arsc column).
|
||||
Its two `.so` files are the whole payload. This is the member that folds cleanly.
|
||||
- **Each density member carries its own table and its own `res/` entries** (set A: 39-78 entries,
|
||||
9.0-15.1 KB tables). `resources.arsc` is per member, not per set — the base's 1.88 MB table does
|
||||
not contain the density variants.
|
||||
- **A `resources.arsc` can be a 40-byte placeholder.** All seven density members of set B carry
|
||||
`arsc=40` and zero `res/` entries, while still being legally required to carry a table. A
|
||||
"has an arsc → owns resources" test would have blocked a merge that is entirely legal here, which
|
||||
is why the script's test is *`res/` entries or a table over 1 KB*.
|
||||
- **Base and split share one package name**, read from the binary AXML `package` attribute of every
|
||||
member, and the base is identifiable as the member whose manifest carries **no `split` attribute**
|
||||
(set A: `split=-`).
|
||||
|
||||
## 2. Unified re-signing — observed
|
||||
|
||||
`--split-mode resign` over set A with one keystore, run through the new `apksigner` route:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_A>/ --split-mode resign \
|
||||
--split-out-dir signed_set/ --ks key.jks \
|
||||
--keytool <jdk>/bin/keytool.exe --apksigner <build-tools>/lib/apksigner.jar \
|
||||
--zipalign <build-tools>/zipalign.exe
|
||||
[resign] signer route: apksigner
|
||||
[resign] <SET_A>-hdpi.apk -> signed_set/<SET_A>-hdpi.apk (41298 bytes)
|
||||
[resign] <SET_A>-ldpi.apk -> signed_set/<SET_A>-ldpi.apk (37202 bytes)
|
||||
[resign] <SET_A>-mdpi.apk -> signed_set/<SET_A>-mdpi.apk (37202 bytes)
|
||||
[resign] <SET_A>-tvdpi.apk -> signed_set/<SET_A>-tvdpi.apk (61642 bytes)
|
||||
[resign] <SET_A>-xhdpi.apk -> signed_set/<SET_A>-xhdpi.apk (41337 bytes)
|
||||
[resign] <SET_A>-xxhdpi.apk -> signed_set/<SET_A>-xxhdpi.apk (49563 bytes)
|
||||
[resign] <SET_A>-xxxhdpi.apk -> signed_set/<SET_A>-xxxhdpi.apk (49577 bytes)
|
||||
[resign] <SET_A>.apk -> signed_set/<SET_A>.apk (4172089 bytes)
|
||||
[result] OK: 8 members, one certificate (sha256 1eb31d9c…2c44)
|
||||
```
|
||||
|
||||
Every member's independent `apksigner verify --print-certs --verbose --min-sdk-version 21
|
||||
--max-sdk-version 34` returned `Verifies` with:
|
||||
|
||||
```
|
||||
Verified using v1 scheme (JAR signing): true
|
||||
Verified using v2 scheme (APK Signature Scheme v2): true
|
||||
Verified using v3 scheme (APK Signature Scheme v3): true
|
||||
Signer #1 certificate DN: CN=apkreverse, OU=dev, O=dev, L=NA, ST=NA, C=NA
|
||||
Signer #1 certificate SHA-256 digest: 1eb31d9c9c81104367a66a0201125aa91d28a6a09eb1dcb5029c36bc0bde2c44
|
||||
```
|
||||
|
||||
All eight printed the **same** SHA-256 — that, not per-file `Verifies`, is the acceptance
|
||||
criterion for a set, and it is the check the run performs explicitly.
|
||||
|
||||
**The signing-route fallback is the load-bearing part of this section.** With no
|
||||
`uber-apk-signer` on the machine, the pre-change script could not sign anything at all: it exited
|
||||
with `signer jar not found`, naming a jar the user never had. The new `--signer auto` resolves to
|
||||
`zipalign -p -f 4` + `apksigner sign --v1 --v2 --v3`, which produced the output above. `--apksigner`
|
||||
accepts `apksigner.bat` (verified: `rc=0`, `--version` → `0.9`), a `lib/apksigner.jar` value run
|
||||
through `java -jar` (verified: same), and the extensionless shell wrapper (which is a shell script
|
||||
and does not run on Windows — that is a fact about the wrapper, not about this script).
|
||||
|
||||
### The signing route this pass actually used — and why it had to change
|
||||
|
||||
`uber-apk-signer` is `repack.py`'s default signer, and it is **not installed on this machine**
|
||||
(no `uber-apk-signer.jar`, `APK_SIGNER_JAR` unset). Before this change that made every signed build
|
||||
impossible here: the script exited with `signer jar not found` and named a jar the user never had.
|
||||
What actually ran in all four measured routes:
|
||||
|
||||
| Item | Value used |
|
||||
|---|---|
|
||||
| signer | `zipalign.exe` + `apksigner`, the latter run from `lib/apksigner.jar` through `java -jar` |
|
||||
| `--apksigner` | `<build-tools>/lib/apksigner.jar`. A `.bat` value was also tested and works (`rc=0`, `--version` → `0.9`); the extensionless wrapper in the same layout is a shell script and does not run on Windows |
|
||||
| `--zipalign` | `<build-tools>/zipalign.exe` |
|
||||
| `--keytool` | `<jdk>/bin/keytool.exe` — **not on PATH**, and signing aborts without a way to create the keystore, so this flag is mandatory on such a machine |
|
||||
| scheme flags | `--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true` |
|
||||
|
||||
Alignment is handled **per member, and in this order**:
|
||||
|
||||
1. the script writes the archive itself, keeping `AndroidManifest.xml` and `resources.arsc` STORED
|
||||
and 4-byte-aligning `resources.arsc` plus uncompressed `lib/**`, then re-reads the file and
|
||||
prints the gate (`== alignment gate: resources.arsc STORED and 4-byte aligned (OK)`);
|
||||
2. `zipalign -p -f 4` runs over that output (`-p` adds the page alignment the loader wants for
|
||||
`extractNativeLibs="false"` libraries);
|
||||
3. `apksigner sign` runs **last** — it adds signature entries without reshuffling the archive,
|
||||
whereas a `jarsigner`-style rewrite after aligning destroys the alignment and the install is then
|
||||
refused with `[-124]`.
|
||||
|
||||
For a split set this is repeated for every member, which is why `resign_split_set()` de-signs and
|
||||
re-aligns each member individually before signing it: a single member whose `resources.arsc` is
|
||||
compressed or unaligned is refused by the installer on its own, and the resulting error names that
|
||||
member's package rather than the set.
|
||||
|
||||
## 3. Merging — observed, including the refusal
|
||||
|
||||
Set B, where every non-base member is code or native libraries:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_B>/ --split-mode merge \
|
||||
--out merged.apk --abi arm64-v8a --ks key.jks --apksigner <...>/lib/apksigner.jar
|
||||
[merge] + lib/arm64-v8a/lib<name>_compat_libc++.so (18944 bytes <- <SET_B>-arm64_v8a.apk)
|
||||
[merge] + lib/arm64-v8a/lib<name>utilsjni.so (206680 bytes <- <SET_B>-arm64_v8a.apk)
|
||||
[merge] folded: dex=0 libs=2 assets=0 conflicts=0
|
||||
[merge] DROPPED 7 entry/ies. This is a DOWNGRADED build -- say so when you deliver it:
|
||||
[zip] classes.dex 872160 bytes method=8 crc=<...>
|
||||
[zip] resources.arsc STORED offset=0x581fc aligned
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
[sign:zipalign] rc=0
|
||||
[sign:apksigner] rc=0
|
||||
[out] merged.apk (603824 bytes)
|
||||
[verify:apksigner] rc=0 Verifies / v1 true / v2 true / v3 true
|
||||
[result] OK
|
||||
```
|
||||
|
||||
`dex=0` is correct rather than suspicious: the base owns `classes.dex` and no member of this set
|
||||
carries a second one, so no renumbering was needed. The `libs=2` fold is the whole point — a
|
||||
one-file APK that now carries the native libraries the ABI member used to provide separately.
|
||||
|
||||
Set A, where merging is **not** legal, refuses rather than producing a broken artifact:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_A>/ --split-mode merge \
|
||||
--out merged.apk --no-sign
|
||||
error: refusing to merge <SET_A>-hdpi.apk -- it carries its own resources (39 res entries,
|
||||
arsc=9392 bytes).
|
||||
Folding it in would require merging resources.arsc, which this script does not do.
|
||||
Either use --split-mode resign (recommended for a resource split), or pass
|
||||
--drop-split-resources to accept that the resources it owns are lost.
|
||||
exit code 1
|
||||
```
|
||||
|
||||
This is the enforcement the reference file promises: the refusal happens **before** any output is
|
||||
written (the `--out` path does not exist afterwards), and the escape hatch is explicit, printed, and
|
||||
required. The mechanism is **observed**; whether a build produced through that hatch merely loses
|
||||
its density variants or misrenders is **inferred** — it was not exercised, because the only sets
|
||||
available were a legal-merge set and a refused set, and manufacturing the third case would have
|
||||
meant hand-editing a resource table.
|
||||
|
||||
## 4. Single-APK behaviour is unchanged — observed
|
||||
|
||||
Both pre-existing paths were re-run through the same new signing route, on an unmodified sample
|
||||
APK from `tools/_work/bench/apks/`:
|
||||
|
||||
- **zero-change roundtrip** (`--apk … --out … --ks …`, no `--dexdir`): the run dropped exactly the
|
||||
three original signature artifacts (`META-INF/CERT.RSA`, `META-INF/CERT.SF`, `META-INF/MANIFEST.MF`),
|
||||
wrote a 60964-byte unsigned archive, signed to 70647 bytes, reported `== alignment gate:
|
||||
resources.arsc STORED and 4-byte aligned (OK)` and `[result] OK` with v1/v2/v3 all `true`;
|
||||
- **explicit dex replacement** (`--dexdir` supplying the same `classes.dex`, 5528 bytes):
|
||||
`[repl] classes.dex<-…/classes.dex`, the entry re-added at the same size and CRC, alignment gate
|
||||
OK, `[result] OK`.
|
||||
|
||||
Both were run with `--no-sign` as well, which exits 0 and reports `UNSIGNED (--no-sign)`. `--help`
|
||||
works, exits 0, and carries the split examples and the route-decision text; `--split-mode analyze`
|
||||
exits 0; a merge refusal exits 1; a failed verification would exit 2.
|
||||
|
||||
The output fix worth recording: `AndroidManifest.xml` used to be printed as `NOT ALIGNED` in the zip
|
||||
layout (it only has to be STORED, unlike `resources.arsc`). It now prints `-` for entries that need
|
||||
no alignment, so the report cannot be misread as a failing gate.
|
||||
|
||||
## 5. Install and launch on the device — **unverified**
|
||||
|
||||
**Not executed.** The device was held by a concurrent device experiment for the whole window
|
||||
available to this pass (`python tools/_work/devlock.py status` reported the lock held by another
|
||||
teammate for 1417 s, note naming its own install/launch and Frida work), and the lead's decision was
|
||||
to ship this gap labelled rather than wait for it. Heavy device work must be serialised
|
||||
(`tools/_work/ENV.md`), so the honest label for everything below is **unverified** — a claim about
|
||||
installability that was never installed is exactly the failure `references/verification.md` exists
|
||||
to prevent.
|
||||
|
||||
Prepared, and not run — the sequence is the one in `references/split-apk.md` §4, against the
|
||||
re-signed renamed fixture:
|
||||
|
||||
```
|
||||
adb install-multiple -r <signed_set>/*.apk
|
||||
adb shell cmd package resolve-activity --brief <PKG>
|
||||
adb shell am start -n <PKG>/<launcher-activity>
|
||||
adb shell 'dumpsys activity activities | grep -m2 ResumedActivity'
|
||||
adb exec-out screencap -p > launch.png
|
||||
```
|
||||
|
||||
What the pass *did* establish without a device, and what each fact does not cover:
|
||||
|
||||
- **The artifacts are installable in principle, not in fact.** All eight members of the re-signed set
|
||||
verify with v1/v2/v3 `true` and one identical SHA-256 (§2), and the merged single APK verifies the
|
||||
same way (§3, §4). Uniformity of the certificate and a passing `apksigner verify` are necessary
|
||||
conditions for a set install; the platform installer accepting the set is the step that was not run.
|
||||
- **The negative control was not run either.** The signature-conflict text quoted in
|
||||
`references/split-apk.md` §6 (a pulled set reinstalled under its own package name) is the
|
||||
platform's documented shape and the shape a competing tool reports; no captured output from
|
||||
`<DEVICE>` backs it.
|
||||
- Every statement about **what the archives contain** — split roles, arsc ownership, the 40-byte
|
||||
placeholder tables, the fold set, the refusal — is **observed** and device-independent.
|
||||
- Every statement about **what the package manager does with them** is **inferred**.
|
||||
|
||||
**What closing it needs:** one device window that is not shared, then the sequence above (about three
|
||||
minutes of device time). The two things most likely to show up there, and already accounted for in
|
||||
the script: an OEM installer interception on `adb install-multiple` (there is a root
|
||||
`pm install-multiple` path for it) and a launcher activity that needs resolving rather than guessing.
|
||||
|
||||
## 6. Maintenance gate — observed
|
||||
|
||||
Run at the repository root with the change in place:
|
||||
|
||||
```
|
||||
$ python check_repo.py
|
||||
== result: 1 problem(s)
|
||||
- skills/apk-reverse/scripts/dex_strpatch.py --help crashed:
|
||||
|
||||
$ python check_refs.py
|
||||
== section references: 242 reachable ==
|
||||
== result: 0 dangling, 0 warning(s)
|
||||
```
|
||||
|
||||
`check_refs.py` is clean, which covers the three `references/repack-and-sign.md` §-references the new
|
||||
file adds, and `check_repo.py` reports one problem in a **different** file belonging to a different
|
||||
task; `repack.py` parses and answers `--help` with exit 0.
|
||||
|
||||
`check_repo.py` requires every `references/*.md` to be named in `SKILL.md` or the root `README.md`,
|
||||
and this task was not permitted to edit either, so the reachability step was pre-checked in a **copy**
|
||||
of the repository (`tools/_work/repo-check/`, workbench only, never in the tree) by running the gate
|
||||
twice on one snapshot with the index lines as the only variable:
|
||||
|
||||
```
|
||||
without the index lines: == result: 5 problem(s) ... UNLISTED references/split-apk.md
|
||||
with the index lines: == result: 4 problem(s) ... (the UNLISTED line is gone)
|
||||
```
|
||||
|
||||
The new file therefore contributes nothing to the gate once listed. The listing itself was added to
|
||||
`README.md` by the lead, and the run quoted above is the result.
|
||||
|
||||
## 7. Deliberately not done — and what stays unverified
|
||||
|
||||
- **No `.aab` was processed.** A bundle input would need `bundletool` (absent here, and it is the
|
||||
tool that owns the correct resource merge). This pass only ever handled maps of an *installed* set.
|
||||
- **No fixture was hand-built with `aapt2`/`d8`.** Those became available on this machine only after
|
||||
the fixtures were chosen (a build-tools archive was unpacked late in the round), and the two real
|
||||
sets already exercised both branches of the decision. Building a minimal split set by hand remains
|
||||
the cheapest way to reproduce this topic on a machine with no installed bundle and no device, and
|
||||
the route is **inferred** — it was not attempted here. It would not change the resource-merge
|
||||
boundary either: `aapt2 link` merges resources when it *builds* an APK from sources, not when it is
|
||||
handed two finished ones.
|
||||
- **No resource merge was implemented or attempted**, by design
|
||||
(`references/split-apk.md` §5). The consequence is that a bundle whose resources live in splits
|
||||
cannot be reduced to one faithful APK by anything in this repository.
|
||||
- **`apktool` was not exercised, because it is not usable on this machine.** Measured:
|
||||
`apktool.bat d …` fails with `Error: Unable to access jarfile <missing-path>`, while
|
||||
`lib/apksigner.jar` and `zipalign.exe` in the same layout work. Any manifest edit that needs
|
||||
`apktool` is therefore **unverified** here, and AXML attributes are read-only in this pass (the
|
||||
script parses the string pool and the root element's attributes; nothing writes AXML).
|
||||
- **Feature splits (`isFeatureSplit`) were not measured.** Neither available set has one. The
|
||||
handling of `isFeatureSplit` / `configForSplit` in the reference file is **inferred** from the
|
||||
platform's documented semantics plus the fact that both attributes are readable through the same
|
||||
AXML reader that was verified against real manifests.
|
||||
- **Split APKs that are not App-Bundle-derived** (the older per-density `-hdpi.apk` naming style,
|
||||
or an OEM's own module set) were not available; both sets here use the standard `config.<qualifier>`
|
||||
split names, and the reader accepts either because it reads the manifest attribute rather than the
|
||||
file name.
|
||||
- **A XAPK/APKS archive** (a store bundle of bundles, usually a zip holding the split set plus
|
||||
`manifest.json`) was not processed. The script's `--split-dir` recursion handles a *directory* of
|
||||
APKs, so an unpacked XAPK works; the archive itself is not read.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Stalker `exclude` — what it fixes, and what it does not
|
||||
|
||||
**Question.** Community practice says a followed thread that runs through `libc`/`libart` on arm64
|
||||
gives deadlocks, watchdog `SIGABRT`s and zero-event traces, and that `Stalker.exclude()` is the
|
||||
remedy. This pass measured which of those two claims survives contact with a device.
|
||||
|
||||
**Strength summary:** the *crash* half is `observed` with a clean control; the *zero-event* half is
|
||||
`observed` as a **negative** — exclusion did not change it. Every command and its output are below.
|
||||
|
||||
## Design
|
||||
|
||||
Three arms, one device, one package, one module, one follow window. Exactly one variable moves
|
||||
between `baseline` → `control` → `treatment`.
|
||||
|
||||
| Arm | What it does | Why it exists |
|
||||
|---|---|---|
|
||||
| `baseline` | spawn, attach, inject the script, **resume, and never follow** | Without this arm a dead process cannot be attributed: "following killed it" and "the app dies under frida anyway" look identical |
|
||||
| `control` | follow the main thread for 6 s with `excludeModules: []` | The shape that produced the earlier zero-event / `SIGSEGV` results |
|
||||
| `treatment` | same follow, with the script's built-in list of 20 system modules excluded | The proposed remedy |
|
||||
|
||||
The harness also neutralises one trap in the script itself: `CONFIG.autoStart` defaults to `true`, so
|
||||
loading `stalker_trace.js` starts a follow with *default* options before a runtime configuration can
|
||||
land. Left alone, that run is neither the control nor the treatment and contaminates both. The
|
||||
harness patches `autoStart` to `false` in the injected source and asserts the patch count.
|
||||
|
||||
For the same reason the harness resumes explicitly: `frida` spawns a process **suspended**, so
|
||||
without `device.resume(pid)` the followed thread executes nothing and both arms report a zero-event
|
||||
trace *manufactured by the harness*. That failure mode is indistinguishable from the one under
|
||||
investigation, which is exactly why it has to be engineered out before the measurement means
|
||||
anything.
|
||||
|
||||
## Result
|
||||
|
||||
```
|
||||
baseline alive=True (no follow installed)
|
||||
control alive=False err=script has been destroyed
|
||||
EXCL|excluded=0/0 []
|
||||
treatment alive=True DONE|reason=timeout blocks=0 blk=0 calls=0 truncated=0
|
||||
EXCL|excluded=20/20 [libm.so,libdl.so,libart.so,libartbase.so,libnativehelper.so,…] not-loaded=3
|
||||
WARN|zero events 1500ms after follow (blocks=0 blk=0 calls=0) -- the pipeline is NOT proven
|
||||
```
|
||||
|
||||
## What this settles
|
||||
|
||||
1. **A follow with no exclusion killed the target process, and the baseline arm rules out the
|
||||
alternatives.** The process survives an attach plus resume with no follow, and dies only when the
|
||||
follow is installed. That is `observed`, not inferred.
|
||||
2. **Exclusion kept the process alive.** The treatment arm reached its normal `DONE|reason=timeout`
|
||||
with the process still running. Excluding the system libraries from a follow is not a style
|
||||
preference.
|
||||
3. **Exclusion did not restore event delivery.** The treatment arm still reported
|
||||
`blocks=0 blk=0 calls=0` after a full 6-second window. The zero-event trace is therefore a
|
||||
**separate defect from the load problem**, and `Stalker.exclude()` must not be presented as its
|
||||
fix. This partially contradicts the community framing, which lists "no events" among the symptoms
|
||||
exclusion cures.
|
||||
4. **The zero-event warning added to `stalker_trace.js` this pass fires correctly** — `treatment`
|
||||
emitted `WARN|zero events 1500ms after follow`, while `control` could not, because its process was
|
||||
already gone. A dead pipeline now announces itself instead of being read as "the code did not run".
|
||||
|
||||
## What this does not settle
|
||||
|
||||
- **Why events do not arrive.** This measurement says it is not the exclusion list. It does not
|
||||
identify the cause, and it does not distinguish "no events" from "the followed thread genuinely ran
|
||||
nothing in that window" — the per-arm control that would separate those (following a thread
|
||||
executing a known loop) was not run.
|
||||
- **Whether the crash is reachable without a follow.** Only one package and one module were used, and
|
||||
the harness kills the process at the end of each arm, so nothing is claimed about a long-lived follow.
|
||||
- **Anything about other ROMs, other frida versions, or armv7.** Single-device result.
|
||||
- **The multiplier.** Nothing here measures the 20-50x cost figure; this is about survival and
|
||||
delivery, not throughput.
|
||||
|
||||
## Environment
|
||||
|
||||
`<DEVICE>` (Android 11 / API 30 / arm64-v8a, Magisk root), frida host 16.7.19, on-device
|
||||
frida-server 16.7.19 reached over `adb forward tcp:27099`. Target: the MASTG `UnCrackable-Level1`
|
||||
sample, package `<PKG>`, module `libc.so` followed on the main thread for 6 s per arm. Device
|
||||
`load average` was ~30 throughout — high for an 8-core phone, and worth recording because the
|
||||
earlier incident in `native-dbi-and-deobfuscation.md` §6 happened on a loaded device.
|
||||
|
||||
## Reproducing
|
||||
|
||||
```
|
||||
python tools/_work/b6/stalker_ab.py --package <PKG> --module libc.so \
|
||||
--mode baseline|control|treatment --seconds 6 --device 127.0.0.1:27099 \
|
||||
--out tools/_work/b6/r_<mode>.json
|
||||
```
|
||||
|
||||
The driver is a work artefact under `tools/_work/` (git-ignored), not part of the skill. Its three
|
||||
arms and the two harness traps it neutralises are the reusable part.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Extension verification — differential hardening for Dex-VMP
|
||||
|
||||
Covers the extension pass that added `references/vmp-differential-analysis.md` and
|
||||
`scripts/vmp_diff_harness.py`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output here), **inferred** (follows from observed facts,
|
||||
step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed).
|
||||
|
||||
Reference environment: Windows 11 host, Python 3.14.0, JDK 17.0.4.1
|
||||
(`C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe`), Android build-tools 2.19 / D8
|
||||
8.2.2-dev off-PATH. **No device and no network were involved in this pass** — unlike the
|
||||
unpacking and rootdump passes, this one is deliberately machine-local, because the thing
|
||||
worth measuring here is the comparison logic, not the platform.
|
||||
|
||||
**Headline: the local half of the route is measured and closes exactly; the
|
||||
platform half was not exercised at all, and the cost table says why it cannot be.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Environment discovery — observed, and one correction to `tools/_work/ENV.md`
|
||||
|
||||
`ENV.md` records `E:\tools\android-14\` as a complete build-tools directory. At pass time
|
||||
it held **three** entries:
|
||||
|
||||
```
|
||||
$ Get-ChildItem 'E:\tools\android-14\' -Name
|
||||
apksigner.bat
|
||||
lib
|
||||
zipalign.exe
|
||||
```
|
||||
|
||||
`aapt2.exe`, `d8.bat` and `dexdump.exe` were absent, so the `build` subcommand had no
|
||||
toolchain. The complete build-tools tree was sitting unextracted in `E:\tools\bt.zip`:
|
||||
|
||||
```
|
||||
$ (bt.zip entries) | Where-Object { $_.Name -match '^(aapt2|d8|dexdump|split-select)' }
|
||||
android-14/aapt2.exe 4566296
|
||||
android-14/d8.bat 3156
|
||||
android-14/dexdump.exe 986392
|
||||
android-14/split-select.exe 1589016
|
||||
android-14/lib/d8.jar 14929869
|
||||
```
|
||||
|
||||
After `Expand-Archive -Path 'E:\tools\bt.zip' -DestinationPath 'E:\tools\' -Force`:
|
||||
|
||||
```
|
||||
$ E:\tools\android-14\aapt2.exe version
|
||||
Android Asset Packaging Tool (aapt) 2.19-10229193
|
||||
|
||||
$ E:\tools\android-14\d8.bat --version
|
||||
D8 8.2.2-dev (build facedf41bbd28b563d1e9e09c5f72d7c5ca598d8 from go/r8bot (luci-r8-custom-ci-focal-14-zjz7))
|
||||
```
|
||||
|
||||
Two further tool facts worth recording, both **observed**:
|
||||
|
||||
- **`E:\agb_apktool.jar` does not exist**, so `E:\tools\bin\apktool.bat` (which is
|
||||
`java -jar "E:\agb_apktool.jar" %*`) resolves to nothing. apktool is unavailable on this
|
||||
machine, and a bare `apktool d` fails with a jar-not-found error rather than a
|
||||
recognisable "tool missing" message.
|
||||
- `javac` is on PATH only through the Oracle `javapath` forwarder; the real JDK is at
|
||||
`C:\Program Files\Java\jdk-17.0.4.1`. The harness takes `--javac` explicitly for this
|
||||
reason.
|
||||
|
||||
## 2. `build` — observed
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/vmp_diff_harness.py build \
|
||||
--build-tools E:\tools\android-14 --out tools/_work/t5-vmp/fixture \
|
||||
--javac "C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe" --apk
|
||||
javac: C:\Program Files\Java\jdk-17.0.4.1\bin\javac.exe
|
||||
d8: E:\tools\android-14\d8.bat
|
||||
aapt2: E:\tools\android-14\aapt2.exe
|
||||
generated: ...\src\OpCoverBig.java (37196 B)
|
||||
|
||||
$ ... javac -g --release 11 -d ...\classes ...\OpCoverProbe.java ...\OpCoverBig.java
|
||||
javac ok: 9 class files
|
||||
|
||||
$ E:\tools\android-14\d8.bat --min-api 26 --no-desugaring --output ...\dex <9 .class paths>
|
||||
d8 ok: ...\dex\classes.dex (35028 B)
|
||||
== ...\classes.dex (35028 bytes) ==
|
||||
classes=9 methods_with_code=75 instructions=3065 desynced=0
|
||||
opcode coverage: 218/224 (97.3%)
|
||||
not emitted by this fixture (6):
|
||||
0x09 move-object/16 move-object/16 (32x) needs both registers >= 256; the object
|
||||
frame reaches 256 on the source side only, which d8 encodes as 0x08
|
||||
0x1b const-string/jumbo needs a string_ids index > 65535, i.e. a fixture carrying
|
||||
65536+ string constants
|
||||
0x2a goto/32 needs a >32767-code-unit backward jump; javac refuses the method
|
||||
first with 'code too large' (its own 64 KB per-method bytecode cap)
|
||||
0xfd invoke-custom/range d8 emitted the 35c form for every lambda shape tried, including
|
||||
a six-parameter one
|
||||
0xfe const-method-handle no Java-language literal; reachable only by hand-written smali
|
||||
or direct dex construction
|
||||
0xff const-method-type same as 0xFE
|
||||
|
||||
$ E:\tools\android-14\aapt2.exe link --manifest ...\apk\AndroidManifest.xml -o ...\apk\base.apk
|
||||
aapt2 ok: ...\probe.apk (20482 B, unsigned)
|
||||
build rc=0
|
||||
```
|
||||
|
||||
Structural facts behind those numbers: **0 desynced methods** (every `decode_all` walk
|
||||
ended exactly on `insns_off + insns_size*2`, i.e. the format table and the emitted stream
|
||||
agree), 3,065 instructions across 75 method bodies, 9 classes.
|
||||
|
||||
### 2a. The two toolchain traps this measured — observed
|
||||
|
||||
Both are encoded in the script so they are not re-derived:
|
||||
|
||||
- **`d8` refuses a non-existent `--output` directory.** First run:
|
||||
`Error: Invalid output: ...\dex / Output must be a .zip or .jar archive or an existing
|
||||
directory / Compilation failed` and rc=1. Same class of trap as `frida-dexdump -o`.
|
||||
`build` creates the directory first.
|
||||
- **`aapt2 link` needs `android.jar` for every `android:` attribute.** A manifest carrying
|
||||
`android:versionCode` / `android:versionName` / `android:minSdkVersion` produced five
|
||||
`error: attribute android:... not found` lines and rc=1. A manifest using only the
|
||||
un-namespaced `package` attribute links cleanly with no `-I` flag at all — which is what
|
||||
the harness ships, at the cost of an APK with no version attributes. That is acceptable
|
||||
for a dex carrier and is stated in the output.
|
||||
|
||||
### 2b. `goto/32` is unreachable from Java — observed
|
||||
|
||||
Driving the generator to 33,000 loop-body statements to push the backward jump past the
|
||||
20t range:
|
||||
|
||||
```
|
||||
$ python tools/_work/t5-vmp/gen_big_probe.py 33000
|
||||
generated ...\OpCoverBig.java: 34442 lines, 766468 bytes
|
||||
javac rc=1
|
||||
OpCoverBig.java:1427: error: code too large
|
||||
public static int gotoFar(int x) {
|
||||
1 error
|
||||
```
|
||||
|
||||
The JVM's own 64 KB per-method bytecode limit fires before the dex branch-distance
|
||||
threshold can be crossed. `goto/32` therefore cannot be covered by any javac-built
|
||||
fixture, at any generator size — this is a structural ceiling, not a tuning problem.
|
||||
|
||||
## 3. Closed loop — observed, and this is the load-bearing result
|
||||
|
||||
`simulate` relabels every opcode through a bijection whose ground truth it writes to disk.
|
||||
`compare` is then given only the two dex files and must re-derive that table.
|
||||
|
||||
```
|
||||
$ python .../vmp_diff_harness.py simulate .../fixture/dex/classes.dex \
|
||||
--out tools/_work/t5-vmp/sim.dex --seed 7 --table tools/_work/t5-vmp/truth.json
|
||||
simulated: ...classes.dex -> ...sim.dex
|
||||
mode=relabel, 3065 method body(ies)/instruction(s) touched (seed 7)
|
||||
header recomputed: checksum_ok=True signature_ok=True
|
||||
ground-truth table written to ...truth.json (compare must re-derive it)
|
||||
|
||||
$ python .../vmp_diff_harness.py compare .../fixture/dex/classes.dex tools/_work/t5-vmp/sim.dex
|
||||
aligned=75 resized=0 stripped=0 missing=0 added=0
|
||||
aligned slots compared: 3065
|
||||
verdict: USABLE
|
||||
(map: 218 rows, every one verdict=high)
|
||||
compare rc=0
|
||||
```
|
||||
|
||||
Scoring the derivation against the ground truth:
|
||||
|
||||
```
|
||||
ground-truth bijection size : 256
|
||||
opcodes the fixture emits : 218
|
||||
compare rows : 218
|
||||
compare high-confidence rows : 218
|
||||
compare undetermined : 6
|
||||
compare non-injective : 0
|
||||
compare run verdict : usable
|
||||
|
||||
derived entries that are WRONG : 0 (none)
|
||||
derived entries with no truth : 0 []
|
||||
emitted opcodes NOT derived : 0 []
|
||||
|
||||
EXACT RECOVERY: 218/218 emitted opcodes (100.0%)
|
||||
CLOSED-LOOP VERDICT: IDENTICAL
|
||||
```
|
||||
|
||||
Why the denominator is 218 and not 256: the ground-truth bijection covers all 256 byte
|
||||
values, but 32 of those are unallocated dex slots and 6 are opcodes this fixture never
|
||||
emits — and an opcode that never appears leaves no known plaintext. The six are reported
|
||||
as `undetermined` with their reasons, never guessed. **Every entry the run did produce was
|
||||
correct, and every opcode that could be read was read.**
|
||||
|
||||
## 4. Negative shapes — observed
|
||||
|
||||
The decision table's failure branches were driven on purpose, because a detector that has
|
||||
never been seen to fire is not a detector.
|
||||
|
||||
**Stub shape** (`--mode stub`: every body replaced by an equal-length `return-void` fill —
|
||||
the extraction shape):
|
||||
|
||||
```
|
||||
$ python .../vmp_diff_harness.py compare .../classes.dex tools/_work/t5-vmp/stub.dex --quiet
|
||||
aligned=75 resized=0 stripped=0 missing=0 added=0
|
||||
aligned slots compared: 3065
|
||||
verdict: NOT-USABLE
|
||||
! 1 private byte(s) are claimed by up to 218 original opcodes each. No injective
|
||||
relabelling can do that: it is the signature of method bodies replaced by one
|
||||
common stub, and any substitution read off such a pair describes the stub, not a
|
||||
private opcode space.
|
||||
rc=1
|
||||
```
|
||||
|
||||
This is the failure mode worth reading twice. In the per-opcode direction the reading is
|
||||
**perfect** — 218 rows, all `high`, zero conflicts — because every original opcode maps to
|
||||
the same private byte. Only the reverse (`private byte → set of originals`) check exposes
|
||||
it. An implementation without that check would have returned a confident, wholly
|
||||
meaningless table, and the first version of this one did exactly that: measured before the
|
||||
fix, the same input returned `verdict: PARTIALLY-USABLE` alongside a note reading
|
||||
"0 of 218 opcodes saw more than one candidate byte" — a warning that contradicted itself.
|
||||
|
||||
**Length-changing shape** (`--mode shrink`: `insns_size` rewritten without moving bytes, a
|
||||
deliberately inconsistent image that exists only to drive the detector):
|
||||
|
||||
```
|
||||
$ python .../vmp_diff_harness.py compare .../classes.dex tools/_work/t5-vmp/shrink.dex --quiet
|
||||
aligned=0 resized=75 stripped=0 missing=0 added=0
|
||||
aligned slots compared: 0
|
||||
verdict: NOT-APPLICABLE
|
||||
! 75 method(s) kept a body but changed its length. Boundaries cannot be projected
|
||||
across a length change; a private opcode stream with its own length table is not
|
||||
reachable this way.
|
||||
rc=1
|
||||
```
|
||||
|
||||
Regression after both fixes: the relabel loop still reports `USABLE` and the closed-loop
|
||||
check still reports `IDENTICAL` — recorded above, re-run after the verdict-logic change.
|
||||
|
||||
**Not driven:** the `stripped` shape (`code_off == 0`) needs a class-data rewrite rather
|
||||
than a byte edit, so its detector path is **inferred** from the shape counters, not
|
||||
observed firing. The counters themselves (`stripped=0` in every run above) are observed.
|
||||
|
||||
## 5. `emit-smali` — observed
|
||||
|
||||
```
|
||||
$ python .../vmp_diff_harness.py emit-smali tools/_work/t5-vmp/sim.dex \
|
||||
--table tools/_work/t5-vmp/cmp.json --class LOpCoverProbe --method arithInt
|
||||
; instructions restored: 3065 ; unmapped-opcode stops: 0
|
||||
; THIS IS A READING SKELETON, NOT ASSEMBLABLE SMALI.
|
||||
...
|
||||
.class public LOpCoverProbe;
|
||||
.super Ljava/lang/Object;
|
||||
.source "OpCoverProbe.java"
|
||||
|
||||
# ---- arithInt(II)I registers=4 insns_size=29
|
||||
.method public static arithInt(II)I
|
||||
.registers 4
|
||||
# 0x0000 90000203 add-int
|
||||
# 0x0004 d000e803 add-int/lit16
|
||||
# 0x0008 d8000007 add-int/lit8
|
||||
# 0x000c b130 sub-int/2addr
|
||||
...
|
||||
# 0x0038 0f00 return
|
||||
.end method
|
||||
```
|
||||
|
||||
Two checks that the restore is real rather than plausible: the stream decodes to the
|
||||
**original fixture's** `arithInt` body (compare it against `OpCoverProbe.java`'s 17
|
||||
operations), and the payload-bearing `switchPacked` method also restores cleanly —
|
||||
`packed-switch` followed by its payload block, stepped over by its own layout rather than
|
||||
mistaken for opcodes. `unmapped-opcode stops: 0` is the honest coverage measure, and it is
|
||||
zero only because the table is complete for this fixture.
|
||||
|
||||
Three defects were found and fixed by this run, each one a silent-wrong-answer shape:
|
||||
|
||||
- `--class LOpCoverProbe;` matched **zero** classes because the filter accepted only one of
|
||||
the three spellings (`LOpCoverProbe;` / `LOpCoverProbe` / `OpCoverProbe`) — output read
|
||||
exactly like a class that is not in the file.
|
||||
- `.source` printed `"<string_idx 34748 out of range>"`: `source_file_idx` sits at
|
||||
`class_def_item + 16`, not `+ 20` (that is `annotations_off`).
|
||||
- Methods rendered without access flags, so the skeleton was not readable as smali.
|
||||
|
||||
## 6. What was not done
|
||||
|
||||
- **No hardening platform was contacted.** The entire submission link of the chain is
|
||||
`unverified`, and its cost estimate in the reference file is reasoning about how those
|
||||
services are distributed (accounts, rate limits, download-page delivery, possible refusal
|
||||
of a synthetic fixture), not a measured upload. This is the pass's largest gap and it is
|
||||
not closeable from this machine.
|
||||
- **No real VMP sample was used.** `tools/_work/bench/repos/DexPatcher/assets/ezAndroid.apk`
|
||||
is recorded in `ENV.md` as a real Dex-VMP challenge sample and was **not** opened in this
|
||||
pass; the differential route needs a *matching hardened counterpart of the same build*,
|
||||
which no available artifact provides.
|
||||
- **The engine-behaviour assumption is inferred**: that a real VMP substitutes per-opcode
|
||||
at stable instruction length. `simulate` implements the weakest form of that assumption
|
||||
deliberately — it is the only form this harness can check itself against.
|
||||
- **Table stability across inputs is unverified.** Whether one fixture's table applies to
|
||||
another build of the same app, or survives a vendor version change, was not tested and is
|
||||
the reason the reference file says to re-derive per target.
|
||||
- **The `stripped` detector path did not fire** (see §4).
|
||||
- `--mode shrink` writes a structurally inconsistent dex by design; `dexutil.Dex.check()`
|
||||
will not reject it because it does not range-check `insns` against the file. It is a
|
||||
detector probe, is labelled as such in `--help`, and must not be treated as a build.
|
||||
@@ -46,6 +46,24 @@ evidence here, one file per topic, in the same three labels:
|
||||
| `EXTENSION-native-dbi.md` | Stalker: the zero-event boundary, the crash from following a hot libc export, and an offline protobuf round-trip that caught a defect in its own decoder |
|
||||
| `EXTENSION-kernel-ondevice.md` | The MT Manager APK MCP probe, the module-environment facts it could verify, and the kernel routes that this device's 4.14 kernel puts out of reach |
|
||||
|
||||
## Benchmark-pass record
|
||||
|
||||
The benchmark pass put **public targets** under the documented routes — `tests/benchmark.md` is the
|
||||
matrix, and these files are the evidence behind its rows. Same three labels. Several of these rows
|
||||
came back negative, and four of them overturned a conclusion this repository had already recorded;
|
||||
those are called out in the matrix rather than buried here.
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `EXTENSION-benchmark-l1-l3.md` | The first two matrix rows: the L1 equal-length patch -> repack -> re-sign chain with its on-screen behaviour change and its zero-change control, and the L3 time-to-death baseline, the two spawn/patch/detach orderings that cannot work, and the terminate path that patching the obvious death site does not stop |
|
||||
| `EXTENSION-extraction-shell-bench.md` | `dex_dump_validate.py` against an 11-variant skeleton set: the trivial-body ratio as a bimodal result rather than a threshold, the three script defects the variants exposed, and the ranking that pointed at a modified image until independent review caught it |
|
||||
| `EXTENSION-stalker-exclude.md` | Stalker exclusion measured across three arms: the no-follow baseline, the follow that kills the process, and the follow with 20 modules excluded that survives but still delivers zero events |
|
||||
| `EXTENSION-java2c.md` | Java2C against JNI sinking: native-declaration density roughly 2000x apart, why a `Java_*` symbol search fails silently, and the Dex-to-C build chain no toolchain on this host could complete |
|
||||
| `EXTENSION-vmp-diff.md` | The known-plaintext differential: a 218-of-224-opcode coverage fixture, a closed-loop check that recovered 218/218 mappings with zero fabrications, and the one link that cannot be automated |
|
||||
| `EXTENSION-kernel-weapons.md` | The generated KernelSU/APatch scaffold and its three kernel-side templates, all shipped unbuilt, plus the structural correction that a userspace module cannot change a syscall return value |
|
||||
| `EXTENSION-split-apk.md` | Two real split sets through both branches: unified re-signing, the merge that is refused by design, the merge that succeeds, and the signer chain this host actually needs |
|
||||
| `EXTENSION-protobuf-raw.md` | A schema-free decoder cross-checked against the official runtime and a real DataStore container, with the packed-boundary and proto3-zero ambiguities reproduced on real bytes |
|
||||
|
||||
## Adding a run
|
||||
|
||||
Record the tools, versions and exact commands, and label each claim. Keep target-specific facts out:
|
||||
|
||||
Reference in New Issue
Block a user