mirror of
https://codeberg.org/reverse-skill-lab/apk-reverse
synced 2026-11-15 20:04:22 +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:
@@ -77,8 +77,10 @@ measurements.
|
||||
- Deciding **what the deliverable should be when an APK is not an option** — a repack refused by
|
||||
several independent checks is *blocked*, not expensive, and the fallback ladder is a system-level
|
||||
module, a local RPC service, or an honest report with a stated boundary.
|
||||
- Telling a **real memory dump from an extraction-shell skeleton**, by measuring the trivial-body
|
||||
ratio instead of eyeballing it — and knowing which recovery route applies, including the root-side
|
||||
- Telling a **real memory dump from an extraction-shell skeleton** — and knowing that the
|
||||
trivial-body ratio is *bimodal rather than a threshold*: it reads ~100% for a bare `return-void`
|
||||
skeleton, but is blind to nop-cleared bodies, throw stubs and partial extraction, which are the
|
||||
shapes real shells actually use. Then knowing which recovery route applies, including the root-side
|
||||
dump for when `frida` itself is refused by the target.
|
||||
- **Calling a routine instead of reversing it** when reversing costs more than invoking: emulated
|
||||
execution on the host, or a live function service-ified over Frida RPC.
|
||||
@@ -91,6 +93,15 @@ measurements.
|
||||
native-side certificate pinning that ignores the system trust store.
|
||||
- Working **from the phone itself**: MT Manager's edit/repack/sign flow and its APK MCP surface,
|
||||
LSPosed Manager, and on-device data inspection, alongside the PC toolchain rather than instead of it.
|
||||
- Telling **Java2C apart from an extraction shell** before spending hours hunting a decrypted DEX that
|
||||
does not exist at any point in the process lifetime — the code was compiled into a `.so`, and the
|
||||
shapes are separated by a measured native-density difference of roughly 2000x, not by intuition.
|
||||
- Handling a build that arrives as a **split APK / App Bundle set**: reading the set off a device,
|
||||
signing every member with one keystore for `pm install-multiple`, or merging code/native members
|
||||
into a standalone APK when that is legal.
|
||||
- Working a **real Dex VMP** with the known-plaintext differential — which links can be automated and
|
||||
which cannot, what a compiled fixture can and cannot reach, and how to *prove* a derived
|
||||
private-opcode table instead of asserting one.
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -207,6 +218,19 @@ references/ loaded on demand, one topic each
|
||||
gate, and when to stop escalating
|
||||
on-device-tooling.md working from the phone itself: MT Manager edit/repack/sign and its APK MCP,
|
||||
LSPosed Manager, Termux+frida, on-device data inspection
|
||||
java2c-and-jni-sinking.md Java2C and JNI sinking, the two hardening shapes most easily confused
|
||||
with an extraction shell: the table that separates landing shell /
|
||||
extraction shell / VMP / Java2C / JNI sinking, why the code is in the
|
||||
`.so` and *never* in a dumped dex, and why a `Java_*` symbol search
|
||||
comes back empty (dynamic registration, `-fvisibility=hidden`)
|
||||
split-apk.md App Bundle / split APK sets: what the set is, pulling it off a device,
|
||||
merging into one APK vs signing the set as a unit, the install refusals
|
||||
and what each means, and making an installable fixture from a pulled set
|
||||
vmp-differential-analysis.md
|
||||
the known-plaintext differential for a real Dex VMP: which links can be
|
||||
automated and which cannot (the upload is the bottleneck), the coverage a
|
||||
compiled fixture can reach, how to *prove* a derived private-opcode
|
||||
table, smali generation, and when the route is closed
|
||||
scripts/ parameterized, path-agnostic
|
||||
doctor.py run this first: capability report + per-script runnability, finds
|
||||
tools installed off-PATH or as runnable jars, and surfaces the
|
||||
@@ -242,7 +266,9 @@ scripts/ parameterized, path-agnostic
|
||||
dart_disasm.py annotated windowed disassembly of Dart AOT code + B/BL caller index
|
||||
find_refs.py count callers of a method before patching it
|
||||
repack.py rebuild APK, strip only signatures, keep META-INF/services/, write a
|
||||
4-byte-aligned archive (resources.arsc STORED+aligned), sign, verify
|
||||
4-byte-aligned archive (resources.arsc STORED+aligned), sign, verify;
|
||||
also split APK / App Bundle sets: inventory, sign every member with one
|
||||
keystore, or merge code/native members into a standalone APK
|
||||
devsh.py quoting-safe ADB shell helper
|
||||
usb_net_proxy.py give an offline device network over USB
|
||||
datastore_inject.py encode/inject AndroidX DataStore preferences safely
|
||||
@@ -294,6 +320,19 @@ scripts/ parameterized, path-agnostic
|
||||
with an explicit diagnostic for the measured zero-event case
|
||||
mt_mcp_probe.py probe MT Manager's on-device APK MCP (Streamable HTTP, port 8787):
|
||||
JSON-RPC handshake plus the grouped tool inventory
|
||||
java2c_probe.py collect the evidence that separates Java2C from an extraction shell, a
|
||||
VMP and ordinary JNI sinking: native density and stub ratio from the dex,
|
||||
JNI_OnLoad / dynamic registration / toolchain strings from the `.so`,
|
||||
each item labelled strong/medium/weak
|
||||
protobuf_decode_raw.py schema-free protobuf decode: hex / file / stdin to a JSON tree, every
|
||||
length-delimited field kept as a candidate set with ties labelled rather
|
||||
than guessed, plus a byte-exact re-encode to check a round trip
|
||||
vmp_diff_harness.py build a labelled opcode-coverage fixture, derive a candidate private-
|
||||
opcode map from an original/hardened dex pair, verify the comparison in a
|
||||
closed loop, and render a restored stream as a smali skeleton
|
||||
kernelsu_syscall_mask.py generate a KernelSU/APatch syscall-masking scaffold: an installable
|
||||
userspace module skeleton plus KPM/LKM/eBPF kernel-side templates, each
|
||||
with its version gate and an explicit unverified label
|
||||
```
|
||||
|
||||
## Install
|
||||
@@ -383,7 +422,13 @@ tier of documented routes: **module-side delivery** when a repack is blocked,
|
||||
**extraction-shell recovery** and its VMP boundary, **emulation and live RPC** for
|
||||
calling rather than reading, **instruction-level tracing** against OLLVM, **protocol
|
||||
reversing** beyond REST, the **kernel-side route** map for when userspace hooking is
|
||||
provably out of reach, and **on-device tooling**. Unity/IL2CPP logic recovery,
|
||||
provably out of reach, and **on-device tooling**. A **benchmark pass** then put public
|
||||
targets under those routes (`tests/benchmark.md`): it added **Java2C discrimination** (the
|
||||
misdiagnosis that sends an agent hunting a decrypted DEX that never exists), **split APK /
|
||||
App Bundle handling**, **schema-free protobuf decoding**, a **Dex-VMP differential** harness,
|
||||
and **kernel-module templates with their version gates** — and it corrected two earlier
|
||||
claims, replacing the trivial-body *threshold* with a measured bimodal result and reopening
|
||||
a VMP verdict that a hand-written opcode table had got wrong. Unity/IL2CPP logic recovery,
|
||||
React Native/Hermes bytecode internals, and defeating a server-side authority are **not**
|
||||
covered, and the skill is written to say so and stop rather than apply the nearest
|
||||
documented procedure to a target it was not written for.
|
||||
@@ -402,6 +447,10 @@ Three qualifications that the Coverage section states in full and that belong he
|
||||
`docs/tool-verification/EXTENSION-*.md`, one file per topic, with its own strength note. The
|
||||
common shape there is *the tool was measured, the route was not* — so read those files before
|
||||
treating any of the newer documents as a verified path.
|
||||
- **The benchmark pass is recorded per row, with that row's own strength.** `tests/benchmark.md`
|
||||
names each public target, the scripts the row exercises, what actually happened (including the
|
||||
rows that failed and the rows nobody ran), and how strong the evidence is. Rows marked
|
||||
`unverified` are statements about the evidence in this repository, not about the mechanism.
|
||||
|
||||
## Repository maintenance
|
||||
|
||||
@@ -415,6 +464,11 @@ check_refs.py every cross-reference that names a section of another
|
||||
build_scripts.py audit for machine-specific leftovers (absolute paths, credentials)
|
||||
```
|
||||
|
||||
`tests/benchmark.md` holds the **regression matrix**: dimension -> public target -> the scripts the
|
||||
row exercises -> measured result -> strength label. It is the checklist to re-run before trusting
|
||||
any claim under `docs/tool-verification/`. Samples are downloaded into `tools/_work/` and are never
|
||||
committed, so each row names its public source and records the hash it was run against.
|
||||
|
||||
`docs/tool-verification/` is not part of the installed skill either. It is the evidence record
|
||||
for one measurement pass against a real target: what each script actually did, which independent
|
||||
method confirmed it, which defects were found, and which scenarios the target could not exercise.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -107,6 +107,13 @@ vocabulary and a set of reassuring numbers.
|
||||
- Packers, custom loaders and code virtualization: identifying them, measuring the validation
|
||||
boundary, and the routes that survive it (`packers.md`,
|
||||
`code-virtualization-and-custom-linkers.md`).
|
||||
- **Java2C** — recognising that the code left the dex entirely, and the evidence that separates it
|
||||
from an extraction shell, a VMP and ordinary JNI sinking (`java2c-and-jni-sinking.md`,
|
||||
`scripts/java2c_probe.py`). Measured: `native` declaration density was **0.03-0.04%** in three real
|
||||
JNI samples against **82.76%** in a Java2C-shaped fixture — roughly 2000x — and the `Java_*` symbol
|
||||
count matched the dex native-method count 1:1 per ABI. **No library produced by a Dex-to-C compiler
|
||||
was built here** (no NDK, no host clang, no device clang, WSL unavailable), so every Java2C-specific
|
||||
criterion is `inferred`; the JNI-sinking side and the discrimination itself are `observed`.
|
||||
- The native layer: `.so` hosts, tamper-triggered self-termination, forged ELF structure, and
|
||||
neutralising a terminate path without freezing the process (`native-and-so.md`,
|
||||
`native-tamper-and-suicide.md`).
|
||||
@@ -299,7 +306,8 @@ unread in this repository.
|
||||
| A `FORTIFY: pthread_mutex_lock called on a destroyed mutex` abort in a Flutter app, on the **main** thread, before the first frame completes | `dart-aot.md` — check `libapp.so` is actually being loaded; Flutter's engine bootstrap is the usual place a native lifecycle fault surfaces |
|
||||
| Log says a **Java-layer** signature/integrity check **passed**, yet the process dies | `code-virtualization-and-custom-linkers.md` §a Java-layer "signature killer" is a decoy |
|
||||
| Deleting a library fixes validation but yields `UnsatisfiedLinkError: dlopen failed: library "X" not found` | `code-virtualization-and-custom-linkers.md` §the deadlock that eats hours |
|
||||
| Whole classes appear as bare `native` declarations with no body | `code-virtualization-and-custom-linkers.md` |
|
||||
| Whole classes appear as bare `native` declarations with no body | `java2c-and-jni-sinking.md` — read it **before** dumping memory: if this is Java2C there is no DEX to find, at any point in the process lifetime. A handful of `native` methods in an otherwise ordinary dex is JNI sinking, not this |
|
||||
| A `Java_*` search over a hardened library returns nothing at all | `java2c-and-jni-sinking.md` §The JNI boundary — why a symbol search fails silently — dynamic registration, or `-fvisibility=hidden`. The check that works is "exports `JNI_OnLoad` and zero `Java_*`" |
|
||||
| A library's **SONAME does not match its filename** | `code-virtualization-and-custom-linkers.md`, `native-and-so.md` |
|
||||
| Your edit had **no effect at all**, with no error | `server-config-and-updates.md` §3 (the value may be server-sent), then `packers.md` §map the validation boundary |
|
||||
| Process **hangs** with no crash record, or dies to a `uid 0` killer | `native-tamper-and-suicide.md` §the rule (you probably made a terminate path *not return*) |
|
||||
@@ -336,6 +344,9 @@ unread in this repository.
|
||||
| The traffic is protobuf/gRPC/QUIC, or a proxy sees TLS but requests still fail on a Flutter app | `references/protocol-reverse.md` — schema-less protobuf, frame capture, and native-side pinning |
|
||||
| Userspace hooks land and the app still dies: the check reads `/proc/self/status` through a raw `svc`, or runs before `JNI_OnLoad` | `references/kernel-and-environment-hardening.md` — what the next layer up and down can actually do, and when to stop |
|
||||
| You must edit, repack, sign or inspect the APK **from the phone itself** | `references/on-device-tooling.md`, `scripts/mt_mcp_probe.py` |
|
||||
| A captured body decodes to nothing readable, or you cannot tell whether a length-delimited field is a string, a nested message or a packed array | `protocol-reverse.md` §1. Protobuf on the wire (measured) — run `scripts/protobuf_decode_raw.py`; the candidate list and its `tie:` lines are the answer, and `docs/tool-verification/EXTENSION-protobuf-raw.md` records exactly what a schema-free decode can and cannot settle |
|
||||
| Method bodies are present but decode as **private opcodes**, and you need the mapping rather than an explanation of why VMP is hard | `vmp-differential-analysis.md`, then `advanced-unpacking.md` for the shape diagnosis |
|
||||
| A store build arrives as `base.apk` + `split_config.*.apk`, or a rebuilt build is refused **as a set** although every file verifies on its own | `split-apk.md` — one keystore across every member for `pm install-multiple`, and check that a merge is legal before trusting a merged single APK |
|
||||
|
||||
## Gates — clear these before you patch, in order
|
||||
|
||||
@@ -556,6 +567,8 @@ Load only what the current step needs.
|
||||
| `references/byte-level-patching.md` | You want to change behaviour by editing a few bytes rather than rebuilding a method — equal-length patches, locating an instruction's exact offset, dex header integrity fields, branch polarity, verifier legality |
|
||||
| `references/packers.md` | The app is packed/hardened, or an edit makes it die before your code runs. Also load before discarding any route as "blocked by the shell" |
|
||||
| `references/code-virtualization-and-custom-linkers.md` | **No packer, dex is readable, and a re-signed build still dies** — whole classes turned into `native` declarations, a private loader with a mismatched SONAME, an embedded self-decrypting payload, or a Java-layer "signature killer" that logs success while a native check kills you. Covers the keep-it/drop-it deadlock and the string-redirect escape |
|
||||
| `references/java2c-and-jni-sinking.md` | **Whole classes are bare `native` declarations and you are about to hunt for a decrypted DEX** — Java2C has none, ever: the code was compiled into a `.so`, so the route is reading that library, not dumping memory. Also the JNI boundary itself — why a `Java_*` symbol search fails silently — and the evidence that separates Java2C from an extraction shell, a VMP and ordinary JNI sinking |
|
||||
| `references/vmp-differential-analysis.md` | Method bodies are present but decode as **private opcodes** — a real Dex VMP: the known-plaintext differential, which links can and cannot be automated, how to *prove* a derived opcode table, smali generation, and when the route is closed |
|
||||
| `references/framework-runtimes.md` | The UI is not native (Flutter / React Native / Unity / Cordova), or Java-layer hooks fire zero times while the UI clearly works |
|
||||
| `references/dart-aot.md` | The logic lives in a Dart AOT snapshot (`libapp.so`): pinning the Dart version, building a matching decompiler, the object pool and reference indexing, register/boolean conventions, locating and patching Dart code |
|
||||
| `references/native-and-so.md` | Patching in a `.so`, needing code to run before the app's own code, hand-built native payloads that crash inside the linker, or **deciding which library/ABI is actually loaded and executing** |
|
||||
@@ -586,6 +599,7 @@ Load only what the current step needs.
|
||||
| `references/protocol-reverse.md` | The traffic is protobuf without a schema, gRPC, or QUIC/HTTP3; or a proxy sees TLS while the app still fails — schema recovery, frame capture, and native-side certificate pinning (Flutter/BoringSSL) with its boundaries |
|
||||
| `references/kernel-and-environment-hardening.md` | Userspace hooking provably cannot reach the check — raw `svc` syscalls, `init_array`-early detection, a ROM that hunts instrumentation: what each layer up and down can still do, the kernel-route map and its version gate, and when escalating is the wrong answer |
|
||||
| `references/on-device-tooling.md` | Working **from the phone itself**: MT Manager edit/repack/sign and its built-in APK MCP, LSPosed Manager, Termux+frida, and on-device data inspection |
|
||||
| `references/split-apk.md` | The target is a **split APK / App Bundle set** (`base.apk` + `split_config.*.apk`), or `pm path <PKG>` returned several files: reading a set off a device, when merging into one APK is legal versus when only unified re-signing works, the install refusal each mistake produces, the manifest attributes a merge breaks, and ABI/density matching |
|
||||
|
||||
## Script index
|
||||
|
||||
@@ -608,7 +622,7 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
|
||||
| `scripts/dart_pool_strings.py` | Recover string literals from a Dart AOT snapshot: framed entries, the one-byte vs UTF-16 split, file offsets, and a run-length noise filter |
|
||||
| `scripts/dart_disasm.py` | Annotated windowed disassembly of Dart AOT code (pool + boolean annotations) plus a B/BL caller index |
|
||||
| `scripts/find_refs.py` | Count and list callers of a method/field (blast-radius check). Takes a smali tree, a `.dex`, a directory of either, or an `.apk`. Always prints what it scanned, so "the input was unreadable" cannot be mistaken for "nothing references it" |
|
||||
| `scripts/repack.py` | Rebuild an APK with replaced dex, strip only signatures, keep `META-INF/services/`, **write a 4-byte-aligned archive** (`resources.arsc` STORED+aligned, which Android R+ refuses to install without), sign, and verify |
|
||||
| `scripts/repack.py` | Rebuild an APK with replaced dex, strip only signatures, keep `META-INF/services/`, **write a 4-byte-aligned archive** (`resources.arsc` STORED+aligned, which Android R+ refuses to install without), sign, and verify. Also handles **split APK / App Bundle sets**: inventory one (`--split-dir`/`--split`), sign every member with a single keystore for `pm install-multiple` (`--split-mode resign`), or fold code/native members into one standalone APK (`--split-mode merge` — refused by design when a member carries its own `resources.arsc`). `--signer auto/jar/apksigner` covers hosts without uber-apk-signer; see `references/split-apk.md` |
|
||||
| `scripts/dexpatch/` | dexlib2 method-level rewriter (for changes that genuinely need new instructions) + build notes |
|
||||
| `scripts/devsh.py` | Quoting-safe ADB shell helper for rooted devices |
|
||||
| `scripts/usb_net_proxy.py` | Give an offline device network over USB (adb reverse + local proxy) |
|
||||
@@ -638,3 +652,7 @@ All scripts are parameterized and path-agnostic; pass paths explicitly. Run `--h
|
||||
| `scripts/stalker_trace.js` | Instruction-level tracing with Frida Stalker: configurable module/offset targets, trigger selection, the event stream, output-size rules, and `transform` customisation |
|
||||
| `scripts/stalker_report.py` | Reduce a `stalker_trace.js` log into block histograms and call sequences, and print an explicit diagnostic for the measured **zero-event** case |
|
||||
| `scripts/mt_mcp_probe.py` | Probe MT Manager's on-device APK MCP (Streamable HTTP, `127.0.0.1:8787/mcp`): JSON-RPC handshake plus grouped `mt_apk_*` tool inventory; prints start-it-by-hand instructions and exits 2 while the service is down |
|
||||
| `scripts/java2c_probe.py` | Collect the evidence that separates **Java2C** from an extraction shell, a VMP and ordinary JNI sinking: per-class `native` density, native-dominated classes and stub ratio from the dex, plus `Java_*` / `JNI_OnLoad` / dynamic-registration / toolchain-string evidence from the `.so`. Every item carries a strong/medium/weak label and the weak ones print their own warning — reading a single criterion is how this gets misdiagnosed |
|
||||
| `scripts/protobuf_decode_raw.py` | **Schema-free protobuf decode**: hex string, binary file or stdin to a JSON tree, with every length-delimited field reported as a candidate set (nested message / UTF-8 / packed array / opaque bytes) and ties labelled rather than guessed. Flags the proto3 explicit-zero and packed-boundary ambiguities; `--max-depth`, `--split`, and `--reencode --check` for a byte-exact round trip. Cross-checked field-for-field against the official runtime |
|
||||
| `scripts/vmp_diff_harness.py` | Differential hardening for Dex-VMP: build a labelled opcode-coverage fixture (**218 of 224 opcodes, measured**), derive a candidate private-opcode map from an original/hardened dex pair with per-entry confidence and a run-level verdict, forge known-table fixtures to verify the comparison in a closed loop, and render a restored stream as a smali skeleton. The upload-to-a-hardening-service link is **not automatable** — see `references/vmp-differential-analysis.md` §2 |
|
||||
| `scripts/kernelsu_syscall_mask.py` | Generate a configurable KernelSU/APatch syscall-masking scaffold: an installable userspace module skeleton plus KPM/LKM/eBPF kernel-side templates, each with its version gate and an explicit unverified label. **A KernelSU userspace module cannot change a syscall return value** — the kernel-side templates are the part that could, and they are shipped unbuilt |
|
||||
|
||||
@@ -14,11 +14,13 @@ extraction shell and no VMP, so nothing here was exercised end to end against a
|
||||
hardened sample by this skill's own verification pass. The FART/Youpk mechanisms and
|
||||
the Android 12-16 failure analysis are **inferred** from public work (FART/Youpk
|
||||
release notes and a 2026-08 Kanxue thread, `thread-292312`, on why classic
|
||||
active-invocation hooks die on modern ART). The one **measured** element is the
|
||||
detection metric: the trivial-body statistic was exercised by
|
||||
`scripts/dex_dump_validate.py` against a surgical fixture derived from a real hardened
|
||||
sample (see `docs/tool-verification/EXTENSION-unpacking.md`). Label your own results
|
||||
the same way.
|
||||
active-invocation hooks die on modern ART). The detection metric has since been
|
||||
**measured against a purpose-built extraction-shell fixture set**, and that pass
|
||||
invalidated the threshold this file used to state — see the calibration table in
|
||||
§Measuring extraction instead of guessing and the commands behind it in
|
||||
`docs/tool-verification/EXTENSION-extraction-shell-bench.md`. The older surgical fixture
|
||||
that first exercised the script is in `docs/tool-verification/EXTENSION-unpacking.md`.
|
||||
Label your own results the same way.
|
||||
|
||||
Two parts of this file have since been exercised against live hardened targets on a
|
||||
rooted device. The **root-side memory route** (§Dumping when frida is refused) was run
|
||||
@@ -28,7 +30,9 @@ traps and the throughput numbers are recorded in
|
||||
`docs/tool-verification/EXTENSION-rootdump.md`. The `frida-dexdump` refusal that
|
||||
motivates that section is in `docs/tool-verification/EXTENSION-device-run.md`. The
|
||||
FART/active-invocation half of the file is still **inferred** — no extraction-shell
|
||||
target has been spliced end to end here.
|
||||
target has been spliced end to end here, and the two candidate samples shipped in
|
||||
`repos/CyReverse` turned out not to be extraction shells at all (their two "shell"
|
||||
assets differ by six bytes; row B3 in the bench evidence file).
|
||||
|
||||
## The four shapes a dump can be
|
||||
|
||||
@@ -39,9 +43,18 @@ directory and read the `stub%` column — the four shapes separate on it.
|
||||
|---|---|---|---|
|
||||
| Parses, many classes, real bodies | near-baseline | First-generation landing shell; the whole-dex dump **is** the original | Done — filter (`recon.md` §Unpacking a dex-level packer) and proceed to patching |
|
||||
| Parses, many classes, most bodies are `return-void` stubs or nop fills | high | **Extraction shell skeleton** — bodies are decrypted only on invocation | FART loop, this file |
|
||||
| Whole classes appear as bare `native` declarations | n/a | dex2c / JNI sink — the code left the dex entirely | `code-virtualization-and-custom-linkers.md` |
|
||||
| Whole classes appear as bare `native` declarations, and the matching `Java_*` export exists | n/a | **JNI sinking** — the code left the dex for the `.so` | `java2c-and-jni-sinking.md` — read the function in the library, **not** this file |
|
||||
| Method bodies look like a thin forwarder to a native routine, or a single native island holds the app's own logic | n/a | **Java2C / native island** — the body is a compiled `J`/`.so` function, so re-reading the dex finds only the call | `java2c-and-jni-sinking.md` — the dex is the wrong instrument |
|
||||
| Whole classes appear as bare `native` declarations, and **no** `Java_*` export exists | n/a | Dynamic-registration native sink — the binding table is built at runtime | `java2c-and-jni-sinking.md` §the symbol search fails structurally |
|
||||
| Parses, bodies present but instruction streams are nonsense to a dalvik disassembler | baseline | **Real Dex VMP** — private opcodes behind a native interpreter | Last section of this file, and be honest about the cost |
|
||||
|
||||
The two `native`-looking rows are **not** one row. They are separated because the
|
||||
correct next action differs: JNI sinking keeps the logic in a named, exported ARM64
|
||||
function that you read from the library, while a dynamic-registration sink has no
|
||||
symbol to find and needs the binding table read instead. Lumping them together sends an
|
||||
analyst to dump memory for code that is sitting in an ordinary `.so` — the misdiagnosis
|
||||
`java2c-and-jni-sinking.md` was written to stop.
|
||||
|
||||
Two supporting signals worth recording while you are here (the second is measured on
|
||||
this repo's hardened sample): a shipped shell `classes.dex` can be megabytes in size
|
||||
yet define only a handful of classes — the bytes are payload, not code, so class count
|
||||
@@ -69,12 +82,67 @@ measured to differ in ~73% of their bytes), rejects structurally impossible imag
|
||||
(bad magic, `file_size` mismatch, ids out of range), counts classes and stub bodies,
|
||||
and ranks which surviving image is the most likely original.
|
||||
|
||||
Calibration, **inferred** from field reports rather than measured across a population:
|
||||
ordinary app code carries a few percent of genuinely trivial bodies (constructors that
|
||||
do nothing, interface stubs); a ratio in the tens of percent or higher is a skeleton,
|
||||
not code. A ratio that is high *and* the image parses is the extraction-shell
|
||||
signature — a first-generation dump of the same app would show the same classes with
|
||||
real bodies.
|
||||
**Calibration — measured, and it is not a threshold.** Do not look for a `stub%` number
|
||||
to compare against. On a real 982 KB application dex (5,061 bodies) every emptying
|
||||
shape the shell family uses was built into a fixture and measured against a
|
||||
zero-change control (`docs/tool-verification/EXTENSION-extraction-shell-bench.md`,
|
||||
which carries the commands and the full matrix):
|
||||
|
||||
| What the shell left behind | `stub%` | vs. control (1.9 %) |
|
||||
|---|---|---|
|
||||
| bare `return-void` in every body | 100.0 % | sighted |
|
||||
| body cut to `const/4 v0,#0; return v0` | 93.2 % | sighted |
|
||||
| nop fill, no return — **the slot-clearing shape** | 0.0 % | **blind, and scores below the untouched original** |
|
||||
| `new RuntimeException; throw` stub — **the 360/legu shape** | 1.9 % | **blind, indistinguishable from the control** |
|
||||
| 25 / 50 / 75 % of bodies emptied | 1.7 / 1.7 / 2.1 % | blind |
|
||||
|
||||
The distribution is **bimodal**: `stub%` lands either at the app's own baseline
|
||||
(ordinary constructors and interface stubs, measured at 1.9 % here) or at ~100 %.
|
||||
Nothing sits in between, so there is no band a threshold could live in, and **partial
|
||||
extraction is invisible** — a dex with three quarters of its bodies removed reported
|
||||
0.2 points above its untouched control.
|
||||
|
||||
Two consequences the metric's name hides:
|
||||
|
||||
- **`stub%` high is dispositive; `stub%` low is not evidence of anything.** A low
|
||||
reading is compatible with a genuine dex, a fully nop-wiped skeleton, and a
|
||||
`throw`-stubbed skeleton alike.
|
||||
- **Read `emptied%`, not `stub%`, for the skeleton call.** `emptied%` unions the
|
||||
`return*`-stub and the nop-wiped classes, which is what lifts the nop-filled
|
||||
skeleton from 0.0 % to 100 %. A `throw`-stubbed skeleton still reads low; for that
|
||||
shape the ranking cannot help you and only per-body inspection can.
|
||||
|
||||
The trivial-body shapes `dex_dump_validate.py` counts, and which of them are skeleton
|
||||
evidence, are documented in the script itself (`classify_body`); the split between
|
||||
`stub` / `erased` / `minimal` exists because collapsing them produced a ranking that
|
||||
named a fully-wiped image the most likely original.
|
||||
|
||||
### The ranking's failure conditions — read these before trusting its winner
|
||||
|
||||
The `ranking` block names a "best-supported candidate for the original", and it is
|
||||
wrong in two measured situations. Both were reproduced, and both are the *opposite* of
|
||||
the obvious reading:
|
||||
|
||||
- **A partially extracted image outranks a heavily stubbed one.** Ranking on the
|
||||
emptied share alone put a dex with 25 % of its bodies removed *ahead of* its own
|
||||
untouched control — 1.7 % against 1.9 %, because replacing a body with
|
||||
`const/4 + return` lowers the `return-void` count. Measured before it was fixed;
|
||||
the current key uses the count of skeleton-shaped bodies first, which is monotone,
|
||||
and evicts anything over 50 % emptied.
|
||||
- **A `throw`-stub skeleton is invisible and ranks at the control's own baseline.**
|
||||
Every body replaced by `new RuntimeException; throw` measured 1.9 % — identical to
|
||||
the untouched image — and it still sorts third in the fixture set. No body statistic
|
||||
in this kit sees that shape. If the target family is 360/legu-like, **do not use the
|
||||
ranking to pick a baseline**; diff candidate images against each other by body hash
|
||||
instead.
|
||||
|
||||
The reason to state this in the reference file rather than only in the script: the
|
||||
ranking's output is a *sentence with a winner in it*, and a wrong winner is worse than
|
||||
no winner — an agent that adopts a modified image as its baseline builds every
|
||||
subsequent diff on the wrong artifact. When `emptied%` is at the app's own baseline and
|
||||
you cannot name a signal that separates the candidates, treat the ranking as
|
||||
uninformative for that set. Commands and full matrix:
|
||||
`docs/tool-verification/EXTENSION-extraction-shell-bench.md`.
|
||||
|
||||
## The FART loop: skeleton, invocation, splice
|
||||
|
||||
@@ -319,9 +387,46 @@ The last shape is the one to say out loud: if the dump's method bodies are intac
|
||||
decode as private opcodes — rare-opcode density off the charts, unknown opcode slots,
|
||||
and a large native interpreter loop behind them — the code has been virtualized, and
|
||||
nothing in this repository will hand it back to you decompiled. Distinguish it from
|
||||
the JNI sink first (`code-virtualization-and-custom-linkers.md`): there the methods
|
||||
are `native` stubs and the logic lives as native code; here the bodies are present
|
||||
and *re-encoded*.
|
||||
the JNI sink first (`code-virtualization-and-custom-linkers.md`,
|
||||
`java2c-and-jni-sinking.md`): there the methods are `native` stubs and the logic lives
|
||||
as native code; here the bodies are present and *re-encoded*.
|
||||
|
||||
### Establish "the bodies do not decode" with a decoder you did not write
|
||||
|
||||
This is the one measurement in this file that has already been got wrong here, so it
|
||||
gets its own rule. A hand-written opcode-width table silently desynchronises on large
|
||||
dex files — one arm64 branch opcode missing from the table is enough — and the
|
||||
desynchronisation is indistinguishable from the finding it fakes. Measured on the
|
||||
`ezAndroid` sample: a workbench table reported **1,393 of 22,424 bodies as
|
||||
non-decodable** and I read it as private opcodes; the platform decoder returned
|
||||
**943,223 lines, 34,566 instruction decodes, and zero structural errors** on the same
|
||||
file (`docs/tool-verification/EXTENSION-extraction-shell-bench.md` §3.4). The bodies
|
||||
were ordinary dalvik the whole time, and one of the "desync" methods disassembles into
|
||||
five clean instructions under `dexdump`.
|
||||
|
||||
So: **before any VMP verdict, decode the same file with `dexdump`,
|
||||
`baksmali`/`smali`, or `jadx`, and quote the failure count that came from *it*.** A
|
||||
rare-opcode histogram is a lead, not evidence. Never let a private table's desync
|
||||
inform a classification — "my decoder stopped" is a fact about the decoder.
|
||||
|
||||
### What a static dex probe can and cannot decide
|
||||
|
||||
Two different questions live in the same dump, and only the first one is answerable
|
||||
from the bytes on disk:
|
||||
|
||||
| Question | Statically decidable? | Why |
|
||||
|---|---|---|
|
||||
| Are the bodies real dalvik, or an encoded program? | **yes** | the platform decoder decodes them or reports a structural error |
|
||||
| Did the code leave the dex for native? | **yes** | `native` access flags, then `Java_*` exports / `JNI_OnLoad` in the matching `.so` |
|
||||
| Does a native island check signature, root or debugger? | **no** | the logic is ARM64 behind a vtable; the check may exist in no dex at all |
|
||||
| Are unlabelled `assets/` blobs live payload? | **no** | an unreferenced blob is dead until a runtime path constructs its name — measured: six such files in `ezAndroid`'s `assets/`, **zero** dex strings referencing them |
|
||||
|
||||
The trap is the asymmetry. A clean static reading licenses exactly one sentence —
|
||||
*"this is not dex-VMP"* — and it does **not** license *"this is not hardened"*. Those
|
||||
are different claims: the second one is the misdiagnosis that sends an analyst looking
|
||||
for an unpacker when the problem is a native island, or for a private disassembler when
|
||||
the code is ordinary ARM64 in a 206 KB `.so`. A static probe's silence about hardening
|
||||
is silence, not a clean bill of health.
|
||||
|
||||
What recovery looks like when someone does it, **all inferred**, no fixture exists in
|
||||
this repo, treat as a research plan rather than a recipe:
|
||||
@@ -348,10 +453,12 @@ for the island itself.
|
||||
|---|---|---|
|
||||
| Dump parses, stub% near baseline | Filter, verify with jadx, move on to patching | `recon.md` |
|
||||
| stub% high, image parses | FART loop: skeleton + active invocation + splice | this file |
|
||||
| `emptied%` high but `stub%` low | nop-wiped skeleton; the old `stub%`-only reading called it the original | §Measuring extraction instead of guessing |
|
||||
| The ranking names a winner but `emptied%` sits at the app's baseline | Treat the ranking as uninformative for this set; do not adopt that image as a patch baseline | §The ranking's failure conditions |
|
||||
| Splice produces a half-parsing dex | Re-check code_item length (padding, handler list) | §The code_item length trap |
|
||||
| Dumper runs, output dir empty | Storage path + `open()` check, then entry-type probe | §Why the classic hooks die on Android 12-16 |
|
||||
| App dies under the invoker | Shell counter-measures; narrow the force list | §Why the classic hooks die on Android 12-16, and `detection-and-anti-analysis.md` |
|
||||
| frida attach refused (`script has been destroyed`) | Root-side `/proc/<pid>/mem` dump, atomic inside one process lifetime | §Dumping when frida is refused |
|
||||
| Sample self-exits and restarts with no instrumenter present | The packer's own check, not your hook; shorten every step to one lifetime | §Dumping when frida is refused |
|
||||
| Methods are `native` stubs | dex2c / JNI sink | `code-virtualization-and-custom-linkers.md` |
|
||||
| Bodies present, decode as nonsense | Real VMP — differential oracle or walk away | §The honest boundary: real Dex VMP |
|
||||
| Methods are `native` stubs with a `Java_*` export | JNI sinking — read the function in the `.so` | `java2c-and-jni-sinking.md` |
|
||||
| Bodies present, decode as nonsense | Real VMP — **but confirm with `dexdump` first**, then differential oracle or walk away | §Establish "the bodies do not decode" with a decoder you did not write |
|
||||
|
||||
@@ -47,6 +47,7 @@ Emulation and RPC are for when the code that matters must keep *running*.
|
||||
| Maintenance cost | every missing JNI/syscall is a new stub | reconnect logic, process lifecycle |
|
||||
| Reproducibility | deterministic (you control time/random) | real time, real randomness |
|
||||
| Best for | signing algorithms, crypto, parsers | anything entangled with live app state |
|
||||
| **Priority when either could work** | second — the environment bill below is real and routinely underestimated | **first** — the device already owns the environment |
|
||||
|
||||
The two-strike rule applies across the table: if a library refuses to run under unidbg after two
|
||||
focused rounds of `DalvikVM` patching, stop emulating and move to RPC (or the reverse) instead of a
|
||||
@@ -54,6 +55,33 @@ third round — a library that checks its own loading path or decrypts itself ag
|
||||
may simply not be worth emulating, and that is a finding, not a failure (`SKILL.md`
|
||||
§Stop conditions).
|
||||
|
||||
### The environment bill — budget days, not hours
|
||||
|
||||
"Filling the environment" reads like a checklist and behaves like a project. The mistake this
|
||||
section prevents is starting an emulation because the table says *works against anti-injection*, and
|
||||
then discovering that this library's environment includes everything emulation cannot fake: an
|
||||
Android `Context` backed by a real package manager, Binder round-trips into another process, a
|
||||
`KeyStore` attestation that only succeeds on real hardware, or a self-check against device state
|
||||
captured at install time. For a commercial native algorithm of that shape, "stub it until it runs"
|
||||
is measured in **days, not hours** — each missing piece is discovered one fault at a time, and the
|
||||
rounds do not get shorter.
|
||||
|
||||
Weigh it before starting:
|
||||
|
||||
| Question | If yes |
|
||||
|---|---|
|
||||
| Does it call through `Context` (package name, files dir, signature, `PackageManager`)? | a stub is often enough — cheap, keep going |
|
||||
| Does it talk to another process (Binder service, bound SDK, remote provider)? | the stub surface grows fast; **prefer RPC** |
|
||||
| Does it verify hardware (KeyStore, TEE, an attestation chain)? | emulation is likely a dead end — the value it wants cannot be produced here |
|
||||
| Does it check its own loading environment (paths, maps, root, debugger, installer)? | readable and patchable *inside* the emulator, but budget one round per check |
|
||||
| Must it be called thousands of times a minute? | the RPC throughput ceiling is real — this is the case that justifies the bill |
|
||||
|
||||
Default to **RPC first, emulation second**: the device already provides the environment, and
|
||||
per-call cost only starts to matter once you need volume. The two claims in this subsection
|
||||
(the day-scale cost of a Context/Binder/KeyStore-shaped library, and the RPC throughput ceiling)
|
||||
are **inferred** from the mechanism and from community practice — this repository has not emulated a
|
||||
commercial sample end to end, as `EXTENSION-emulation-rpc.md` states.
|
||||
|
||||
## Part A — Unidbg
|
||||
|
||||
### What it is
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Java2C and JNI Sinking — the dex is not where the code is
|
||||
|
||||
Load this when the dex shows method bodies missing or replaced by `native` declarations, and
|
||||
you are about to go looking for a decrypted DEX in memory. **Decide which of the two shapes you
|
||||
have before you spend anything**, because one of them has no DEX to find at any point in the
|
||||
process lifetime, and the search for it is unbounded.
|
||||
|
||||
Two neighbouring routes have historically folded this shape into one: `advanced-unpacking.md`'s
|
||||
dump-shape table and the SKILL.md symptom index both point "whole classes are bare `native`
|
||||
declarations" at `code-virtualization-and-custom-linkers.md`. That file is about protection which
|
||||
leaves the code **reachable** — a private container, a loader, a private opcode interpreter, all of
|
||||
which you can dump and measure. **Java2C is not reachable, because it was never bytecode.** This
|
||||
file splits that row and owns both halves of it; the virtualization file stays what it is.
|
||||
|
||||
**Strength note, read this first.** The measurements below are **observed** — every number was
|
||||
produced by `scripts/java2c_probe.py` against a real sample during this pass, recorded in
|
||||
`docs/tool-verification/EXTENSION-java2c.md`. But no Java2C library exists in this repository and
|
||||
none could be built here (no NDK, no clang, WSL unavailable — see that record), so **the
|
||||
Java2C-specific identification criteria are inferred, not observed end to end**. What *is*
|
||||
observed is the discriminating measurement that separates Java2C from JNI sinking, and the JNI
|
||||
boundary behaviour. Treat the type table as a map; label your own conclusions the same way.
|
||||
|
||||
## The five shapes — one table, because the wrong row costs days
|
||||
|
||||
| Shape | Static shape in dex | What is in memory at runtime | Route | Cost of the wrong row |
|
||||
|---|---|---|---|---|
|
||||
| **Landing shell** | `Application` is a third-party class; dex is a small stub | A full, decrypted DEX | Dump memory, filter, patch | Cheap if you notice the stub `Application` |
|
||||
| **Extraction shell** | Reads as a normal dex until you measure it; bodies are stubs | A DEX whose bodies are filled in **on invocation** | Dump, measure `stub%`, FART-style active invocation | Medium — you keep dumping, and every dump is a skeleton |
|
||||
| **VMP** | Dex parses; bodies are *present* but decode as nonsense | A DEX containing **private opcodes** interpreted by a native VM | Measure, then usually stop and report | Medium — recovery cost usually exceeds task value |
|
||||
| **Java2C** | Whole classes are `native`; **no code_item at all** for them | **Never a DEX.** The translated methods exist only as compiled C in a `.so` | Read the `.so`; rebuild the JNI call chain | **Highest in this table** — you hunt a decrypted DEX that is never produced, then blame your dump tooling, then your anti-detection assumptions |
|
||||
| **JNI sinking** | A handful of `native` declarations; the rest of the dex is ordinary Java | A normal DEX | Locate the Java call site; reverse one or a few native functions | Low — but you may widen the job to the whole app if you read it as Java2C |
|
||||
|
||||
The one question that decides the table: **does the artifact ever hold dex bytecode for the
|
||||
methods you care about?**
|
||||
|
||||
- Landing shell, extraction shell, VMP: **yes** — the bytecode is there, in some state, and the
|
||||
work is recovering or interpreting it.
|
||||
- Java2C: **no** — a translated method is a `native` declaration plus an entry in a registration
|
||||
table. Nothing is decrypted, so nothing can be dumped. `[inferred]` as a runtime claim: it
|
||||
follows from the translation mechanism, and this pass did not observe a live Java2C app.
|
||||
- JNI sinking: **yes** — the dex was never damaged.
|
||||
|
||||
## Identification
|
||||
|
||||
### The discriminating measurement is native density
|
||||
|
||||
This is the single most useful number and it separates the two shapes by roughly three orders of
|
||||
magnitude. Measured on real samples with `scripts/java2c_probe.py` `[observed]`:
|
||||
|
||||
| Sample | dex methods | `native` methods | native ratio |
|
||||
|---|---|---|---|
|
||||
| JNI-sinking target A (MASTG L2) | 5081 | 2 | **0.04 %** |
|
||||
| JNI-sinking target B (MASTG L3) | 11182 | 3 | **0.03 %** |
|
||||
| App carrying a real JNI library | 23603 | 7 | **0.03 %** |
|
||||
| Java2C-shaped fixture | 29 | 24 | **82.76 %** |
|
||||
|
||||
A JNI *sink* is by definition surgical: a few hot methods are moved out and the rest of the app
|
||||
stays in Java. **A JNI sink is not "the app is native".** When the density is in the tens of
|
||||
percent, and whole classes have zero Java methods left, you are looking at a translation pass.
|
||||
|
||||
`[observed]` in the same runs: two of the fixture's three classes were native-dominated (>= 70 %
|
||||
of >= 3 named methods), against **zero** such classes in any of the three real targets. Note that
|
||||
constructors are excluded from that test — a translation pass leaves `<init>`/`<clinit>` in Java
|
||||
in practice, so "every method native" is an essentially unreachable test and should not be used.
|
||||
|
||||
### Native-layer signatures
|
||||
|
||||
| Evidence | Strength | Why |
|
||||
|---|---|---|
|
||||
| `.so` exports `Java_*` symbols whose count is roughly 1:1 with the dex native-method count | **strong** | Measured 2:2 (sample A) and 3:3 (sample B) `[observed]`. It is the signature of a *static-linkage* translation or sink. Note it does **not** distinguish Java2C from JNI sinking — only the density does |
|
||||
| The `.so` contains one C function per Java method, each beginning `JNIEnv *env, jobject thiz, ...` | **strong** | Observed in generated, uncompiled output: every translated method became exactly one function with those leading parameters |
|
||||
| Toolchain strings: `Dex2C`, `dynamic_register_compile_methods`, `ScopedLocalRef`, `well_known_classes` | **strong** | These are the runtime header and entry-point names a Dex-to-C toolchain emits. `[observed]` in generated sources; `[inferred]` that they survive compilation into the shipped `.so` |
|
||||
| `JNI_OnLoad` present | **weak** | Present in nearly every JNI library. Measured true on a system library with zero `Java_*` symbols `[observed]` — it told us registration was dynamic, not that anything was hardened |
|
||||
| `RegisterNatives` **string** anywhere in the `.so` | **weak** | See the boundary section: NDK's C++ headers do not emit this symbol at all |
|
||||
| `libc++_shared.so` / `c++_static` | **weak** | An ordinary NDK C++ setting. The Dex-to-C toolchain read here does ship `APP_STL := c++_static`, but so do countless unrelated libraries `[observed]` |
|
||||
| `classes.dex` byte-count per class far below the app's own pre-hardening build | **weak, uncalibrated** | Bodies leaving the dex should shrink it. Measured 584 B/class on the fixture versus 1152–1386 B/class on the real Java targets `[observed]` — but there is no population baseline for "normal", so this only compares an app against **itself** before and after |
|
||||
|
||||
`java2c_probe.py` prints every one of these with its strength attached, and prints the weak ones
|
||||
as an explicit warning block. A weak hit is not a verdict and must not be reported as one.
|
||||
|
||||
## The route: read the .so, not memory
|
||||
|
||||
For Java2C the analyst's instinct — dump memory and find the dex — is not merely expensive, it is
|
||||
**unbounded**, because the success condition can never occur.
|
||||
|
||||
1. **Do not dump.** There is no target artifact. If you dump anyway, you will recover a dex that
|
||||
still shows the same `native` declarations you started with, and the natural (wrong) reading is
|
||||
"the dump failed".
|
||||
2. **List the translated functions.** With static linkage, `Java_<PKG>_<Class>_<method>__<proto>`
|
||||
names map the `.so` back onto the dex one for one. Use the dex as the index and the `.so` as
|
||||
the library.
|
||||
3. **Rebuild the chain backwards.** A translated function calls *out* to Java constantly. The
|
||||
strings `FindClass` / `GetMethodID` / `GetStaticMethodID` / `Call*Method` / `NewObject` /
|
||||
`GetFieldID` inside it are the reverse edges. Following them reconstructs what the original
|
||||
Java method did, which is the only way the logic comes back.
|
||||
4. **Expect a register-machine shape.** Generated code declares a slot per dex register up front
|
||||
and assigns them in sequence. Reading it as if a human wrote it wastes hours; read it as a
|
||||
linearizer output, matching each call site against the JNI signature it passes.
|
||||
|
||||
The decompiler choice matters less here than for obfuscated code: the generated functions are
|
||||
long but flat and unoptimized, which is precisely the shape a decompiler handles well.
|
||||
|
||||
## The JNI boundary — why a symbol search fails silently
|
||||
|
||||
Two independent mechanisms erase the symbol that a reader would search for. Both are real; the
|
||||
second was measured this pass.
|
||||
|
||||
**1. Dynamic registration.** Nothing named `Java_*` is exported. Binding is performed at runtime
|
||||
by a registration table built in a JNI entry point. A grep over the symbol table returns nothing,
|
||||
exits zero, and looks like a legitimate answer.
|
||||
|
||||
**2. `-fvisibility=hidden`, and an inline `RegisterNatives`.** The Dex-to-C toolchain read here
|
||||
ships `APP_CPPFLAGS += -fvisibility=hidden` in its `Application.mk` `[observed]`, which hides the
|
||||
generated functions from the dynamic symbol table — so even a statically-named translation can
|
||||
present as "no `Java_*` symbols". Compounding it: NDK's C++ `jni.h` implements `RegisterNatives`
|
||||
as an **inline member that calls through the function table**, so calling it emits *no*
|
||||
`RegisterNatives` symbol either `[observed]`: a real system library exporting `JNI_OnLoad` and
|
||||
zero `Java_*` symbols contains zero literal `RegisterNatives` symbols. A rule that greps for that
|
||||
name is structurally incapable of firing.
|
||||
|
||||
**The check that does work** `[observed]`: a library that **exports a JNI entry point and exports
|
||||
zero `Java_*` symbols** is registering dynamically. That combination is affirmative evidence, and
|
||||
it fired on a real library in this pass.
|
||||
|
||||
**Confirming it at runtime.** Hook the registration entry point (`JNI_OnLoad`) and read the table
|
||||
it builds, or hook the class-load path and enumerate the class's methods' native backing. Do not
|
||||
conclude "no JNI boundary" from an empty symbol table; conclude "the binding is not visible in the
|
||||
symbol table" and go find where it happens.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| What you see | What it is not | What it is |
|
||||
|---|---|---|
|
||||
| Whole classes are `native`, you dump memory and find the same `native` declarations | "The dump failed" / "the tool is broken" | The translation moved the code out of dex permanently. Re-read the dex shape and open the `.so` |
|
||||
| `grep Java_ .dynsym` returns nothing | "There is no native implementation" | Dynamic registration, or hidden visibility — see the boundary section |
|
||||
| A library exports `JNI_OnLoad` and nothing else you recognise | "It is a loader" | It may be an ordinary dynamically-registering JNI library. A loader normally also has an `init_array` entry and a payload to map |
|
||||
| Native density is high but every class still has Java methods | Java2C | Could be a JNI sink applied widely, or an R8-stripped app. Check whether the `native` methods have matching `Java_*` symbols before concluding |
|
||||
| The dex looks completely unremarkable on a sample you were told is hardened | "Nothing is hardened" | A VMP whose payload is decrypted at runtime has an ordinary static dex. Measured this pass: a VMP-labelled sample had 7 native methods out of 23603 and a 4 % stub ratio `[observed]`. **Static dex metrics cannot see runtime-only hardening** — that needs a runtime check, not more reading |
|
||||
|
||||
## Where this file stops
|
||||
|
||||
- **It does not decompile the generated C.** That is ordinary native reversing; `native-and-so.md`
|
||||
and `native-dbi-and-deobfuscation.md` own it.
|
||||
- **It does not recover a VMP.** Private-opcode interpretation is a different mechanism with a
|
||||
different cost model (`advanced-unpacking.md`).
|
||||
- **The Java2C runtime claim is inferred.** That no dex bytecode exists in memory for a translated
|
||||
method follows from the mechanism, not from a measurement made here. If you get a live Java2C
|
||||
sample, that is the first thing to verify, and it is cheap: attach, force the class to load, and
|
||||
ask whether the method resolves to Java bytecode or to a registered native entry.
|
||||
@@ -19,6 +19,13 @@ externally documented mechanisms with sources, labelled `inferred` — none of i
|
||||
That is deliberate: the purpose is to stop you from spending a day rediscovering the version
|
||||
gate (§4) or the conflict (§5), not to teach kernel hacking.
|
||||
|
||||
**Extension pass, read the next paragraph before relying on it.** This file now has a companion
|
||||
generator — `scripts/kernelsu_syscall_mask.py` — which emits a loadable userspace module skeleton
|
||||
plus kernel-side **templates** for the three routes in §4. The generator, its userspace output and
|
||||
its own consistency check are `measured`; **the kernel-side code has never been compiled or loaded
|
||||
anywhere**, and every generated kernel file says so in its own header. A template is a starting
|
||||
point that removes the blank page, not a weapon that has been fired.
|
||||
|
||||
## 1. The attack-surface timeline — why "just hook it" fails
|
||||
|
||||
An app process initializes in a fixed order, and every anti-analysis check you will meet lives at
|
||||
@@ -147,6 +154,83 @@ the process earlier than any userspace tool can be, needs no ptrace (so it does
|
||||
rewrites one `/proc` read is a research project; a Zygisk module that hooks the target's
|
||||
constructor is a build task.
|
||||
|
||||
### 4a. From "no weapon" to "template + gate": what the extension pass added
|
||||
|
||||
The gap this closes is specific. §4 above was a map with no artefact behind it: a reader told
|
||||
"Kernel module hooking — inline hooks and syscall-table hooks in kernel space — the layer that *can*
|
||||
rewrite what a `/proc` read returns" had nowhere to go next. `scripts/kernelsu_syscall_mask.py`
|
||||
produces the artifact; this subsection states its boundary, because the boundary is the part that
|
||||
matters.
|
||||
|
||||
**The correction worth internalising first: a KernelSU module cannot do any of this.** A KernelSU
|
||||
(or Magisk, or APatch-userspace) module is a *userspace* module whose scripts run as root in the
|
||||
normal world. `module.prop`, `post-fs-data.sh` and `service.sh` cannot change what `openat`
|
||||
returns — not because of a version gate, but because nothing in that format is ever in the kernel's
|
||||
return path. Detectors of the kind §2 and §3 discuss are not defeated by a module of that shape.
|
||||
The generator emits that skeleton anyway, because it is the right carrier for the configuration and
|
||||
the metadata, and it says in its own README what the skeleton cannot do.
|
||||
|
||||
What *can* reach the return path is one of three artifacts, and each carries a gate that has to be
|
||||
checked on the actual device:
|
||||
|
||||
| Artifact | What it is | Gate | Status of the shipped template |
|
||||
|---|---|---|---|
|
||||
| **KPM** (KernelPatch / APatch) | A relocatable `.kpm` loaded by kpimg injected into the kernel image; replaces syscall-table pointers (`fp_hook_syscalln`) or rewrites prologues (`hook_wrapN`) | A KernelPatch-patched boot image, plus a **bare-metal** ARM64 toolchain (`aarch64-none-elf-gcc`) — not the NDK | `unverified` — template only, never compiled |
|
||||
| **Out-of-tree LKM** | An ordinary kernel module that reaches `sys_call_table` and swaps a pointer | Kernel source matching the device's exact vermagic, and on ≥5.7 a way around `kallsyms_lookup_name` no longer being exported | `unverified` — template only; rated last of the three on cost |
|
||||
| **eBPF probe** | A BPF program on a syscall tracepoint or kprobe | GKI 5.10+ with the tracing/BTF machinery | `unverified` — template only; the version gate alone closes it on most older devices |
|
||||
|
||||
The eBPF row carries a capability ceiling that is easy to miss and is stated in the generated file
|
||||
as well: **a tracepoint can observe a syscall, not rewrite its result.** `bpf_override_return()`
|
||||
only applies to functions flagged `ALLOW_ERROR_INJECTION`, which raw syscall entries are not —
|
||||
so "eBPF to spoof a `/proc` read" is, on most kernels, an observation plan wearing a rewrite
|
||||
plan's clothes. Observation is genuinely useful (it tells you which syscall the check uses, which
|
||||
`§5` requires you to know anyway); it is not spoofing.
|
||||
|
||||
**Field notes on the KPM route, from a public KPM development write-up** (`inferred` here — the
|
||||
author's measurements, not this repository's; source: blackr0ck, *APatch KPM 开发*,
|
||||
[bbs.kanxue.com/thread-291665.htm](https://bbs.kanxue.com/thread-291665.htm), 2026-06). These are
|
||||
recorded because each one is a day of somebody's time:
|
||||
|
||||
- **An inline hook can install cleanly and never fire.** On a GKI kernel with LTO, the exported
|
||||
symbol is frequently not the call site — the callee was inlined into its only caller, so
|
||||
replacing instructions at the symbol's address intercepts nothing. The reported symptom is
|
||||
"module loaded, callback never ran". Pointer replacement in the syscall table does not have this
|
||||
failure mode, because the syscall entry path must go through the table.
|
||||
- **The instruction-sequence length in a module header is a trap of its own.** The reported cause
|
||||
of a hard-to-diagnose load failure: declaring a resolved kernel function with `extern` makes the
|
||||
compiler emit an undefined reference (`*UND*`) instead of allocating the slot, and the loader
|
||||
refuses the module with `unknown symbol`. Letting it be a tentative definition allocates `.bss`
|
||||
and the loader fills it in. The generated template states this rule in the code, and
|
||||
`verify` checks for it.
|
||||
- **Per-syscall callbacks run on every syscall of that number, system-wide.** The reported
|
||||
incident: a `write` hook that did not first reject `fd <= 2` intercepted the framework's own log
|
||||
output tens of thousands of times and the device rebooted. **Performance is correctness here** —
|
||||
the first statement in every callback is a filter that rejects the common case.
|
||||
- **Which hook point survives is kernel-specific, and the cheap way to find out is to start at the
|
||||
syscall layer.** The same write-up reports that modifying the SELinux internal function they
|
||||
first targeted crashed the kernel under every combination tried (before/after, argument,
|
||||
return value, skip-origin), while a syscall-table hook on `write` proved stable and sufficient.
|
||||
Treat the syscall layer as the default and move inward only with evidence.
|
||||
|
||||
**Measured on the reference device, and why every template here is labelled the way it is.**
|
||||
`<DEVICE>` runs kernel **4.14.186+** (`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 ...) #1 SMP PREEMPT Wed Mar 30 23:32:42 CST 2022`). On that device the two
|
||||
remaining gates answer themselves:
|
||||
|
||||
- **eBPF**: 4.14 is far below the 5.10 gate. Closed, as §4 already recorded.
|
||||
- **KPM**: no KernelPatch-patched image is present, and the host has no
|
||||
`aarch64-none-elf-gcc` (`kernelsu_syscall_mask.py gates` reports `NOT FOUND`, along with no
|
||||
`ndk-build` and no `make`). Installing APatch means patching the boot image — a bricking risk
|
||||
that this repository does not take on an APK task.
|
||||
- **LKM**: no kernel source for the device, so no matching vermagic is possible.
|
||||
|
||||
So on this machine the honest answer to "do you have a kernel-level weapon" is: **you have a
|
||||
template, a gate table and a measured statement that the gate is closed here.** That is a route
|
||||
decision, not a failure — and it is the same shape of answer §6 gives for the whole escalation
|
||||
ladder. Do not let the existence of the template change what you claim about it: `unverified` is
|
||||
still `unverified`.
|
||||
|
||||
## 5. The syscall-level realities that survive every hook
|
||||
|
||||
Two facts decide a lot of "unexplainable" behaviour, and both belong to the kernel's design
|
||||
@@ -178,7 +262,7 @@ honest outcome (`observed`, from `tools/_phone-modules/README-*.md`):
|
||||
| LSPosed (Zygisk) module hooking | available — framework activation verified by log (`welcome to LSPosed!` lines, `lspd` daemon process) |
|
||||
| MT Manager on-device editing/repack/sign | available (`on-device-tooling.md`) |
|
||||
| eBPF tracing | closed (kernel gate) |
|
||||
| KPM / kernel module route | closed (no kernel source/build for the device; out of scope regardless) |
|
||||
| KPM / kernel module route | closed on this device — no KernelPatch image, no kernel source, no bare-metal ARM64 toolchain; the **template and gate table** are shipped (§4a) |
|
||||
| Frida with disguised server | available (a renamed server binary was present on the device from prior work) |
|
||||
|
||||
The generalisable rule: **enumerate the ladder for *your* device once, write the one-line
|
||||
@@ -198,6 +282,8 @@ defeated with a custom kernel module is not a deliverable anyone can install.
|
||||
| Check uses raw syscalls | Static patch of the check; or LSPosed/Zygisk module hooking the consuming code | "Hook libc open/read" (does nothing) |
|
||||
| Check detects the *environment* (root/emulator), not your patch | `detection-and-anti-analysis.md` A/B/C — usually a different device or route | Kernel work |
|
||||
| You are about to write a kernel module / patch a kernel | Stop. State what is blocked and the evidence; propose the static/module route | Kernel development (out of scope for this skill) |
|
||||
| You have the kernel template and want to load it on a 4.14/Magisk device | Read `§4a`'s gate table, confirm the gate is closed, and say so | Patching a boot image to obtain a kernel route, on an APK task |
|
||||
| A kernel-side template exists, so it feels like a weapon | It is `unverified` until a device with the matching gate compiles and loads it | Presenting a template as a working capability |
|
||||
| Escalation effort exceeds the user's actual ask | `detection-and-anti-analysis.md` stop signal — switch to static | One more layer |
|
||||
|
||||
## Checklist
|
||||
@@ -207,5 +293,8 @@ defeated with a custom kernel module is not a deliverable anyone can install.
|
||||
- [ ] Root scheme's artefacts enumerated (manager/daemon/mounts) before blaming the target
|
||||
- [ ] Hiding module: exactly one of Shamiko / Zygisk-Assistant; denylist configured to its spec
|
||||
- [ ] Kernel rung checked against `uname -r` — one line recorded if closed
|
||||
- [ ] Kernel-side template treated as `unverified` until a device with the matching gate
|
||||
actually compiles and loads it (`§4a`); the userspace module skeleton is a carrier for
|
||||
configuration, not a kernel capability
|
||||
- [ ] Raw-syscall vs libc-call distinction verified by disassembly before building any spoof
|
||||
- [ ] No kernel development undertaken as part of an APK deliverable
|
||||
|
||||
@@ -182,6 +182,53 @@ across runs only for code that is mapped from file; do not compare a `libart`-ma
|
||||
address between two runs; and never place a follow/unfollow pair on a hot function (measured failure
|
||||
in §6).
|
||||
|
||||
### The cost is not linear, and on arm64 it decides the tool choice
|
||||
|
||||
Two costs decide whether Stalker is the right tool, and neither is visible in the API:
|
||||
|
||||
- **Translation multiplier.** A followed thread pays a large constant factor on every translated
|
||||
block. Community reporting for arm64 puts it at **20-50x**; the mechanism is not controversial
|
||||
(every block is copied into the DBI's code cache and routed through a dispatcher), while the exact
|
||||
number is workload-dependent. This one is `inferred` — this repository has not measured the
|
||||
multiplier itself, only its consequences below.
|
||||
- **What you let into the trace.** Every block of every library the thread touches is a candidate.
|
||||
Following into `libc` / `libart` / `libhwui` is how a four-second window becomes a device-wide
|
||||
event: measured here, one such run took the test phone's `load average` to `34.11` on 8 cores and
|
||||
destroyed a co-running job's attach (§6).
|
||||
|
||||
`Stalker.exclude()` is the control for the second cost, and it is not optional.
|
||||
`scripts/stalker_trace.js` applies it before `follow()` from its `excludeModules` list and reports
|
||||
what it actually excluded on one `EXCL` line — read that line before believing any trace, because a
|
||||
module that was not loaded at follow time was **not** excluded. Exclude every system library you are
|
||||
not studying; the target module is never excluded even if its name appears in the list.
|
||||
|
||||
Three failure shapes follow from ignoring this: deadlock, a watchdog `SIGABRT`, and the zero-event
|
||||
trace. The deadlock and the watchdog shape remain community reports (`inferred`); the other two were
|
||||
measured, **and the measurement splits the claim in two** — the split matters more than the summary:
|
||||
|
||||
| Arm — one device, one package, one module, 6 s follow | Outcome |
|
||||
|---|---|
|
||||
| attach + resume, **no follow** | process survives |
|
||||
| follow, `excludeModules: []` | **process dies**, script destroyed |
|
||||
| follow, 20 system modules excluded | process survives, **but `blocks=0 blk=0 calls=0`** |
|
||||
|
||||
Exclusion is what keeps the target alive — now `observed`, with the no-follow arm ruling out "it
|
||||
would have died under frida anyway". But exclusion does **not** restore event delivery: the
|
||||
zero-event trace survived the treatment arm unchanged. Treat those as two problems, and do not offer
|
||||
`exclude` as the fix for a follow that delivers nothing. Commands and outputs:
|
||||
`docs/tool-verification/EXTENSION-stalker-exclude.md`.
|
||||
|
||||
**arm64 also raises the floor.** PAC/BTI-bearing code gives a translator more ways to mis-handle a
|
||||
block than armv7 did, which is one more reason a trace that works on an emulator is not evidence
|
||||
about a device. Treat any arm64 result as needing a control run (a followed thread executing a known
|
||||
loop, nonzero `BLK` lines) before it is interpreted.
|
||||
|
||||
**Emulation is a legitimate alternative, not a consolation prize.** When the target is a pure
|
||||
computation inside a `.so` and its environment can be faked, a Unidbg/Unicorn trace can be both
|
||||
faster and more stable than Stalker on a real arm64 device: no device load, no watchdog, no PAC, and
|
||||
a deterministic replay. That inversion of the usual intuition is half the reason
|
||||
`emulation-and-rpc.md` exists — decide with its decision table rather than by habit.
|
||||
|
||||
## 5. From a trace to a deobfuscated function (inferred)
|
||||
|
||||
This is the part the community write-up above also describes, and it is **inferred** here — the
|
||||
|
||||
@@ -55,9 +55,13 @@ Three consequences that decide how you interpret any decoded body:
|
||||
2. **A packed repeated field is invisible without a schema.** Field 4 above is three varints
|
||||
(`3, 270, 86942`) packed into one length-delimited value. A schema-free decode can only report it
|
||||
as opaque bytes — the length is known, the element boundaries are not.
|
||||
3. **An explicit zero is byte-identical to an absent field** in proto3 (`28 00` versus no bytes at
|
||||
all). A decoded `0` is not evidence that the sender set the field, and "the server sent 0" is not
|
||||
a safe conclusion from a decode alone.
|
||||
3. **A decoded `0` and a field that was never set are two different questions.** An implicit
|
||||
(no-presence) proto3 field whose value is the default writes **nothing at all**, so `field = 0`
|
||||
and "never touched" produce identical bytes; a field **with** presence (`proto2 optional`,
|
||||
`proto3 optional`) set to `0` does put its tag and a zero byte on the wire. Both halves were
|
||||
measured against the official runtime; a schema-free decoder can only flag the case, never
|
||||
resolve it. So a decoded `0` was emitted on purpose by some writer, and a field *missing* from a
|
||||
decode is not evidence that 0 was the value in use.
|
||||
|
||||
### Decoding without a schema
|
||||
|
||||
@@ -77,6 +81,63 @@ Three consequences that decide how you interpret any decoded body:
|
||||
covers reading these, and `datastore_inject.py` can re-encode an edited DataStore value once you
|
||||
know the message.
|
||||
|
||||
### The decoder that ships here (measured)
|
||||
|
||||
`scripts/protobuf_decode_raw.py` walks a payload with no schema and prints a tree. Input is a hex
|
||||
string, a binary file, or stdin (hex text and raw bytes are told apart automatically); `0x` prefixes,
|
||||
spaces, commas, newlines and `\xNN` escapes are all accepted, and `|` splits independent frames.
|
||||
`--json` emits the same tree as data, `--max-depth` bounds the nesting, and `--reencode` writes a
|
||||
decoded (possibly hand-edited) tree back to bytes and can diff the result against the original.
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/protobuf_decode_raw.py --hex "08 96 01 12 07 74657374696e67"
|
||||
frame 0 bytes 0..12 (12 B) status=end_of_buffer
|
||||
f1 varint 150
|
||||
f2 len-delimited(7) view=utf8_string
|
||||
candidate utf8_string 0.50 valid UTF-8 with no control characters
|
||||
candidate packed_varint 0.50 the payload is exactly a sequence of 7 varint(s) ...
|
||||
candidate bytes 0.20 opaque bytes: always consistent with the payload ...
|
||||
tie: packed_varint and utf8_string are equally consistent with these bytes ...
|
||||
string: 'testing'
|
||||
```
|
||||
|
||||
The point of the tool is the candidate list. Fed a nested message that `google.protobuf` 6.33.6
|
||||
itself produced (inner payload `0801120178`), it answers:
|
||||
|
||||
```
|
||||
nested_message 0.50 | packed_varint 0.50 | utf8_string 0.35 | bytes 0.20
|
||||
tie: nested_message and packed_varint are equally consistent ... the view is a display default
|
||||
```
|
||||
|
||||
Both readings really are legal protobuf for those six bytes, and no heuristic closes the gap. The
|
||||
confidence numbers are a documented ordering, not probabilities, and the `view` exists only so the
|
||||
tree can be expanded and re-encoded -- it is not a claim about what the field is.
|
||||
|
||||
**Round trip, which is the strongest evidence available without a schema.** Decode, re-encode, and
|
||||
compare bytes: the message serialized by the official runtime re-encodes byte-identically to
|
||||
`SerializeToString()`; a real AndroidX DataStore file (81 B, written by `scripts/datastore_inject.py`)
|
||||
round-trips byte-identically; and editing one value in the JSON tree and re-encoding produced a file
|
||||
that the *other* tool read back as the new value. Two of the built-in fixture families are exceptions
|
||||
by design and are reported as such rather than hidden: a non-canonical varint re-encodes to the
|
||||
minimal form, and a `--split varint-length` run compares frame bodies only, since the length prefixes
|
||||
are framing and not message data.
|
||||
|
||||
**What a schema-free decode cannot conclude -- write none of these down:**
|
||||
|
||||
- that a length-delimited field *is* a string, a submessage or a packed array, rather than that it
|
||||
could be any of them;
|
||||
- the element boundaries of a packed array, or its element type;
|
||||
- that a field's value was 0 because it is absent from the decode, or that a sender "set" a field
|
||||
because a 0 appears (see the two traps above);
|
||||
- which message a field number belongs to, or that the same field number in two payloads means the
|
||||
same thing;
|
||||
- that a payload which parses cleanly is a real message at all -- §6 has that failure mode, and the
|
||||
fixes are a second sample and a round trip, not a longer look at the first one.
|
||||
|
||||
The measured record for this tool -- every command, both input forms, the round trip, and the
|
||||
framing mistake it does *not* protect you from -- is in
|
||||
`docs/tool-verification/EXTENSION-protobuf-raw.md`.
|
||||
|
||||
## 2. Recovering the schema from the APK
|
||||
|
||||
### What generated protobuf code looks like
|
||||
@@ -253,6 +314,13 @@ their parents, and (c) when you have a schema, re-encoding the decoded structure
|
||||
with the input — the same round-trip check used in §1, which caught a decoder bug in this
|
||||
repository's own fixture.
|
||||
|
||||
**A framing choice that is wrong without being refused.** `--split varint-length` means protobuf's own
|
||||
`writeDelimitedTo` framing: a varint length, then the message. gRPC frames a message differently — one
|
||||
compression-flag byte, then a **four-byte big-endian** length (§3). Handed a real gRPC frame, the
|
||||
varint splitter reported **five** frames rather than failing, while the same bytes decoded with no
|
||||
splitting reported `stray_end_group`. Strip the 5-byte gRPC prefix yourself, and check the frame count
|
||||
either way: a wrong split still produces a decode, which is what makes it expensive.
|
||||
|
||||
**Assuming the proxy sees everything.** The proxy sees what the client sends through it. An app that
|
||||
uses QUIC (§4), a raw socket, a native client bypassing Java's HTTP stack, or a certificate-pinned
|
||||
connection shows up as silence. Silence is evidence about the transport, not about the app: check
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
# Split APKs and App Bundles
|
||||
|
||||
A store build is frequently **not one file**. Play and most OEM stores ship App Bundles, and the
|
||||
device receives a set: `base.apk` plus one `split_config.*.apk` per ABI, screen density and language
|
||||
(and, for a dynamic-feature bundle, the feature modules as well). Every member carries **the same
|
||||
package name and its own signature**, and the package manager treats the set as one package.
|
||||
|
||||
Two failures follow from treating such a set as an APK, and both look like a broken build when they
|
||||
are nothing of the kind:
|
||||
|
||||
- Signing only the base leaves the set disagreeing about its certificate; the installer rejects the
|
||||
whole set and names the package, not the member you forgot.
|
||||
- "Just merge them into one APK" is free *only* for members that carry code or native libraries. A
|
||||
member that carries resources cannot be folded into the base without merging `resources.arsc`,
|
||||
which is a resource-compiler job. A wrongly merged archive installs and then renders the wrong
|
||||
thing, or dies on the first resource lookup.
|
||||
|
||||
`scripts/repack.py` handles both routes and **refuses to guess**: it inventories the set, decides
|
||||
which route is legal from what the members actually carry, prints the reason, and only then acts.
|
||||
|
||||
**Strength labels** (same three this repository uses everywhere): **observed** = reproduced with an
|
||||
exact command and output; **inferred** = follows from observed facts, step not executed;
|
||||
**unverified** = reported or assumed, not independently confirmed. The measurements behind this
|
||||
file are recorded in `docs/tool-verification/EXTENSION-split-apk.md`.
|
||||
|
||||
## 1. What the set is
|
||||
|
||||
| Member | Manifest `split` attribute | Carries | Foldable into the base? |
|
||||
|---|---|---|---|
|
||||
| base | *(absent)* | all `classes*.dex`, the main `resources.arsc`, every resource not moved out, the real `<application>` | — it *is* the base |
|
||||
| ABI split | `config.arm64_v8a`, `config.armeabi_v7a`, `config.x86_64`, … | `lib/<abi>/*.so` only | **yes** — entry names do not collide |
|
||||
| density split | `config.hdpi` … `config.xxxhdpi` | its own `resources.arsc` **and** `res/` entries | **no** — requires a resource merge |
|
||||
| language split | `config.en`, `config.zh`, … | its own `resources.arsc` (strings) | **no** — same reason |
|
||||
| feature split | `feature_x` (+ `isFeatureSplit="true"`, `configForSplit="base"`) | its own `classes*.dex`, possibly resources | **code only**; its resources hit the same wall |
|
||||
|
||||
**Observed** on a real 9-member set pulled from a stock Android 11 device (arm64-v8a target):
|
||||
|
||||
- the base carried `classes.dex` (872 KB) and `resources.arsc` (71 KB);
|
||||
- the ABI member carried two `lib/arm64-v8a/*.so` files and **no `resources.arsc` at all**;
|
||||
- each density member carried its own `resources.arsc` and its own `res/` entries;
|
||||
- a second, 8-member set's density members carried 39-78 `res/` entries each and 9-15 KB tables.
|
||||
|
||||
So "which member owns the resources" is answerable **from the zip entry list alone** — no device, no
|
||||
resource compiler, no decompiler. Copy a set to disk and ask.
|
||||
|
||||
**A member can own a nearly empty table.** A split is *required* to carry a `resources.arsc`, so a
|
||||
placeholder one is normal: on the same real set, eight density members carried a **40-byte** table
|
||||
and no `res/` entries at all — they are placeholders, not resources. Treating "has an arsc" as "owns
|
||||
resources" would block a merge that is perfectly legal. `repack.py` uses *entries under `res/` or a
|
||||
table bigger than 1 KB* as the test.
|
||||
|
||||
## 2. Getting the set off a device
|
||||
|
||||
`pm path` prints **one line per member**; a package with several lines is a split set. This is how
|
||||
you find one to copy, and it is also how you tell, before anything else, that the thing you are about
|
||||
to patch is not a single APK:
|
||||
|
||||
```bash
|
||||
adb shell 'pm path <PKG>'
|
||||
package:/system/priv-app/<DIR>/<APP>.apk
|
||||
package:/system/priv-app/<DIR>/<APP>-arm64_v8a.apk
|
||||
package:/system/priv-app/<DIR>/<APP>-xxhdpi.apk
|
||||
```
|
||||
|
||||
To enumerate every split set on a device (toybox shell — no `--time-style`, no bash arrays):
|
||||
|
||||
```bash
|
||||
adb shell 'for p in $(pm list packages | cut -d: -f2); do c=$(pm path $p | wc -l); \
|
||||
if [ $c -gt 1 ]; then echo "$c $p"; fi; done'
|
||||
```
|
||||
|
||||
Copy the whole directory out; the members live side by side:
|
||||
|
||||
```bash
|
||||
adb shell 'su -c "ls -la /system/priv-app/<DIR>/"'
|
||||
adb pull /system/priv-app/<DIR> <dest>/
|
||||
```
|
||||
|
||||
Two traps, both **observed**:
|
||||
|
||||
- `adb pull <dir> <dest>` creates `<dest>/<dir>/` — the set arrives one level deeper than you expect.
|
||||
`repack.py --split-dir` searches recursively for exactly this reason.
|
||||
- A **third-party** split set is rare on a stock ROM. On the device measured here, `pm list packages -3`
|
||||
returned **zero** split sets among ~40 third-party apps, while four preinstalled Google components
|
||||
were split. If you need a real set and the third-party list is empty, that is the normal
|
||||
distribution, not a mistake on your side.
|
||||
|
||||
**Do not test by reinstalling a pulled set under its own package name.** It is already installed on
|
||||
that device, owned by the vendor certificate you do not have, so the install fails for a reason that
|
||||
has nothing to do with your pipeline (§6). Either work on a set you own, or rename the pulled one
|
||||
(§7) to get an installable fixture.
|
||||
|
||||
## 3. The decision: merge or resign
|
||||
|
||||
Run this first — it writes nothing and answers with a reason:
|
||||
|
||||
```bash
|
||||
python skills/apk-reverse/scripts/repack.py --split-dir <set>/ --split-mode analyze
|
||||
```
|
||||
|
||||
| Situation | Route | Why |
|
||||
|---|---|---|
|
||||
| Every non-base member carries only code and/or `lib/` (a placeholder `resources.arsc` is fine) | **merge** | nothing that needs a resource table moves |
|
||||
| Any member carries `res/` entries or a real table | **resign** | a resource merge rewrites the table; see §5 |
|
||||
| You must keep the build installable **and** the app is distributed as a bundle | **resign** | this is what the store does; the set stays a set |
|
||||
| You need one file to hand someone, sideload, or feed to a tool that takes an APK | **merge**, if legal | the only way to get a single artifact |
|
||||
| The delivery must survive store updates, or the ROM's own installer refuses multi-APK installs | **resign** (`pm install-multiple`), or merge and accept a fixed build | — |
|
||||
| A member declares `isFeatureSplit="true"` and ships `classes*.dex` **and** resources | **resign** | its code could fold, its resources cannot; a half-merged build is worse than either route |
|
||||
|
||||
Failure modes, one per route:
|
||||
|
||||
- **merge**, when a resource-carrying member is dropped to make it legal: the app starts and shows
|
||||
the base's resources. Missing density variants show up as blurry or missing images; missing
|
||||
language splits show as untranslated strings. This is a **downgraded** build and has to be
|
||||
delivered as one — `repack.py` prints `DROPPED n entry/ies` and refuses unless you pass
|
||||
`--drop-split-resources`, so the loss cannot be silent.
|
||||
- **merge**, when the base still declares `isSplitRequired="true"`: install succeeds and start is
|
||||
refused (`INSTALL_FAILED_MISSING_SPLIT`, §6). The attribute has to be cleared first, and that is a
|
||||
binary-AXML edit.
|
||||
- **resign**, when one member is missed: the set fails as a set, with a signature error naming the
|
||||
package. Sign the directory, not a list you typed by hand.
|
||||
- **resign**, when the target ROM's installer is not the platform installer: `adb install-multiple`
|
||||
can be intercepted by an OEM security centre, exactly as single-APK installs are
|
||||
(`references/repack-and-sign.md` §Vendor install interception (OEM shells)). Use the root
|
||||
`pm install-multiple` path.
|
||||
|
||||
## 4. Unified signing — the route that always works
|
||||
|
||||
```bash
|
||||
python skills/apk-reverse/scripts/repack.py \
|
||||
--split-dir <set>/ --split-mode resign \
|
||||
--split-out-dir <signed_set>/ \
|
||||
--ks key.jks --apksigner <path/to/apksigner>
|
||||
```
|
||||
|
||||
Each member is de-signed (only the signature entries), rewritten as a 4-byte-aligned archive, and
|
||||
signed with **one** keystore and **v1+v2+v3**; the run then verifies every member and prints the
|
||||
signer fingerprint per file. The acceptance criterion is not "each file verifies" — it is **one
|
||||
certificate across the whole set**:
|
||||
|
||||
```
|
||||
[result] OK: 8 members, one certificate (sha256 1eb31d9c…2c44)
|
||||
```
|
||||
|
||||
Then install the whole set, base included:
|
||||
|
||||
```bash
|
||||
adb install-multiple -r <signed_set>/*.apk
|
||||
adb shell "su -c 'pm install-multiple -r /data/local/tmp/set/*.apk'" # when an OEM installer refuses the multi-APK form
|
||||
```
|
||||
|
||||
**The signature must cover every member**, and `--abi`/density selection happens at install time, not
|
||||
at signing time — sign them all, let the installer pick (see §6).
|
||||
|
||||
### Toolchain: `uber-apk-signer` is optional, `apksigner` is not
|
||||
|
||||
`repack.py`'s original signing route went through `uber-apk-signer` only, so a machine with a full
|
||||
Android build-tools directory and no uber-apk-signer could not produce a signed build at all. The
|
||||
script now picks the route itself:
|
||||
|
||||
- `--signer jar` — the uber-apk-signer path (used automatically when the jar exists);
|
||||
- `--signer apksigner` — `zipalign -p -f 4` then `apksigner sign --v1 --v2 --v3`, which is the whole
|
||||
of what the jar was doing for us;
|
||||
- `--signer auto` (default) — jar if present, else apksigner.
|
||||
|
||||
`--apksigner` accepts all three shapes a build-tools install ships: `apksigner.bat`, the `apksigner`
|
||||
shell wrapper, or the bare `lib/apksigner.jar`. **On Windows pass the `.jar` or the `.bat`** — the
|
||||
extensionless wrapper is a shell script and will not execute. A `.jar` value is run through
|
||||
`java -jar`, so `--java` (or java on PATH) is required for it.
|
||||
|
||||
Order is load-bearing: **align, then sign**. `apksigner` adds signature entries without reshuffling
|
||||
the archive; the v1 (JAR) path emulates `jarsigner`, whose rewrite of the zip would destroy the
|
||||
alignment Android R+ requires of `resources.arsc`.
|
||||
|
||||
## 5. Merging into a single APK
|
||||
|
||||
```bash
|
||||
python skills/apk-reverse/scripts/repack.py \
|
||||
--split-dir <set>/ --split-mode merge --out merged.apk \
|
||||
--abi arm64-v8a --ks key.jks --apksigner <path/to/apksigner>
|
||||
```
|
||||
|
||||
What folds: `classes*.dex` (renumbered so a member's `classes.dex` becomes `classes2.dex`, keeping
|
||||
the base's own dex intact), `lib/**`, `assets/**`. What does not, and why: `res/**` and
|
||||
`resources.arsc`.
|
||||
|
||||
**Why resource merging is not attempted.** `resources.arsc` is an indexed table: a global string
|
||||
pool plus type-spec and type chunks, with every resource identified by a `(package, type, entry)`
|
||||
triple and every string referenced by pool offset. Folding a member's table into the base's means
|
||||
rewriting pool offsets, type/entry counts and the alignment of every string, in both directions —
|
||||
that is `aapt2 link`, not a byte-level edit. The failure it produces is the worst kind: the archive
|
||||
is *structurally* valid, installs, and then resolves resource ids to the wrong entries or to nothing.
|
||||
An APK that renders garbage is harder to diagnose than one that refuses to install.
|
||||
|
||||
If you genuinely need one standalone file for a bundle whose resources are split, the honest routes are:
|
||||
|
||||
1. **`bundletool build-apks --mode=universal`** (the vendor tool, needs the original `.aab`) — this is
|
||||
what merges resources correctly, and it is the only route that does.
|
||||
2. **Keep the split set** and install it as a set (§4).
|
||||
3. **Accept the downgrade** with `--drop-split-resources`, and say so in the delivery.
|
||||
|
||||
**Manifest constraints when you do merge — and when you patch any split at all.** These attributes
|
||||
are the difference between "installs and runs" and an install or start refusal, and they are easy to
|
||||
miss because they live in binary AXML:
|
||||
|
||||
| Attribute | Lives on | Effect if you get it wrong |
|
||||
|---|---|---|
|
||||
| `split="config.xxhdpi"` | `<manifest>` of a **split** | Identifies the member. A split whose `package` differs from the base's, or whose `split` name is duplicated, is refused as a set |
|
||||
| `configForSplit="base"` | `<manifest>` of a split | Ties a configuration split to a feature module. Changing package/module names without updating it silently detaches the split |
|
||||
| `isFeatureSplit="true"` | `<manifest>` of a feature split | Marks an install-time/on-demand module. A merge that folds code in but leaves the attribute declared describes a member that no longer exists |
|
||||
| `isSplitRequired="true"` | `<manifest>` of the **base** | The platform refuses to start a base that believes it is incomplete. This is the one that breaks merged builds (§3) |
|
||||
|
||||
Read them without a decompiler through `scripts/repack.py`'s own AXML reader (`--split-mode analyze`
|
||||
prints the `split` name per member; `inspect_apk()` returns `package`, `split`, `configForSplit`,
|
||||
`isSplitRequired`, `isFeatureSplit`). Changing any of them means editing the binary string pool and
|
||||
the attribute chunks around it, which is what `apktool` is for — and note that a full
|
||||
`apktool b` rebuild rewrites the whole archive
|
||||
(`references/repack-and-sign.md` §Repacking an unpacked (de-shelled) app).
|
||||
|
||||
## 6. Install refusals, and what each one means
|
||||
|
||||
| Message | What it actually says | Attribution |
|
||||
|---|---|---|
|
||||
| `Failure [INSTALL_FAILED_INVALID_APK: ... signatures do not match previously installed version]` / `Package <PKG> signatures do not match` | The set you are installing is signed with a different certificate than the one already on the device | **Not** a malformed build. Either re-sign to the original key, or install to a package name that does not exist yet |
|
||||
| `Failure [INSTALL_FAILED_ALREADY_EXISTS]` / `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Same package, different signer (or a downgrade without `-d`) | A pulled system component cannot be reinstalled over itself; see §7 |
|
||||
| `Failure [INSTALL_FAILED_MISSING_SPLIT: Missing split for <PKG>]` | The base declares `isSplitRequired="true"` and the set is incomplete — or you merged it | The base is telling the truth: it expects members. Clear the attribute, or install the complete set |
|
||||
| `Failure [INSTALL_FAILED_INVALID_APK: Failed to parse ... split ... has different signature]` | **One member** of the set is signed differently from the rest | This is the failure mode of signing only the base. Sign the whole directory |
|
||||
| `Failure [-124: ... resources.arsc ... stored uncompressed and aligned on a 4-byte boundary]` | One member's `resources.arsc` is compressed or unaligned | Per-member, not per-set. `repack.py` reports it per member before signing (`references/repack-and-sign.md` §2a) |
|
||||
| `Failure [-99]`, or a numeric-only code with no `INSTALL_FAILED_*` | An OEM installer interceptor rejected the request | Nothing to do with the set; use the root `pm install-multiple` path |
|
||||
| `adb install-multiple` on a device that has no `install-multiple` support (very old `pm`) | Install each member with `pm install` in order, base last | Use `pm install-create` / `install-write` / `install-commit` explicitly |
|
||||
|
||||
`INSTALL_FAILED_INVALID_APK` in particular is a **family**, not one error: it covers stale
|
||||
signatures, malformed base/split relationships, and alignment violations. Read the text after the
|
||||
colon before attributing anything — the constant alone will send you looking at the wrong file.
|
||||
|
||||
## 7. Making an installable fixture from a pulled set
|
||||
|
||||
**Observed, and the reason this section exists:** a set pulled from the device is installed *on that
|
||||
device*, under a name owned by the vendor certificate. Re-signed copies cannot be installed back
|
||||
over it, so "does our signed set install?" cannot be answered with the pulled set as it stands.
|
||||
|
||||
The cheap, structure-preserving way out is an **equal-length package rename**: replace every
|
||||
occurrence of the old package name in `AndroidManifest.xml`, `resources.arsc` and `classes*.dex`
|
||||
with a new name of exactly the same length. Nothing moves — no length prefix, no string-pool offset,
|
||||
no dex `string_ids` offset, no resource index — so the only field that must be recomputed is the dex
|
||||
header's integrity pair (signature first, checksum last, see
|
||||
`references/repack-and-sign.md` §Repacking an unpacked (de-shelled) app). Then run the ordinary
|
||||
`--split-mode resign` over the renamed directory and install that.
|
||||
|
||||
A detail worth knowing before you touch a manifest: **a member's Java classes need not live under its
|
||||
manifest `package` at all**. On the real set measured here, the manifest package was
|
||||
`com.google.android.<name>` while all 677 class references in the dex were
|
||||
`Lcom/android/<name>/...` — and every `<activity android:name>` was fully qualified. Renaming the
|
||||
manifest package therefore left every component resolvable, with no dex edit needed. Assume the
|
||||
opposite (that the two must match), and you will edit a dex for nothing.
|
||||
|
||||
## 8. ABI and density matching
|
||||
|
||||
The device tells you what it can run:
|
||||
|
||||
```bash
|
||||
adb shell getprop ro.product.cpu.abilist # e.g. arm64-v8a,armeabi-v7a,armeabi
|
||||
adb shell getprop ro.product.cpu.abi # the primary ABI
|
||||
adb shell getprop ro.product.cpu.abilist32 # 32-bit list, on a 64-bit device
|
||||
```
|
||||
|
||||
Match against the **split name**, not the file name, and mind the separator: split qualifiers use
|
||||
**underscores** (`config.arm64_v8a`) while `lib/` directories use **hyphens** (`lib/arm64-v8a/`). The
|
||||
mapping is a substitution, not a lookup — `arm64_v8a` → `arm64-v8a`, `armeabi_v7a` → `armeabi-v7a`;
|
||||
`x86` and `x86_64` are unchanged. That is why `--abi arm64-v8a` matches a split named
|
||||
`config.arm64_v8a`.
|
||||
|
||||
Four rules that decide ABI questions:
|
||||
|
||||
- **A 64-bit device still installs a 32-bit-only set**, and then runs it 32-bit. If the base carries
|
||||
no native code and only the `armeabi_v7a` split does, the app is a 32-bit app on that device.
|
||||
- **The ABI split is selected by the installer, not by `repack.py`.** `--abi` exists for *merging*
|
||||
(where the choice is baked into one file); for `--split-mode resign`, sign every member and let
|
||||
`pm` pick.
|
||||
- **Merging without `--abi` folds in every ABI member**, which makes one APK with two or three copies
|
||||
of the same library set. Legal, larger, and pointless on a single-device delivery.
|
||||
- **`getprop` reports what the device claims**; what runs is a live mapping question
|
||||
(`scripts/lib_map.py`). A device that reports `arm64-v8a` first can still execute a 32-bit process.
|
||||
|
||||
Density is the same shape without the separator trap: `config.xxhdpi` matches
|
||||
`res/drawable-xxhdpi/`, and the installer picks the closest variant the device's `ro.sf.lcd_density`
|
||||
can use. There is nothing to select on your side.
|
||||
|
||||
## 9. Traps worth paying for once
|
||||
|
||||
- **`resources.arsc` belongs to whoever ships it.** In a split set it exists *per member*, and the
|
||||
base's table does not contain the density member's entries. Reading the base's table and concluding
|
||||
"the resource exists" is wrong; it exists in one configuration only.
|
||||
- **`android:extractNativeLibs`.** If the base declares `extractNativeLibs="false"` (the default for
|
||||
modern targetSdk), the `.so` files are mmapped straight out of the archive, and they must be
|
||||
**uncompressed and page-aligned** — this is what `zipalign -p` exists for. Merging native members
|
||||
into a base and then writing the archive without that alignment produces an install that succeeds
|
||||
and a start that dies in the linker. `repack.py` writes uncompressed `lib/**` 4-byte aligned, and
|
||||
`zipalign -p -f 4` adds the page alignment the loader wants for them.
|
||||
- **A placeholder `resources.arsc` is not a resource split** (§1). Blocking on "has an arsc" refuses
|
||||
legal merges.
|
||||
- **Do not `zipalign` after signing to "fix" a member.** It rewrites the archive and invalidates the
|
||||
signature. Align, then sign.
|
||||
- **`apktool` may not be usable even when a wrapper exists.** Measured on the pass machine: the
|
||||
`apktool.bat` wrapper pointed at a jar that was not on disk
|
||||
(`Error: Unable to access jarfile <missing>.jar`), while `apksigner.jar` and `zipalign.exe` in the
|
||||
same layout were fine. Check the wrapper's target before planning a manifest edit around it.
|
||||
- **The AXML `split` attribute is a string-pool entry**, which is why an equal-length rename of it is
|
||||
safe and why a length-changing edit is not: the pool's own length prefixes and the attribute chunk
|
||||
sizes around it would both need recomputing.
|
||||
|
||||
## 10. What this file does not claim
|
||||
|
||||
- **It does not merge resources.** No route here reproduces `bundletool build-apks --mode=universal`.
|
||||
A bundle whose resources live in splits stays a set, or becomes a downgraded single APK by explicit
|
||||
choice.
|
||||
- **It does not split a monolithic APK back into modules**, and it cannot turn a `.aab` into APKs —
|
||||
that is `bundletool`, and the `.aab` is the input it needs.
|
||||
- **`--split-mode merge` covers code and native libraries only** (§5). Feature-split *code* folds;
|
||||
feature-split *resources* do not.
|
||||
- The install-failure texts in §6 are the shape the platform produces; **the exact wording varies by
|
||||
Android version and by OEM installer**. Read the text after the constant, not the constant.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Differential hardening (the known-plaintext route to a private opcode table)
|
||||
|
||||
Load this when the dump is a **real Dex VMP** — bodies present, decoding as nonsense to a
|
||||
dalvik disassembler, a native interpreter loop behind them — and you have decided the
|
||||
private opcode space is worth attacking rather than walking away from. The neighbouring
|
||||
file owns the diagnosis (`advanced-unpacking.md` §The honest boundary: real Dex VMP) and
|
||||
the sentence that sends you here. This file owns what happens next, and — more of it than
|
||||
is comfortable — what does not.
|
||||
|
||||
**Strength note, read before trusting any number here.** Every mechanism below was
|
||||
exercised on locally built fixtures, and the closed loop is measured: `simulate` relabels
|
||||
a fixture through a known bijection and `compare` re-derives that table **exactly, 218 of
|
||||
218 emitted opcodes, zero wrong entries, zero fabricated entries** (`docs/tool-verification/EXTENSION-vmp-diff.md`).
|
||||
What was **not** exercised is the other half of the premise: **no third-party hardening
|
||||
platform was contacted**. The assumption that a real engine performs a per-opcode
|
||||
substitution at stable instruction length is **inferred** from public VMP write-ups and is
|
||||
exactly the assumption this method can fail on. The cost table's platform column is
|
||||
therefore reasoning from how those platforms are distributed, not from a measured upload.
|
||||
|
||||
## 1. The method, and the one thing it needs to be true
|
||||
|
||||
The hardened body cannot tell you where its instruction boundaries are: its opcodes are
|
||||
private, so the format specification's length table does not apply to it. That is the
|
||||
whole obstacle — a disassembler that cannot decide where instruction *n* ends cannot name
|
||||
instruction *n+1*.
|
||||
|
||||
The differential dissolves it with an oracle you control. Compile a fixture whose every
|
||||
instruction you know; get the same platform to harden it; then **take the original's
|
||||
instruction boundaries and project them onto the hardened bytes**. Original offset
|
||||
`0x14` was a `mul-int` occupying 2 code units, so hardened bytes `0x14..0x17` are the same
|
||||
instruction, re-encoded. Read the hardened opcode byte at each projection and you have a
|
||||
`(original opcode → private byte)` sample. Aggregate over thousands of instructions and
|
||||
the substitution table falls out.
|
||||
|
||||
Three preconditions, and they are not negotiable:
|
||||
|
||||
| Precondition | Why it must hold | What it looks like when it fails |
|
||||
|---|---|---|
|
||||
| **The hardened body keeps the same length** | The projection is a stencil; a length change slides every boundary after the first edit | `compare` reports `resized` for the whole method and refuses the run |
|
||||
| **The substitution is per-opcode, not per-method** | One private byte per original opcode is what makes the table a table | Every original opcode claims the same private byte (`compare` → `NOT-USABLE`, stub shape) |
|
||||
| **The body is still in the dex** | Alignment needs two byte streams | Method has `code_off == 0` (`compare` reports `stripped`) |
|
||||
|
||||
The third is the one that kills the method most often in practice: a VMP that *extracts*
|
||||
bodies to a native interpreter leaves nothing to align. The differential route then has no
|
||||
input at all, and no amount of clever fixture design creates one. Check for it before
|
||||
spending any time here.
|
||||
|
||||
## 2. Cost table — which links can be automated, and which cannot
|
||||
|
||||
This is the part the method's public descriptions leave out. The chain is six links; two
|
||||
of them are unavoidably human, and pretending otherwise is how a "harness" ships that has
|
||||
never once been run against a real target.
|
||||
|
||||
| # | Link | Automation | Where a human must stand | Time cost | Failure mode |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Compile the coverage fixture into a dex + APK | **Full** — `scripts/vmp_diff_harness.py build`, measured | none | ~40 s on this machine (javac + d8 + aapt2), ~1 s of it the script | toolchain absent or version-drifted; `d8` rejects a non-existent `--output` directory, `aapt2 link` rejects `android:` attributes without `android.jar` |
|
||||
| 2 | **Submit to the hardening platform** | **None. Not automatable.** | 100 % — a browser, an account, sometimes a queue and a review | minutes to **days**; the platform decides | **the binding constraint of the whole route.** No public API; per-account rate limits; uploads are frequently gated behind registration; the artifact often comes back as a download page rather than a file path. A synthetic fixture may simply be **refused** (no real app shape, no certificate, no package identity) |
|
||||
| 3 | Retrieve the hardened output and extract its dex | Partial — if the output is an APK, `unzip` + `dexutil.load_dex` handles it | download and hand-off | seconds, once the file exists | output is an APK whose dex is *also* encrypted, or the platform returns only a repacked APK with a native loader and no readable dex |
|
||||
| 4 | Derive the correspondence | **Full** — `compare`, measured end to end | none | **sub-second** on a 3,065-instruction fixture | alignment slips (see §4); engine is not a pure relabelling |
|
||||
| 5 | Prove the derived table | Partial — the mechanical half is scripted and measured; the judgement is not | decide whether the held-out check was passed or merely not attempted | minutes | a table that is *self-consistent* but describes a stub (§5) |
|
||||
| 6 | Render Smali from the restored stream | Full — `emit-smali`, measured | none | sub-second | table has holes; restored stream decodes short of the method end |
|
||||
|
||||
**Conclusion, stated plainly: do not build a fully automatic pipeline.** Link 2 is
|
||||
irreducibly manual — the platform has no interface a script can drive, and its output
|
||||
cadence is not one a pipeline can wait on. The right shape is what this repository ships:
|
||||
|
||||
- links 1, 4 and 6 as **reliable local tools** (they are deterministic and were measured);
|
||||
- link 5 as **a checklist plus one scripted test** (§5) — the judgement stays human;
|
||||
- link 2 as **a documented manual step**, with its cost stated up front, so nobody writes
|
||||
an orchestrator that waits forever on a web form;
|
||||
- and links 4–5 **self-verifiable without the platform at all**, which is what `simulate`
|
||||
exists for. You can prove your comparison logic against a known table today; you cannot
|
||||
prove the engine's behaviour until a human uploads a fixture.
|
||||
|
||||
The practical consequence: the deliverable of this route is an **analysis report with a
|
||||
stated boundary**, not a pipeline. Say so before starting.
|
||||
|
||||
## 3. The coverage fixture — what a compiled probe can and cannot reach
|
||||
|
||||
A Java-compiled fixture reaches **218 of the 224 named opcodes in this repository's format
|
||||
table (97.3 %)** — that number is measured, not estimated, and the six it misses are
|
||||
structural rather than incidental. The reason to care is direct: **an opcode your fixture
|
||||
never emits leaves no known plaintext, so its substitution is unreadable by construction.**
|
||||
|
||||
| Missed opcode | Why a javac + d8 fixture cannot reach it |
|
||||
|---|---|
|
||||
| `const-string/jumbo` (0x1b) | needs a `string_ids` index > 65535, i.e. a fixture shipping 65,536+ string constants |
|
||||
| `goto/32` (0x2a) | needs a >32767-code-unit backward jump; javac refuses the method first with `error: code too large` — its own 64 KB per-method bytecode cap. **Measured** on a generated 33,000-statement loop |
|
||||
| `const-method-handle` (0xfe), `const-method-type` (0xff) | no Java-language literal exists for these constants; reachable only via hand-written smali or direct dex construction |
|
||||
| `move-object/16` (0x09) | needs **both** registers ≥ 256; the object frame pushes the source past 256 but d8 encodes that as `move-object/from16` |
|
||||
| `invoke-custom/range` (0xfd) | d8 emitted the 35c form for every lambda shape tried, including a six-parameter one |
|
||||
|
||||
What the fixture **does** cover, by construction, and the design decisions that get it
|
||||
there (each of these was a measured gap before it was closed):
|
||||
|
||||
- **Three-register forms.** javac emits the `/2addr` form whenever the destination is one
|
||||
of the operands, so `sub-int`, `div-int`, `rem-int`, `and-int`, `or-int`, `xor-int`,
|
||||
`shl-int`, `shr-int`, `ushr-int` and their long/float/double counterparts are **absent**
|
||||
from ordinary code. Three distinct locals per operation is what forces them into the
|
||||
stream; the `/2addr` forms then need the accumulator spelling. Both are wanted.
|
||||
- **Literal widths.** `div-int/lit16` needs a divisor in 128..32767; `and-int/lit8` needs a
|
||||
mask inside −128..127. Writing `& 255` produces `and-int/lit16` and silently skips the
|
||||
lit8 form.
|
||||
- **Per-type field access.** `iget-wide`, `iget-object`, `iget-boolean`, `iget-byte`,
|
||||
`iget-char`, `iget-short` and the `sget` set all appear only when the load targets a
|
||||
**fresh local**. Accumulating into an existing register is where d8 narrows the access.
|
||||
- **`/range` invokes.** The 35c invoke forms carry at most five argument registers, so a
|
||||
seven-register call (`this` plus six ints) is what makes the `/range` encoding the only
|
||||
legal one.
|
||||
- **Wide registers and long jumps** — `move/16`, `move-wide/16`, `move-object/from16`,
|
||||
`goto/16` — have **no hand-written form at all**. They exist only past a register-number
|
||||
(≥256) or branch-distance (>127 code units) threshold, so the generator emits bulk
|
||||
source: 300 int locals, 140 long locals, 160 object locals, a 200-statement loop body.
|
||||
Measured yield: `move/16` ×46, `move-wide/16` ×20, `move-object/from16` ×161, `goto/16` ×1.
|
||||
- **Payloads and structure**: `packed-switch`/`sparse-switch` with their payload blocks,
|
||||
`fill-array-data`, `filled-new-array/range`, try/catch ranges with `move-exception`,
|
||||
`monitor-enter`/`monitor-exit`, `instance-of`/`check-cast`, and `invoke-custom` /
|
||||
`invoke-polymorphic` (which is why `d8 --min-api 26 --no-desugaring` is the default —
|
||||
desugaring replaces lambdas with synthetic classes and the two opcodes disappear).
|
||||
|
||||
**Report the coverage every time.** `build` prints it, `audit` recomputes it for any dex.
|
||||
A differential run whose coverage is 60 % produces a table that is 40 % missing, and
|
||||
nothing in the output will say so unless you look at this number.
|
||||
|
||||
## 4. Extracting the correspondence
|
||||
|
||||
`compare ORIGINAL HARDENED` does four things, and the first three are diagnostics that
|
||||
decide whether the fourth is meaningful:
|
||||
|
||||
1. **Pair methods** by `(class, name, descriptor)` and classify each pair. `aligned` means
|
||||
both sides have a `code_item` of **identical `insns_size`** — the only state in which
|
||||
projection is legal. `resized` means the length moved. `stripped` means the hardened
|
||||
method has `code_off == 0`. `missing`/`added` mean the method set itself moved.
|
||||
2. **Project and sample.** For each aligned pair, walk the original's decoded instruction
|
||||
list; for instruction at original offset *o*, read the hardened byte at
|
||||
`hardened.insns_off + (o − original.insns_off)`. Record `(original opcode → hardened
|
||||
byte)` with a count.
|
||||
3. **Aggregate and audit the aggregate.** Per original opcode: one candidate byte and the
|
||||
row is `high`; more than one and the row is a `conflict`. Then the **reverse
|
||||
direction** — build `private byte → set of original opcodes` and flag every private byte
|
||||
claimed by more than one original. This reverse pass is not a nicety; it is the only
|
||||
thing that catches the stub shape (§5).
|
||||
4. **Emit a run-level verdict**, because a table is dangerous without one: `usable`,
|
||||
`partially-usable`, `not-usable`, or `not-applicable` with the reason. A `not-applicable`
|
||||
run exits non-zero.
|
||||
|
||||
Unreadable opcodes are reported as `undetermined` with the reason — either the fixture
|
||||
never emitted them (§3) or the dex slot is unallocated. They are **not** guessed at.
|
||||
|
||||
## 5. Proving the table — the part that decides whether any of this is real
|
||||
|
||||
A candidate table is a hypothesis. Four checks, in increasing order of what they can
|
||||
falsify:
|
||||
|
||||
**a. Closed-loop regression — measured, and the strongest available without a platform.**
|
||||
`simulate` relabels a fixture's opcodes through a bijection whose ground truth it writes
|
||||
out; `compare` must re-derive that table from the two dexes alone. Measured on this
|
||||
repository's fixture: **218 rows derived, 218 of 218 emitted opcodes recovered, zero
|
||||
entries wrong, zero entries fabricated, zero non-injective private bytes, verdict
|
||||
`usable`.** Any change to the comparison logic that breaks this check is a regression, and
|
||||
the check needs no platform, no device and no network.
|
||||
|
||||
**b. Injectivity — the check that catches a confident wrong answer.** Every private byte
|
||||
must be claimed by exactly one original opcode. Measured against the stub shape (bodies
|
||||
replaced by an equal-length `return-void` fill): per-opcode reading looks *perfect* — 218
|
||||
rows, all `high`, zero conflicts — because every original opcode maps to the same byte.
|
||||
Only the reverse direction exposes it: one private byte claimed by **218** originals →
|
||||
`NOT-USABLE`. A table produced without this check would have been silently worthless.
|
||||
|
||||
**c. Held-out consistency.** Derive the table from one subset of methods, then use it to
|
||||
disassemble a **different** subset that contributed nothing to the derivation. A correct
|
||||
table restores the instructions such that the walk lands exactly on
|
||||
`insns_off + insns_size × 2` and every branch target lands on an instruction boundary. A
|
||||
wrong or incomplete table desynchronises and says so. `emit-smali`'s
|
||||
`instructions restored` / `unmapped-opcode stops` counters are the readout: on the measured
|
||||
fixture, 3,065 restored and 0 stops.
|
||||
|
||||
**d. Cross-check against a second, independent recovery.** The same private opcode space
|
||||
observed through a *different* route — an in-memory dump of the original dex alongside the
|
||||
hardened one, or the `/proc/<pid>/mem` route in
|
||||
`advanced-unpacking.md` §Dumping when frida is refused — is the only evidence that the
|
||||
platform's transformation is the one you assumed. **Not done here**; it requires a real
|
||||
VMP sample and a real platform, and is the honest gap in this file.
|
||||
|
||||
Note what none of these checks establish: that the engine's substitution is *stable across
|
||||
inputs*. Fixture-derived tables have been reported in public work to drift per
|
||||
vendor-version and per hardening pass. Re-derive per target; never cache a table.
|
||||
|
||||
## 6. Generating Smali from a restored stream
|
||||
|
||||
`emit-smali HARDENED_DEX --table TABLE.json` restores opcode bytes through the table and
|
||||
renders method bodies as an annotated smali **reading skeleton**:
|
||||
|
||||
- Boundaries come from the table itself — once a byte maps back to a standard opcode the
|
||||
standard length table applies to that instruction, so the walk is self-bootstrapping.
|
||||
A byte with no table entry stops that method's walk, and the count of such events is
|
||||
printed as the honest coverage measure.
|
||||
- `.class` / `.super` / `.source` / `.method` headers are real, with access flags decoded
|
||||
from `access_flags`, so the skeleton is readable as smali rather than as a hex dump.
|
||||
- Payload blocks (`packed-switch-payload` etc.) are stepped over by their own layout, not
|
||||
treated as opcodes.
|
||||
|
||||
**It is explicitly not assembliable, and the output says so in its first ten lines.**
|
||||
Recovered: instruction boundaries and opcode names. Not recovered: register numbers,
|
||||
field/method/string indices as the engine rewrote them, and any register re-allocation —
|
||||
**no map for those exists in this method at all.** Feeding the skeleton to a smali
|
||||
assembler produces an empty method, which looks like success and is not.
|
||||
|
||||
## 7. When this route does not work — the honest retreat
|
||||
|
||||
| What `compare` reports | What it means | Do this |
|
||||
|---|---|---|
|
||||
| `verdict: not-applicable`, `stripped` > 0 | bodies left the dex for a native interpreter | the differential has no input. Stop. Return to `advanced-unpacking.md` §The honest boundary: real Dex VMP and price the island honestly |
|
||||
| `verdict: not-applicable`, `resized` > 0 | the engine's stream has its own length table | the projection premise is false. Stop rather than guessing boundaries |
|
||||
| `verdict: not-usable`, one private byte shared by many originals | bodies replaced by a common stub | the engine did not relabel per-opcode here. Stop |
|
||||
| `verdict: partially-usable`, conflicts ≤ 30 % | partial relabelling, or alignment slipping on some methods | usable only for the `high` rows; say so and stop at the unreadable ones |
|
||||
| `usable`, but coverage was low | a complete table over an incomplete opcode set | usable, with the uncovered opcodes named as unreadable |
|
||||
|
||||
And the retreat that outranks all of them: **the cheaper question is whether the behaviour
|
||||
you need exists outside the virtualized island.** A hook at the method's Java boundary, an
|
||||
emulated call to the routine rather than a reading of it
|
||||
(`emulation-and-rpc.md`), or a server-side answer frequently reaches the goal without ever
|
||||
paying for the island. Weeks of table recovery is the expensive way to ask a question a
|
||||
boundary hook answers in an hour.
|
||||
|
||||
## 8. Decision summary
|
||||
|
||||
| Observation | Action | Where |
|
||||
|---|---|---|
|
||||
| Bodies present, decode as nonsense | Diagnose real VMP before anything else | `advanced-unpacking.md` §The honest boundary: real Dex VMP |
|
||||
| Need the private opcode mapping | Build the coverage fixture, then submit it by hand | `scripts/vmp_diff_harness.py build`; §2 of this file |
|
||||
| Asked to "automate the whole chain" | Say which link cannot be automated and what it costs | §2 — the platform step is manual, by construction |
|
||||
| Fixture coverage below ~90 % | Add the missing opcode classes before submitting | §3, and the `audit` report |
|
||||
| Two dexes in hand | Derive the candidate table, read the verdict first | `scripts/vmp_diff_harness.py compare` |
|
||||
| About to trust a derived table | Run the closed loop, the injectivity check and a held-out subset | §5 — a/b/c are scripted |
|
||||
| Table complete | Render the skeleton; expect operands, not registers | `scripts/vmp_diff_harness.py emit-smali` |
|
||||
| `resized` / `stripped` / stub verdict | Stop and say so | §7 |
|
||||
@@ -9,9 +9,18 @@ structure before trusting); this script is the tool that rule was missing.
|
||||
|
||||
Per file it reports: size, sha256, dex version, checksum/signature verdicts
|
||||
(`dexutil.verify_dex_header`), class count, method-body census (no-code / real /
|
||||
trivial-stub / empty), the trivial-body ratio that flags an extraction-shell
|
||||
skeleton, and --find pattern hits across the string table. Files are grouped by
|
||||
sha256 and the survivors are ranked: most likely original first.
|
||||
trivial-stub / nop-erased / minimal-form / empty), the stub and emptied ratios that
|
||||
flag an extraction-shell skeleton, and --find pattern hits across the string table.
|
||||
Files are grouped by sha256 and the survivors are ranked: most likely original first.
|
||||
|
||||
**Read `emptied%`, not `stub%`, for the skeleton call.** The two differ, and the
|
||||
difference is load-bearing: `stub%` counts only bodies left as a lone `return*`, while
|
||||
`emptied%` also counts bodies wiped to nothing but nops. A shell that clears the slot
|
||||
instead of writing a return scores `stub% = 0.0` on a fully emptied image — measured,
|
||||
and it used to be ranked the *most likely original* because of it. `stub%` is also
|
||||
bimodal in practice: it lands at the app's own baseline or at ~100 %, with no band in
|
||||
between, and partial extraction (75 % of bodies removed) moves it by 0.2 points. Full
|
||||
matrix and commands: `docs/tool-verification/EXTENSION-extraction-shell-bench.md`.
|
||||
|
||||
Exit code 0 unless nothing in the input parses as a dex at all (exit 1).
|
||||
|
||||
@@ -36,12 +45,34 @@ RETURN_OPS = {0x0E, 0x0F, 0x10, 0x11} # return-void / return / return-wide / re
|
||||
|
||||
|
||||
def classify_body(data, code_off):
|
||||
"""Classify one code_item's instruction stream: 'empty' | 'stub' | 'real'.
|
||||
"""Classify one code_item's instruction stream.
|
||||
|
||||
'stub' = skip plain nop units (0x0000) and exactly one return* remains. That is
|
||||
what an extraction shell leaves behind when it defers decryption to first
|
||||
invocation, and what a repair fixture writes when mimicking one. Anything with a
|
||||
second meaningful unit is real code, however small.
|
||||
Returns one of 'empty' | 'stub' | 'erased' | 'malformed' | 'real'.
|
||||
|
||||
'stub' -- skip plain nop units (0x0000) and exactly one return* remains.
|
||||
That is what an extraction shell leaves behind when it defers
|
||||
decryption to first invocation, and what a repair fixture writes
|
||||
when mimicking one.
|
||||
'erased' -- insns_size > 0 but every unit is 0x0000. Measured on a fixture:
|
||||
a body emptied by nop fill lands here, and reporting it as 'real'
|
||||
(the pre-2026-09 behaviour) inverted the ranking, because a fully
|
||||
nop-filled skeleton scored stub% = 0.0 and was named the most
|
||||
likely original. A real compiler never emits a whole body of
|
||||
nops, so this is skeleton evidence, not code.
|
||||
'minimal' -- a body truncated to `const/4 vR, #0; return vR` and nop-padded,
|
||||
with a non-zero high byte on the return. This is a *legal* minimal
|
||||
non-void body, not a malformed one -- and it is a skeleton shape
|
||||
the stub detector is blind to by design. Counted separately so the
|
||||
blind spot is visible in the report instead of hiding inside
|
||||
'real'. Measured: a fixture with all 5,061 bodies cut to this form
|
||||
reports stub% = 1.9%, the same as its untouched control.
|
||||
'real' -- anything else, including a body of a single non-return unit.
|
||||
|
||||
A two-unit `const/4 v0, #0; return v0` is deliberately **not** a stub: it is what
|
||||
a *legal* minimal body looks like, and treating it as skeleton evidence would
|
||||
make every tiny accessor a false positive. That asymmetry is the detector's
|
||||
designed boundary, and it is measured in
|
||||
`docs/tool-verification/EXTENSION-extraction-shell-bench.md`.
|
||||
"""
|
||||
insns_size = u32(data, code_off + 12)
|
||||
if insns_size == 0:
|
||||
@@ -55,14 +86,27 @@ def classify_body(data, code_off):
|
||||
continue
|
||||
meaningful += 1
|
||||
last_unit = unit
|
||||
if meaningful > 1:
|
||||
return "real" # early exit: two units can never be a stub
|
||||
if meaningful > 2:
|
||||
return "real" # three units can never be a stub or a minimal
|
||||
if meaningful == 0:
|
||||
return "erased" # every unit was 0x0000
|
||||
if meaningful == 1:
|
||||
op = last_unit & 0xFF # first byte holds the opcode for 10x/11x
|
||||
if op == 0x0E and last_unit == 0x000E: # return-void, no register
|
||||
return "stub"
|
||||
if op == 0x0E:
|
||||
# 10x return-void has no operand: `0xNN0E` with NN != 0 is `return vNN`,
|
||||
# i.e. a non-void return wearing a return-void opcode -- minimal form.
|
||||
return "stub" if last_unit == 0x000E else "minimal"
|
||||
if op in (0x0F, 0x10, 0x11): # 11x returns: high byte is the register
|
||||
return "stub"
|
||||
return "real"
|
||||
# meaningful == 2: legal minimal body only when it is `const/4 vR,#0; return* vR`
|
||||
first_unit = 0
|
||||
for k in range(insns_size):
|
||||
if u16(data, base + k * 2):
|
||||
first_unit = u16(data, base + k * 2)
|
||||
break
|
||||
if (first_unit & 0xFF) == 0x12 and (last_unit & 0xFF) in (0x0E, 0x0F, 0x10, 0x11):
|
||||
return "minimal"
|
||||
return "real"
|
||||
|
||||
|
||||
@@ -161,7 +205,7 @@ def profile_dex(path, patterns, trim=False):
|
||||
prof["signature_ok"] = sig_ok
|
||||
|
||||
prof["classes"] = header["class_defs_size"]
|
||||
no_code = with_code = stubs = empties = 0
|
||||
no_code = with_code = stubs = empties = erased = minimal = 0
|
||||
try:
|
||||
for code_off in walk_methods(data, header):
|
||||
if code_off == 0: # abstract / native declaration
|
||||
@@ -175,14 +219,26 @@ def profile_dex(path, patterns, trim=False):
|
||||
stubs += 1
|
||||
elif kind == "empty":
|
||||
empties += 1
|
||||
elif kind == "erased":
|
||||
erased += 1
|
||||
elif kind == "minimal":
|
||||
minimal += 1
|
||||
except Exception as exc: # desynced class_data: keep counts
|
||||
prof["walk_error"] = str(exc)
|
||||
prof["methods_no_code"] = no_code
|
||||
prof["methods_with_code"] = with_code
|
||||
prof["bodies_real"] = with_code - stubs - empties
|
||||
prof["bodies_real"] = with_code - stubs - empties - erased - minimal
|
||||
prof["bodies_stub"] = stubs
|
||||
prof["bodies_empty"] = empties
|
||||
prof["bodies_erased"] = erased
|
||||
prof["bodies_minimal"] = minimal
|
||||
# trivial_ratio keeps its original definition (return*-stub share of bodies) so
|
||||
# existing records stay comparable. The skeleton signal is the union of every
|
||||
# measured emptied shape; a body wiped to nothing at all must never score lower
|
||||
# than a body wiped to `return-void`, or the ranking inverts (see
|
||||
# docs/tool-verification/EXTENSION-extraction-shell-bench.md).
|
||||
prof["trivial_ratio"] = (stubs / with_code) if with_code else 0.0
|
||||
prof["emptied_ratio"] = ((stubs + erased) / with_code) if with_code else 0.0
|
||||
|
||||
if patterns:
|
||||
try:
|
||||
@@ -207,7 +263,38 @@ def collect_files(paths):
|
||||
|
||||
|
||||
def rank_key(prof):
|
||||
return (prof["trivial_ratio"],
|
||||
"""Sort key: most-likely-original first.
|
||||
|
||||
Both body signals are needed, in the right order, and each was measured to fail
|
||||
alone:
|
||||
|
||||
* the `return*`-stub share alone let a **nop-filled** skeleton score 0.0 and be
|
||||
named the most likely original, because a wiped body counts as neither a stub
|
||||
nor real code;
|
||||
* `emptied_ratio` alone still failed, because it is **bimodal** -- it sits at the
|
||||
host app's own baseline or at ~100 %, with nothing in between. A dex with 25 %
|
||||
of its bodies emptied measures *below* its own untouched control, 1.7 % against
|
||||
1.9 %, so the sort pointed at a modified image and called it the original;
|
||||
* `minimal + erased` alone failed too, and in the opposite direction: a skeleton
|
||||
that writes a bare `return-void` into every body has minimal = erased = 0 and
|
||||
would sort first, despite `emptied%` = 100.
|
||||
|
||||
So the key combines both, and the combination has to let *either* signal disqualify
|
||||
an image rather than letting them tie:
|
||||
|
||||
* level 1 -- `emptied_ratio >= 0.5`. Nothing that is mostly emptied is a candidate,
|
||||
whatever else it scores. This is the level that evicts the two skeletons the
|
||||
plain counts cannot see: an image with a bare `return-void` in every body has
|
||||
minimal = erased = 0, so a pure-count key ranked it first.
|
||||
* level 2 -- `minimal + erased`, the skeleton-evidence count, low-first. This is
|
||||
what orders the **bimodal band**, where `emptied_ratio` cannot: the untouched
|
||||
control carries 82, and the count is monotone as bodies are removed (1299 /
|
||||
2524 / 3736 / 4946 at 25 / 50 / 75 / 100 %).
|
||||
* level 3 -- `emptied_ratio` low-first, to separate what is left.
|
||||
"""
|
||||
return (1 if prof.get("emptied_ratio", prof["trivial_ratio"]) >= 0.5 else 0,
|
||||
prof.get("bodies_minimal", 0) + prof.get("bodies_erased", 0),
|
||||
prof.get("emptied_ratio", prof["trivial_ratio"]),
|
||||
0 if prof["checksum_ok"] else 1,
|
||||
0 if prof["signature_ok"] else 1,
|
||||
prof["name"])
|
||||
@@ -248,9 +335,9 @@ def main(argv=None):
|
||||
# ---- human-readable report -------------------------------------------------
|
||||
print("== dex dump validation: %d file(s), %d parse, %d rejected =="
|
||||
% (len(profiles), len(valid), len(profiles) - len(valid)))
|
||||
fmt = "%-28s %9s %-12s %3s %-7s %-7s %6s %5s %6s %7s"
|
||||
fmt = "%-28s %9s %-12s %3s %-7s %-7s %6s %6s %6s %7s %7s %7s"
|
||||
print(fmt % ("name", "size", "sha256[:12]", "ver", "cksum", "sig",
|
||||
"class", "noco", "code", "stub%"))
|
||||
"class", "noco", "code", "stub%", "erased%", "emptied%"))
|
||||
for p in profiles:
|
||||
if "error" in p:
|
||||
print("%-28s %9s %-12s %3s REJECTED: %s"
|
||||
@@ -261,12 +348,19 @@ def main(argv=None):
|
||||
"ok" if p["checksum_ok"] else "BAD",
|
||||
"ok" if p["signature_ok"] else "BAD",
|
||||
p["classes"], p["methods_no_code"], p["methods_with_code"],
|
||||
"%.1f%%" % (100.0 * p["trivial_ratio"])))
|
||||
"%.1f%%" % (100.0 * p["trivial_ratio"]),
|
||||
"%.1f%%" % (100.0 * (p["bodies_erased"] / p["methods_with_code"]
|
||||
if p["methods_with_code"] else 0.0)),
|
||||
"%.1f%%" % (100.0 * p.get("emptied_ratio", 0.0))))
|
||||
if p.get("trimmed_from"):
|
||||
print("%-28s trimmed %d -> %d B (page-aligned dump)"
|
||||
% ("", p["trimmed_from"], p["trimmed_to"]))
|
||||
if p.get("walk_error"):
|
||||
print("%-28s walk stopped early: %s" % ("", p["walk_error"]))
|
||||
if p.get("bodies_minimal"):
|
||||
print("%-28s minimal-form bodies: %d (const/4+return truncation -- a "
|
||||
"skeleton shape the stub%% column cannot see)"
|
||||
% ("", p["bodies_minimal"]))
|
||||
for pat, n in sorted(p.get("find_hits", {}).items()):
|
||||
print("%-28s find %-24s %d" % ("", pat, n))
|
||||
|
||||
@@ -283,18 +377,32 @@ def main(argv=None):
|
||||
print("\nranking (most likely original first):")
|
||||
for i, p in enumerate(ranking, 1):
|
||||
note = ""
|
||||
if p["trivial_ratio"] >= 0.5:
|
||||
note = " <-- SKELETON: stub%%=%.0f, extraction-shell shape" \
|
||||
% (100.0 * p["trivial_ratio"])
|
||||
print(" %d. %-28s stub%%=%.1f cksum=%s sig=%s classes=%d%s"
|
||||
if p.get("emptied_ratio", 0.0) >= 0.5:
|
||||
note = (" <-- SKELETON: emptied%%=%.0f, extraction-shell shape"
|
||||
% (100.0 * p["emptied_ratio"]))
|
||||
elif p.get("bodies_erased", 0):
|
||||
note = (" <-- %d body(ies) wiped to nops: skeleton evidence"
|
||||
% p["bodies_erased"])
|
||||
print(" %d. %-28s stub%%=%.1f erased%%=%.1f min/erased=%d cksum=%s "
|
||||
"sig=%s classes=%d%s"
|
||||
% (i, p["name"], 100.0 * p["trivial_ratio"],
|
||||
100.0 * (p["bodies_erased"] / p["methods_with_code"]
|
||||
if p["methods_with_code"] else 0.0),
|
||||
p.get("bodies_minimal", 0) + p.get("bodies_erased", 0),
|
||||
"ok" if p["checksum_ok"] else "BAD",
|
||||
"ok" if p["signature_ok"] else "BAD",
|
||||
p["classes"], note))
|
||||
top = ranking[0]
|
||||
copies = len(groups.get(top["sha256"], []))
|
||||
print("\nverdict: %s is the most likely original (%d copy(ies) in this set)"
|
||||
% (top["name"], copies))
|
||||
print("\nverdict: %s is the best-supported candidate for the original "
|
||||
"(%d copy(ies) in this set)" % (top["name"], copies))
|
||||
print("ranked by: mostly-emptied images evicted first (emptied%% >= 50), then "
|
||||
"the minimal+nop-erased body count low-first, then emptied%%.\n"
|
||||
" Limits, stated rather than hidden: this cannot see a `throw`-stub "
|
||||
"skeleton (it scores at the control's own baseline), and a\n"
|
||||
" partially extracted image still outranks a heavily stubbed one. "
|
||||
"Confirm the winner before using it as a patch baseline --\n"
|
||||
" docs/tool-verification/EXTENSION-extraction-shell-bench.md")
|
||||
else:
|
||||
print("\nverdict: no image parsed as a dex -- nothing to rank")
|
||||
return 0 if valid else 1
|
||||
|
||||
@@ -16,10 +16,24 @@ Feeding the extracted images to `dex_dump_validate.py` is the next step: this
|
||||
script only *finds* and *cuts*; that one judges whether what came out is a real
|
||||
body or an extraction-shell skeleton.
|
||||
|
||||
**Measured hit rate on the reference device: zero for anonymous regions.** The
|
||||
root-dump pass recorded in `docs/tool-verification/EXTENSION-rootdump.md` scanned
|
||||
142 anonymous mappings and returned **no hits**, while the *named* ART dex mappings
|
||||
in the same process yielded 17 dex images without any search at all. A negative
|
||||
result from this script is therefore weak evidence: it is compatible with "the
|
||||
payload is not resident", "the payload is resident but not as a contiguous
|
||||
dex-magic image", and "the payload was paged out between capture and scan". Treat a
|
||||
zero-hit scan as a reason to check the named mappings first
|
||||
(`[anon:dalvik-classes*.dex extracted in memory from <src>]`), not as a conclusion
|
||||
about the packer. A positive hit inside a genuinely unnamed mapping has not been
|
||||
observed here yet.
|
||||
|
||||
Scope note: the header's `file_size` field decides the cut. When the declared size
|
||||
does not fit inside the capture, the hit is reported and skipped rather than
|
||||
guessed at -- a dex whose tail is missing is not recoverable by truncating the
|
||||
wrong end.
|
||||
wrong end. Page-aligned captures from a VMA routinely overrun the image they hold,
|
||||
so a run against raw VMA exports usually needs `--keep-partial` or a trim step
|
||||
before `dex_dump_validate.py` will accept the output.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
|
||||
@@ -0,0 +1,960 @@
|
||||
#!/usr/bin/env python3
|
||||
"""java2c_probe.py - collect the evidence that decides HOW a target was hardened.
|
||||
|
||||
The question this answers is narrow on purpose. Faced with "the Java methods have
|
||||
no bodies", four different mechanisms produce that observation and they need four
|
||||
different routes:
|
||||
|
||||
Java2C Java bytecode translated to C, compiled into a .so. No dex
|
||||
bytecode exists at runtime, ever. Route: read the .so.
|
||||
Extraction shell Real dex bytecode, encrypted at rest, decrypted at runtime.
|
||||
Route: dump memory, then validate the dump.
|
||||
VMP Real dex bytecode replaced by private opcodes fed to an
|
||||
interpreter. Route: measure, then usually stop.
|
||||
JNI sinking A few hot methods deliberately moved to native by hand.
|
||||
Route: reverse the .so; the dex is still readable.
|
||||
|
||||
The expensive mistake is reading the first shape as the second: the analyst goes
|
||||
looking for a decrypted DEX that is never produced, because nothing was ever
|
||||
encrypted. This script prints the discriminating evidence and labels each item
|
||||
strong / medium / weak so the reading is not mistaken for a verdict.
|
||||
|
||||
Usage (paths are always explicit; nothing is discovered implicitly):
|
||||
|
||||
python java2c_probe.py --apk sample.apk
|
||||
python java2c_probe.py --apk sample.apk --json
|
||||
python java2c_probe.py --dex classes.dex --so lib/arm64-v8a/libnc.so
|
||||
python java2c_probe.py --dir extracted/ # every *.dex and *.so below
|
||||
|
||||
Exit codes: 0 = evidence collected, 1 = no usable input, 2 = argument error.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dex parsing (self-contained; deliberately does not import the repo's dexutil)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ACC_NATIVE = 0x0100
|
||||
ACC_ABSTRACT = 0x0400
|
||||
|
||||
# Instruction length in 16-bit code units, for the subset that a stub or a
|
||||
# decompiled-from-C body produces. Unknown opcodes return 1 so a counting error
|
||||
# can never inflate the trivial-body ratio; see the note on that metric below.
|
||||
_INSN_UNITS = {
|
||||
0x00: 1, 0x01: 1, 0x02: 1, 0x03: 1, 0x04: 1, 0x05: 1, 0x06: 1, 0x07: 1,
|
||||
0x08: 1, 0x09: 1, 0x0a: 1, 0x0b: 1, 0x0c: 1, 0x0d: 1, 0x0e: 1, 0x0f: 1,
|
||||
0x10: 1, 0x11: 1, 0x12: 1, 0x13: 2, 0x14: 3, 0x15: 2, 0x16: 2, 0x17: 3,
|
||||
0x18: 5, 0x19: 2, 0x1a: 2, 0x1b: 3, 0x1c: 2, 0x1d: 1, 0x1e: 1, 0x1f: 1,
|
||||
0x20: 1, 0x21: 1, 0x22: 1, 0x23: 1, 0x24: 3, 0x25: 3, 0x26: 3, 0x27: 1,
|
||||
0x28: 1, 0x29: 1, 0x2a: 1, 0x2b: 1, 0x2c: 1, 0x2d: 1, 0x2e: 1, 0x2f: 1,
|
||||
0x30: 1, 0x31: 1, 0x32: 1, 0x33: 1, 0x34: 1, 0x35: 1, 0x36: 1, 0x37: 1,
|
||||
0x38: 1, 0x39: 1, 0x3a: 1, 0x3b: 1, 0x3c: 1, 0x3d: 1, 0x3e: 1, 0x3f: 1,
|
||||
0x40: 1, 0x41: 1, 0x42: 1, 0x43: 1, 0x44: 2, 0x45: 2, 0x46: 2, 0x47: 2,
|
||||
0x48: 2, 0x49: 2, 0x4a: 2, 0x4b: 2, 0x4c: 2, 0x4d: 2, 0x4e: 2, 0x4f: 2,
|
||||
0x50: 2, 0x51: 2, 0x52: 2, 0x53: 2, 0x54: 2, 0x55: 2, 0x56: 2, 0x57: 2,
|
||||
0x58: 2, 0x59: 2, 0x5a: 2, 0x5b: 2, 0x5c: 2, 0x5d: 2, 0x5e: 2, 0x5f: 2,
|
||||
0x60: 2, 0x61: 2, 0x62: 2, 0x63: 2, 0x64: 2, 0x65: 2, 0x66: 2, 0x67: 2,
|
||||
0x68: 2, 0x69: 2, 0x6a: 2, 0x6b: 2, 0x6c: 2, 0x6d: 2, 0x6e: 3, 0x6f: 3,
|
||||
0x70: 3, 0x71: 3, 0x72: 3, 0x73: 1, 0x74: 1, 0x75: 1, 0x76: 1, 0x77: 1,
|
||||
0x78: 2, 0x79: 2, 0x7a: 2, 0x7b: 2, 0x7c: 2, 0x7d: 2, 0x7e: 2, 0x7f: 2,
|
||||
0x80: 2, 0x81: 2, 0x82: 2, 0x83: 2, 0x84: 2, 0x85: 2, 0x86: 2, 0x87: 2,
|
||||
0x88: 2, 0x89: 2, 0x8a: 2, 0x8b: 2, 0x8c: 2, 0x8d: 2, 0x8e: 2, 0x8f: 2,
|
||||
0x90: 2, 0x91: 2, 0x92: 2, 0x93: 2, 0x94: 2, 0x95: 2, 0x96: 2, 0x97: 2,
|
||||
0x98: 2, 0x99: 2, 0x9a: 2, 0x9b: 2, 0x9c: 2, 0x9d: 2, 0x9e: 2, 0x9f: 2,
|
||||
0xa0: 2, 0xa1: 2, 0xa2: 2, 0xa3: 2, 0xa4: 2, 0xa5: 2, 0xa6: 2, 0xa7: 2,
|
||||
0xa8: 2, 0xa9: 2, 0xaa: 2, 0xab: 2, 0xac: 2, 0xad: 2, 0xae: 2, 0xaf: 2,
|
||||
0xb0: 1, 0xb1: 1, 0xb2: 2, 0xb3: 2, 0xb4: 2, 0xb5: 2, 0xb6: 2, 0xb7: 2,
|
||||
0xb8: 2, 0xb9: 2, 0xba: 2, 0xbb: 2, 0xbc: 2, 0xbd: 2, 0xbe: 2, 0xbf: 2,
|
||||
0xc0: 2, 0xc1: 2, 0xc2: 2, 0xc3: 2, 0xc4: 2, 0xc5: 2, 0xc6: 2, 0xc7: 2,
|
||||
0xc8: 2, 0xc9: 2, 0xca: 2, 0xcb: 2, 0xcc: 2, 0xcd: 2, 0xce: 2, 0xcf: 2,
|
||||
0xd0: 2, 0xd1: 2, 0xd2: 2, 0xd3: 2, 0xd4: 2, 0xd5: 2, 0xd6: 2, 0xd7: 2,
|
||||
0xd8: 2, 0xd9: 2, 0xda: 2, 0xdb: 2, 0xdc: 2, 0xdd: 2, 0xde: 2, 0xdf: 2,
|
||||
0xe0: 2, 0xe1: 2, 0xe2: 2, 0xe3: 0, 0xe4: 0, 0xe5: 0, 0xe6: 0, 0xe7: 0,
|
||||
0xe8: 0, 0xe9: 0, 0xea: 0, 0xeb: 0, 0xec: 0, 0xed: 0, 0xee: 0, 0xef: 0,
|
||||
0xf0: 0, 0xf1: 0, 0xf2: 0, 0xf3: 0, 0xf4: 0, 0xf5: 0, 0xf6: 0, 0xf7: 0,
|
||||
0xf8: 0, 0xf9: 0, 0xfa: 0, 0xfb: 0, 0xfc: 0, 0xfd: 0, 0xfe: 0, 0xff: 0,
|
||||
}
|
||||
|
||||
_RETURN_OPS = (0x0e, 0x0f, 0x10, 0x11)
|
||||
|
||||
|
||||
def _uleb128(buf, pos):
|
||||
result = 0
|
||||
shift = 0
|
||||
while True:
|
||||
if pos >= len(buf):
|
||||
raise ValueError("uleb128 truncated")
|
||||
byte = buf[pos]
|
||||
pos += 1
|
||||
result |= (byte & 0x7F) << shift
|
||||
if not (byte & 0x80):
|
||||
return result, pos
|
||||
shift += 7
|
||||
if shift > 35:
|
||||
raise ValueError("uleb128 too long")
|
||||
|
||||
|
||||
class Dex(object):
|
||||
"""Minimal structural reader: header, class_defs, class_data, code_item."""
|
||||
|
||||
def __init__(self, data, name):
|
||||
self.data = data
|
||||
self.name = name
|
||||
self.header_ok = False
|
||||
self.header_note = ""
|
||||
self.methods = [] # dicts: class, name, flags, code_off, units
|
||||
self.class_names = []
|
||||
self.native_methods = 0
|
||||
self.abstract_methods = 0
|
||||
self.body_methods = 0
|
||||
self.no_code_methods = 0 # neither native nor abstract, yet code_off == 0
|
||||
self.trivial_bodies = 0
|
||||
self.native_with_code = 0
|
||||
self.fully_native_classes = 0
|
||||
self.native_dominated_classes = 0
|
||||
self.parse_error = None
|
||||
self._parse()
|
||||
|
||||
def _u16(self, off):
|
||||
return struct.unpack_from("<H", self.data, off)[0]
|
||||
|
||||
def _u32(self, off):
|
||||
return struct.unpack_from("<I", self.data, off)[0]
|
||||
|
||||
def _parse(self):
|
||||
d = self.data
|
||||
if len(d) < 112 or d[:4] != b"dex\n":
|
||||
self.parse_error = "not a dex image (bad magic or too short)"
|
||||
return
|
||||
try:
|
||||
file_size = self._u32(0x20)
|
||||
map_off = self._u32(0x34)
|
||||
string_ids_size = self._u32(0x38)
|
||||
string_ids_off = self._u32(0x3C)
|
||||
type_ids_size = self._u32(0x40)
|
||||
type_ids_off = self._u32(0x44)
|
||||
proto_ids_size = self._u32(0x48)
|
||||
proto_ids_off = self._u32(0x4C)
|
||||
method_ids_size = self._u32(0x58)
|
||||
method_ids_off = self._u32(0x5C)
|
||||
class_defs_size = self._u32(0x60)
|
||||
class_defs_off = self._u32(0x64)
|
||||
except Exception as exc: # pragma: no cover
|
||||
self.parse_error = "header read failed: %s" % exc
|
||||
return
|
||||
|
||||
# Header claims vs. reality. A truncated or padded capture is the normal
|
||||
# way this probe is handed bad input, so say which one happened.
|
||||
if file_size != len(d):
|
||||
self.header_note = "header file_size=%d actual=%d" % (file_size, len(d))
|
||||
else:
|
||||
self.header_ok = True
|
||||
|
||||
if not (0 < string_ids_size < 1 << 22 and string_ids_off + string_ids_size * 4 <= len(d)):
|
||||
self.parse_error = "string_ids table out of bounds"
|
||||
return
|
||||
|
||||
string_offsets = [self._u32(string_ids_off + 4 * i) for i in range(string_ids_size)]
|
||||
|
||||
def string_at(idx):
|
||||
if idx < 0 or idx >= len(string_offsets):
|
||||
return "<bad-string-idx-%d>" % idx
|
||||
off = string_offsets[idx]
|
||||
_, pos = _uleb128(d, off) # utf16 length, ignored
|
||||
end = d.find(b"\x00", pos)
|
||||
if end < 0:
|
||||
return "<unterminated>"
|
||||
return d[pos:end].decode("utf-8", "replace")
|
||||
|
||||
try:
|
||||
type_desc = [string_at(self._u32(type_ids_off + 4 * i))
|
||||
for i in range(type_ids_size)]
|
||||
except Exception as exc:
|
||||
self.parse_error = "type_ids read failed: %s" % exc
|
||||
return
|
||||
|
||||
try:
|
||||
if method_ids_off + method_ids_size * 8 > len(d):
|
||||
raise ValueError("method_ids out of bounds")
|
||||
method_info = []
|
||||
for i in range(method_ids_size):
|
||||
base = method_ids_off + 8 * i
|
||||
class_idx = self._u16(base)
|
||||
name_idx = self._u32(base + 4)
|
||||
desc = type_desc[class_idx] if class_idx < len(type_desc) else "?"
|
||||
method_info.append((desc, string_at(name_idx)))
|
||||
except Exception as exc:
|
||||
self.parse_error = "method_ids read failed: %s" % exc
|
||||
return
|
||||
|
||||
if class_defs_off + class_defs_size * 32 > len(d):
|
||||
self.parse_error = "class_defs table out of bounds"
|
||||
return
|
||||
|
||||
per_class_native = {}
|
||||
per_class_total = {}
|
||||
|
||||
for ci in range(class_defs_size):
|
||||
base = class_defs_off + 32 * ci
|
||||
class_idx = self._u32(base)
|
||||
class_data_off = self._u32(base + 24)
|
||||
cname = type_desc[class_idx] if class_idx < len(type_desc) else "?%d" % class_idx
|
||||
self.class_names.append(cname)
|
||||
|
||||
if class_data_off == 0:
|
||||
continue
|
||||
try:
|
||||
pos = class_data_off
|
||||
static_fields, pos = _uleb128(d, pos)
|
||||
instance_fields, pos = _uleb128(d, pos)
|
||||
direct_methods, pos = _uleb128(d, pos)
|
||||
virtual_methods, pos = _uleb128(d, pos)
|
||||
|
||||
for _ in range(static_fields + instance_fields):
|
||||
_, pos = _uleb128(d, pos) # field_idx_diff
|
||||
_, pos = _uleb128(d, pos) # access_flags
|
||||
|
||||
# direct_methods and virtual_methods are two independent encoded
|
||||
# lists: each one's first method_idx_diff is relative to 0. Walking
|
||||
# them as a single run silently mis-attributes every virtual
|
||||
# method, so they are decoded as two passes.
|
||||
for list_size in (direct_methods, virtual_methods):
|
||||
method_idx = 0
|
||||
for _ in range(list_size):
|
||||
diff, pos = _uleb128(d, pos)
|
||||
flags, pos = _uleb128(d, pos)
|
||||
code_off, pos = _uleb128(d, pos)
|
||||
method_idx += diff
|
||||
|
||||
if method_idx < len(method_info):
|
||||
mcls, mname = method_info[method_idx]
|
||||
else:
|
||||
mcls, mname = "?", "?%d" % method_idx
|
||||
|
||||
units = None
|
||||
if code_off:
|
||||
units = self._u16(code_off + 12) # insns_size
|
||||
|
||||
self.methods.append({
|
||||
"class": mcls, "name": mname, "flags": flags,
|
||||
"code_off": code_off, "units": units,
|
||||
})
|
||||
# Constructors are excluded from the per-class shape: a
|
||||
# Java2C pass leaves <init>/<clinit> in Java in practice,
|
||||
# and counting them would make "all methods native" an
|
||||
# essentially unreachable test.
|
||||
named = mname not in ("<init>", "<clinit>")
|
||||
if named:
|
||||
per_class_total[cname] = per_class_total.get(cname, 0) + 1
|
||||
|
||||
if flags & ACC_NATIVE:
|
||||
self.native_methods += 1
|
||||
if named:
|
||||
per_class_native[cname] = per_class_native.get(cname, 0) + 1
|
||||
if code_off:
|
||||
self.native_with_code += 1
|
||||
elif flags & ACC_ABSTRACT:
|
||||
self.abstract_methods += 1
|
||||
elif code_off == 0:
|
||||
self.no_code_methods += 1
|
||||
else:
|
||||
self.body_methods += 1
|
||||
if self._is_trivial(code_off, units):
|
||||
self.trivial_bodies += 1
|
||||
except Exception:
|
||||
# One unreadable class_data must not void the whole measurement.
|
||||
continue
|
||||
|
||||
for cname, total in per_class_total.items():
|
||||
if total < 3:
|
||||
continue
|
||||
native_here = per_class_native.get(cname, 0)
|
||||
if native_here == total:
|
||||
self.fully_native_classes += 1
|
||||
if native_here >= 0.7 * total:
|
||||
self.native_dominated_classes += 1
|
||||
|
||||
def _is_trivial(self, code_off, units):
|
||||
"""A body that only returns a constant: the classic stub shape.
|
||||
|
||||
Approximation, and labelled as such: it walks the instruction stream with
|
||||
the length table above and accepts only nop/move/const/return. Anything
|
||||
that touches a field, calls out, or branches is not a stub.
|
||||
"""
|
||||
if not units:
|
||||
return False
|
||||
insns_off = code_off + 16
|
||||
if insns_off + units * 2 > len(self.data):
|
||||
return False
|
||||
try:
|
||||
pos = 0
|
||||
seen = 0
|
||||
while pos < units and seen < 8:
|
||||
word = struct.unpack_from("<H", self.data, insns_off + pos * 2)[0]
|
||||
op = word & 0xFF
|
||||
if op in _RETURN_OPS:
|
||||
return True if seen <= 2 else False
|
||||
if op == 0x00 or 0x01 <= op <= 0x09 or 0x12 <= op <= 0x19:
|
||||
step = _INSN_UNITS.get(op, 1)
|
||||
if step == 0:
|
||||
return False
|
||||
pos += step
|
||||
seen += 1
|
||||
continue
|
||||
return False
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -- derived metrics ---------------------------------------------------
|
||||
@property
|
||||
def total_methods(self):
|
||||
return len(self.methods)
|
||||
|
||||
@property
|
||||
def native_ratio(self):
|
||||
return (float(self.native_methods) / self.total_methods) if self.total_methods else 0.0
|
||||
|
||||
@property
|
||||
def trivial_ratio(self):
|
||||
return (float(self.trivial_bodies) / self.body_methods) if self.body_methods else 0.0
|
||||
|
||||
def summary(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"size": len(self.data),
|
||||
"header_size_matches": self.header_ok,
|
||||
"header_note": self.header_note,
|
||||
"classes": len(self.class_names),
|
||||
"total_methods": self.total_methods,
|
||||
"native_methods": self.native_methods,
|
||||
"native_ratio": round(self.native_ratio, 4),
|
||||
"abstract_methods": self.abstract_methods,
|
||||
"body_methods": self.body_methods,
|
||||
"no_code_methods": self.no_code_methods,
|
||||
"native_with_code": self.native_with_code,
|
||||
"trivial_bodies": self.trivial_bodies,
|
||||
"trivial_ratio": round(self.trivial_ratio, 4),
|
||||
"fully_native_classes": self.fully_native_classes,
|
||||
"native_dominated_classes": self.native_dominated_classes,
|
||||
"bytes_per_class": int(len(self.data) / len(self.class_names)) if self.class_names else 0,
|
||||
"parse_error": self.parse_error,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ELF parsing (self-contained; no lief / pyelftools dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each entry: (regex, label, strength, meaning). Strength is what stops a weak
|
||||
# hit from being read as a verdict - see the printed weak-criteria block.
|
||||
_STRINGSIGS = [
|
||||
(rb"Dex2C", "Dex2C", "strong", "dcc toolchain marker in code/rodata"),
|
||||
(rb"dynamic_register_compile_methods", "dcc-register-fn", "strong",
|
||||
"dcc's generated RegisterNatives entry point"),
|
||||
(rb"dcc", "dcc", "weak", "3 letters: matches unrelated words (e.g. 'addcc')"),
|
||||
(rb"JNI_OnLoad", "JNI_OnLoad", "weak", "present in almost every JNI library"),
|
||||
(rb"RegisterNatives", "RegisterNatives", "weak",
|
||||
"dynamic registration; ordinary JNI libraries use it too"),
|
||||
(rb"libc\+\+_shared\.so", "libc++_shared", "weak", "NDK C++ runtime, not a hardening marker"),
|
||||
(rb"c\+\+_static", "c++_static", "weak", "dcc's default APP_STL; also a common NDK setting"),
|
||||
(rb"FindClass", "FindClass", "weak", "any JNI callback path"),
|
||||
(rb"GetMethodID", "GetMethodID", "weak", "any JNI callback path"),
|
||||
(rb"GetStaticMethodID", "GetStaticMethodID", "weak", "any JNI callback path"),
|
||||
(rb"CallObjectMethod", "CallObjectMethod", "weak", "any JNI callback path"),
|
||||
(rb"CallStaticObjectMethod", "CallStaticObjectMethod", "weak", "any JNI callback path"),
|
||||
(rb"NewLocalRef", "NewLocalRef", "weak", "generated code that manages refs by hand"),
|
||||
(rb"ScopedLocalRef", "ScopedLocalRef", "medium", "dcc runtime header name"),
|
||||
(rb"well_known_classes", "well_known_classes", "medium", "dcc runtime header name"),
|
||||
]
|
||||
|
||||
_JNI_CALLS = ("FindClass", "GetMethodID", "GetStaticMethodID", "CallObjectMethod",
|
||||
"CallStaticObjectMethod", "CallIntMethod", "CallVoidMethod",
|
||||
"CallStaticIntMethod", "NewObject", "GetFieldID")
|
||||
|
||||
|
||||
class Elf(object):
|
||||
"""Enough of ELF to answer: what is exported, what is imported, what is inside."""
|
||||
|
||||
def __init__(self, data, name):
|
||||
self.data = data
|
||||
self.name = name
|
||||
self.arch = "unknown"
|
||||
self.is_elf = False
|
||||
self.dynsym = [] # (name, shndx)
|
||||
self.sig_hits = {} # label -> (count, strength, meaning)
|
||||
self.java_symbols = []
|
||||
self.has_jni_onload = False
|
||||
self.imports_register_natives = False
|
||||
self.jni_call_refs = []
|
||||
self.elf_note = ""
|
||||
self._parse()
|
||||
|
||||
def _parse(self):
|
||||
d = self.data
|
||||
if len(d) < 64 or d[:4] != b"\x7fELF":
|
||||
self.elf_note = "not an ELF image"
|
||||
return
|
||||
self.is_elf = True
|
||||
ei_class = d[4]
|
||||
ei_data = d[5]
|
||||
if ei_class not in (1, 2):
|
||||
self.elf_note = "bad EI_CLASS=%d" % ei_class
|
||||
return
|
||||
endian = "<" if ei_data == 1 else ">"
|
||||
is64 = ei_class == 2
|
||||
try:
|
||||
e_type = struct.unpack_from(endian + "H", d, 16)[0]
|
||||
e_machine = struct.unpack_from(endian + "H", d, 18)[0]
|
||||
if is64:
|
||||
e_shoff = struct.unpack_from(endian + "Q", d, 0x28)[0]
|
||||
e_shentsize = struct.unpack_from(endian + "H", d, 0x3A)[0]
|
||||
e_shnum = struct.unpack_from(endian + "H", d, 0x3C)[0]
|
||||
e_shstrndx = struct.unpack_from(endian + "H", d, 0x3E)[0]
|
||||
else:
|
||||
e_shoff = struct.unpack_from(endian + "I", d, 0x20)[0]
|
||||
e_shentsize = struct.unpack_from(endian + "H", d, 0x2E)[0]
|
||||
e_shnum = struct.unpack_from(endian + "H", d, 0x30)[0]
|
||||
e_shstrndx = struct.unpack_from(endian + "H", d, 0x32)[0]
|
||||
except struct.error as exc:
|
||||
self.elf_note = "header read failed: %s" % exc
|
||||
return
|
||||
|
||||
self.arch = {
|
||||
3: "x86", 8: "mips", 20: "ppc", 40: "arm", 62: "x86_64",
|
||||
183: "aarch64", 243: "riscv",
|
||||
}.get(e_machine, "machine-%d" % e_machine)
|
||||
if e_type not in (2, 3):
|
||||
self.elf_note = "e_type=%d (not EXEC/DYN)" % e_type
|
||||
|
||||
# Strings first: it is the check that works even when the section table
|
||||
# has been stripped, which is the common case for a hardened library.
|
||||
self._scan_strings()
|
||||
|
||||
if e_shoff and e_shnum and e_shoff + e_shnum * e_shentsize <= len(d):
|
||||
self._read_sections(endian, is64, e_shoff, e_shentsize, e_shnum, e_shstrndx)
|
||||
else:
|
||||
self.elf_note = (self.elf_note + "; " if self.elf_note else "") + \
|
||||
"no usable section table (stripped?) - symbol checks skipped"
|
||||
|
||||
def _scan_strings(self):
|
||||
d = self.data
|
||||
for pattern, label, strength, meaning in _STRINGSIGS:
|
||||
count = len(re.findall(pattern, d))
|
||||
if count:
|
||||
self.sig_hits[label] = (count, strength, meaning)
|
||||
for call in _JNI_CALLS:
|
||||
if re.search(re.escape(call).encode(), d):
|
||||
self.jni_call_refs.append(call)
|
||||
|
||||
def _read_sections(self, endian, is64, shoff, shentsize, shnum, shstrndx):
|
||||
d = self.data
|
||||
sections = []
|
||||
for i in range(shnum):
|
||||
base = shoff + i * shentsize
|
||||
try:
|
||||
if is64:
|
||||
name_off = struct.unpack_from(endian + "I", d, base)[0]
|
||||
sh_type = struct.unpack_from(endian + "I", d, base + 4)[0]
|
||||
sh_offset = struct.unpack_from(endian + "Q", d, base + 0x18)[0]
|
||||
sh_size = struct.unpack_from(endian + "Q", d, base + 0x20)[0]
|
||||
sh_link = struct.unpack_from(endian + "I", d, base + 0x28)[0]
|
||||
sh_entsize = struct.unpack_from(endian + "Q", d, base + 0x38)[0]
|
||||
else:
|
||||
name_off = struct.unpack_from(endian + "I", d, base)[0]
|
||||
sh_type = struct.unpack_from(endian + "I", d, base + 4)[0]
|
||||
sh_offset = struct.unpack_from(endian + "I", d, base + 0x10)[0]
|
||||
sh_size = struct.unpack_from(endian + "I", d, base + 0x14)[0]
|
||||
sh_link = struct.unpack_from(endian + "I", d, base + 0x18)[0]
|
||||
sh_entsize = struct.unpack_from(endian + "I", d, base + 0x24)[0]
|
||||
except struct.error:
|
||||
return
|
||||
sections.append((name_off, sh_type, sh_offset, sh_size, sh_link, sh_entsize))
|
||||
|
||||
if shstrndx >= len(sections):
|
||||
return
|
||||
_, _, str_off, str_size, _, _ = sections[shstrndx]
|
||||
if str_off + str_size > len(d):
|
||||
return
|
||||
shstr = d[str_off:str_off + str_size]
|
||||
|
||||
def sec_name(off):
|
||||
end = shstr.find(b"\x00", off)
|
||||
return shstr[off:end if end >= 0 else len(shstr)].decode("utf-8", "replace")
|
||||
|
||||
for name_off, sh_type, sh_offset, sh_size, sh_link, sh_entsize in sections:
|
||||
# SHT_DYNSYM == 11, SHT_SYMTAB == 2
|
||||
if sh_type not in (2, 11):
|
||||
continue
|
||||
if sh_link >= len(sections):
|
||||
continue
|
||||
_, _, dstr_off, dstr_size, _, _ = sections[sh_link]
|
||||
if dstr_off + dstr_size > len(d) or sh_offset + sh_size > len(d):
|
||||
continue
|
||||
dstr = d[dstr_off:dstr_off + dstr_size]
|
||||
entsize = sh_entsize or (24 if is64 else 16)
|
||||
if entsize == 0:
|
||||
continue
|
||||
for off in range(sh_offset, sh_offset + sh_size, entsize):
|
||||
try:
|
||||
if is64:
|
||||
st_name = struct.unpack_from(endian + "I", d, off)[0]
|
||||
st_shndx = struct.unpack_from(endian + "H", d, off + 6)[0]
|
||||
else:
|
||||
st_name = struct.unpack_from(endian + "I", d, off)[0]
|
||||
st_shndx = struct.unpack_from(endian + "H", d, off + 14)[0]
|
||||
except struct.error:
|
||||
break
|
||||
if st_name >= len(dstr):
|
||||
continue
|
||||
end = dstr.find(b"\x00", st_name)
|
||||
sname = dstr[st_name:end if end >= 0 else len(dstr)].decode("utf-8", "replace")
|
||||
if not sname:
|
||||
continue
|
||||
self.dynsym.append((sname, st_shndx))
|
||||
if sname.startswith("Java_"):
|
||||
self.java_symbols.append(sname)
|
||||
if sname == "JNI_OnLoad":
|
||||
self.has_jni_onload = True
|
||||
# Substring match, so a C implementation (bare name) and a C++
|
||||
# one (mangled form) are both caught. In practice NEITHER is
|
||||
# present: see the note on dynamic-registration in classify().
|
||||
if "RegisterNatives" in sname and st_shndx == 0:
|
||||
self.imports_register_natives = True
|
||||
|
||||
def summary(self):
|
||||
return {
|
||||
"name": self.name,
|
||||
"size": len(self.data),
|
||||
"is_elf": self.is_elf,
|
||||
"arch": self.arch,
|
||||
"elf_note": self.elf_note,
|
||||
"dynsym_entries": len(self.dynsym),
|
||||
"java_symbols": len(self.java_symbols),
|
||||
"java_symbol_sample": self.java_symbols[:5],
|
||||
"exports_JNI_OnLoad": self.has_jni_onload,
|
||||
"imports_RegisterNatives": self.imports_register_natives,
|
||||
"jni_call_refs": sorted(self.jni_call_refs),
|
||||
"signature_hits": {k: {"count": v[0], "strength": v[1], "meaning": v[2]}
|
||||
for k, v in sorted(self.sig_hits.items())},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verdict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def classify(dexes, elves, so_bytes_total):
|
||||
"""Turn measurements into a type guess plus the reasoning behind it.
|
||||
|
||||
Every rule below is a shape test, not a proof, and the confidence is reported
|
||||
so a low-confidence Java2C reading is not mistaken for a confirmed one.
|
||||
"""
|
||||
evidence = []
|
||||
verdict = "no-hardening-signal"
|
||||
confidence = "low"
|
||||
|
||||
dex_metrics = [d for d in dexes if not d.parse_error]
|
||||
total_methods = sum(d.total_methods for d in dex_metrics)
|
||||
total_native = sum(d.native_methods for d in dex_metrics)
|
||||
native_ratio = (float(total_native) / total_methods) if total_methods else 0.0
|
||||
fully_native = sum(d.fully_native_classes for d in dex_metrics)
|
||||
native_dominated = sum(d.native_dominated_classes for d in dex_metrics)
|
||||
no_code = sum(d.no_code_methods for d in dex_metrics)
|
||||
trivial_bodies = sum(d.trivial_bodies for d in dex_metrics)
|
||||
body_methods = sum(d.body_methods for d in dex_metrics)
|
||||
trivial_ratio = (float(trivial_bodies) / body_methods) if body_methods else 0.0
|
||||
|
||||
java_syms = sum(len(e.java_symbols) for e in elves)
|
||||
register_natives_ref = any(e.imports_register_natives for e in elves)
|
||||
has_jni_onload = any(e.has_jni_onload for e in elves)
|
||||
# The reliable static signature of dynamic registration is the ABSENCE of
|
||||
# Java_* symbols from a library that does export a JNI entry point. Looking
|
||||
# for a literal RegisterNatives symbol does not work: NDK's C++ jni.h
|
||||
# implements it as an inline member that calls through the function table,
|
||||
# so no such symbol is emitted (measured: a real library exports JNI_OnLoad
|
||||
# with zero Java_* and zero RegisterNatives symbols).
|
||||
# Judged per library, not across the set: one library registering
|
||||
# dynamically says nothing about another that exports static symbols.
|
||||
dynamic_register_shape = any(e.has_jni_onload and not e.java_symbols for e in elves)
|
||||
tool_markers = []
|
||||
for e in elves:
|
||||
for label, (count, strength, meaning) in e.sig_hits.items():
|
||||
if strength == "strong":
|
||||
tool_markers.append("%s(x%d) in %s" % (label, count, os.path.basename(e.name)))
|
||||
medium_markers = []
|
||||
for e in elves:
|
||||
for label, (count, strength, meaning) in e.sig_hits.items():
|
||||
if strength == "medium":
|
||||
medium_markers.append("%s in %s" % (label, os.path.basename(e.name)))
|
||||
|
||||
# --- dex-layer shape -------------------------------------------------
|
||||
java2c_dex_shape = (native_dominated >= 2) or (native_ratio >= 0.35 and total_native >= 10)
|
||||
jni_sink_dex_shape = 0 < total_native <= 20 and native_ratio < 0.15
|
||||
stub_shape = no_code > 0 or (body_methods >= 8 and trivial_ratio >= 0.5)
|
||||
|
||||
if dex_metrics:
|
||||
evidence.append({
|
||||
"claim": "dex layer: native declaration density %.2f%% (%d/%d)"
|
||||
% (native_ratio * 100, total_native, total_methods),
|
||||
"strength": "medium" if java2c_dex_shape else "strong",
|
||||
"supports": "java2c" if java2c_dex_shape else "baseline",
|
||||
"note": "High density with whole classes turned native is the Java2C shape. "
|
||||
"It is NOT sufficient on its own: a hand-written JNI class looks "
|
||||
"identical in dex, so the .so layer decides.",
|
||||
})
|
||||
evidence.append({
|
||||
"claim": "dex layer: %d classes are native-dominated (>=70%% native of >=3 "
|
||||
"named methods), %d fully native" % (native_dominated, fully_native),
|
||||
"strength": "medium",
|
||||
"supports": "java2c" if native_dominated >= 2 else "baseline",
|
||||
"note": "A hand-written JNI boundary class normally keeps its Java half, so "
|
||||
"whole classes turned native are the Java2C shape. Constructors are "
|
||||
"excluded: they stay in Java even after a translation pass.",
|
||||
})
|
||||
if no_code:
|
||||
evidence.append({
|
||||
"claim": "dex layer: %d non-native, non-abstract methods have no code_item"
|
||||
% no_code,
|
||||
"strength": "strong",
|
||||
"supports": "extraction-shell/vmp",
|
||||
"note": "Structurally invalid for plain Java; expect a loader that "
|
||||
"fills bodies at runtime, or a private-opcode converter.",
|
||||
})
|
||||
if trivial_ratio:
|
||||
evidence.append({
|
||||
"claim": "dex layer: %.0f%% of method bodies are return-a-constant stubs "
|
||||
"(%d/%d)" % (trivial_ratio * 100, trivial_bodies, body_methods),
|
||||
"strength": "medium",
|
||||
"supports": "extraction-shell/vmp" if stub_shape else "baseline",
|
||||
"note": "Approximate metric (see --help). Empty bodies are the signature "
|
||||
"of an extraction shell or a VMP, not of Java2C.",
|
||||
})
|
||||
|
||||
# --- native-layer shape ----------------------------------------------
|
||||
if elves:
|
||||
evidence.append({
|
||||
"claim": "native layer: %d exported Java_* symbols across %d library(ies)"
|
||||
% (java_syms, len(elves)),
|
||||
"strength": "strong" if java_syms else "medium",
|
||||
"supports": "java2c" if java_syms else "none",
|
||||
"note": "Static-linkage form. Compare the count against the dex native "
|
||||
"method count: a rough 1:1 map is the Java2C signature.",
|
||||
})
|
||||
if dynamic_register_shape:
|
||||
dyn_libs = sorted(os.path.basename(e.name)
|
||||
for e in elves if e.has_jni_onload and not e.java_symbols)
|
||||
evidence.append({
|
||||
"claim": "native layer: dynamic registration in %s "
|
||||
"(JNI_OnLoad exported, zero Java_* symbols)"
|
||||
% ", ".join(dyn_libs),
|
||||
"strength": "strong",
|
||||
"supports": "dynamic-registration",
|
||||
"note": "Binding happens at runtime, so a symbol search comes back empty "
|
||||
"and fails SILENTLY. Do not expect a RegisterNatives symbol "
|
||||
"either: NDK's C++ jni.h makes it an inline member that calls "
|
||||
"through the function table, so none is emitted.",
|
||||
})
|
||||
if register_natives_ref:
|
||||
evidence.append({
|
||||
"claim": "native layer: a RegisterNatives symbol is imported",
|
||||
"strength": "weak",
|
||||
"supports": "dynamic-registration",
|
||||
"note": "Only a C-side implementation leaves this name; its absence "
|
||||
"proves nothing.",
|
||||
})
|
||||
if has_jni_onload:
|
||||
evidence.append({
|
||||
"claim": "native layer: JNI_OnLoad exported",
|
||||
"strength": "weak",
|
||||
"supports": "jni-boundary-exists",
|
||||
"note": "Present in nearly every JNI library. Holds no information "
|
||||
"about which hardening was applied.",
|
||||
})
|
||||
if tool_markers:
|
||||
evidence.append({
|
||||
"claim": "native layer: toolchain markers found: %s" % ", ".join(tool_markers),
|
||||
"strength": "strong",
|
||||
"supports": "java2c",
|
||||
"note": "A Dex-to-C toolchain leaves its own runtime symbols behind.",
|
||||
})
|
||||
if medium_markers:
|
||||
evidence.append({
|
||||
"claim": "native layer: runtime markers found: %s" % ", ".join(medium_markers),
|
||||
"strength": "medium",
|
||||
"supports": "java2c",
|
||||
"note": "dcc runtime header names; suggestive, and easy to rename.",
|
||||
})
|
||||
|
||||
# --- verdict ----------------------------------------------------------
|
||||
if tool_markers and (java2c_dex_shape or java_syms):
|
||||
verdict, confidence = "java2c", "high"
|
||||
elif java2c_dex_shape and java_syms and total_native and \
|
||||
(float(java_syms) / total_native) >= 0.5:
|
||||
verdict, confidence = "java2c", "high"
|
||||
elif java2c_dex_shape and medium_markers:
|
||||
verdict, confidence = "java2c", "medium"
|
||||
elif java2c_dex_shape and dynamic_register_shape:
|
||||
# A Dex-to-C toolchain that hides its symbols (dcc's Application.mk ships
|
||||
# -fvisibility=hidden) leaves exactly this: a JNI entry point and a
|
||||
# registration table, with no Java_* name to search for.
|
||||
verdict, confidence = "java2c", "medium"
|
||||
elif stub_shape:
|
||||
verdict, confidence = "extraction-shell-or-vmp", "medium"
|
||||
elif java2c_dex_shape:
|
||||
verdict, confidence = "java2c-suspect-needs-so", "low"
|
||||
elif jni_sink_dex_shape:
|
||||
verdict, confidence = "jni-sinking", "medium"
|
||||
elif dex_metrics or elves:
|
||||
verdict, confidence = "no-hardening-signal", "medium"
|
||||
|
||||
routes = {
|
||||
"java2c": [
|
||||
"Do NOT look for a decrypted DEX: none exists, at any point, in memory.",
|
||||
"Read the C in the .so: each translated method is one function with a "
|
||||
"JNIEnv*/jobject prefix.",
|
||||
"Rebuild the call graph through the JNI reverse calls "
|
||||
"(FindClass/GetMethodID/CallXxxMethod).",
|
||||
],
|
||||
"java2c-suspect-needs-so": [
|
||||
"Supply the matching .so (--so) before acting: the dex shape alone "
|
||||
"cannot separate Java2C from a hand-written JNI class.",
|
||||
"If no .so carries the logic, re-read the dex shape as ordinary Java.",
|
||||
],
|
||||
"extraction-shell-or-vmp": [
|
||||
"Dump memory and measure the dump with scripts/dex_dump_validate.py.",
|
||||
"If the dump is mostly stubs, the bodies are filled at invocation time: "
|
||||
"references/advanced-unpacking.md.",
|
||||
"If bodies decode as private opcodes, that is a VMP - recovery cost "
|
||||
"usually exceeds the task value.",
|
||||
],
|
||||
"jni-sinking": [
|
||||
"The dex is still readable: locate the Java call site, then reverse "
|
||||
"the one or few native functions.",
|
||||
"references/native-and-so.md, then the algorithm tooling.",
|
||||
],
|
||||
"dynamic-registration": [
|
||||
"Hook or find RegisterNatives to read the binding table at runtime.",
|
||||
],
|
||||
"no-hardening-signal": [
|
||||
"Proceed with ordinary dex-level analysis.",
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"confidence": confidence,
|
||||
"metrics": {
|
||||
"dex_total_methods": total_methods,
|
||||
"dex_native_methods": total_native,
|
||||
"dex_native_ratio": round(native_ratio, 4),
|
||||
"dex_fully_native_classes": fully_native,
|
||||
"dex_native_dominated_classes": native_dominated,
|
||||
"dex_no_code_methods": no_code,
|
||||
"dex_trivial_ratio": round(trivial_ratio, 4),
|
||||
"so_java_symbols": java_syms,
|
||||
"so_dynamic_registration": dynamic_register_shape,
|
||||
"so_bytes_total": so_bytes_total,
|
||||
},
|
||||
"evidence": evidence,
|
||||
"route": routes.get(verdict, []),
|
||||
"weak_criteria_warning": [
|
||||
"JNI_OnLoad / RegisterNatives / libc++_shared.so / FindClass appear in "
|
||||
"ordinary JNI libraries: a hit is not a hardening verdict.",
|
||||
"A high native ratio is also produced by hand-written JNI code and by R8 "
|
||||
"stripping Java bodies; only the .so layer separates these.",
|
||||
"The trivial-body ratio is approximate and flags extraction shells and "
|
||||
"VMPs, never Java2C.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# input collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _looks_like_dex(data):
|
||||
return data[:4] == b"dex\n"
|
||||
|
||||
|
||||
def _looks_like_elf(data):
|
||||
return data[:4] == b"\x7fELF"
|
||||
|
||||
|
||||
def collect(args):
|
||||
dex_blobs = []
|
||||
elf_blobs = []
|
||||
sources = []
|
||||
|
||||
def add_file(path):
|
||||
try:
|
||||
with open(path, "rb") as fp:
|
||||
data = fp.read()
|
||||
except OSError as exc:
|
||||
print("warning: cannot read %s (%s)" % (path, exc), file=sys.stderr)
|
||||
return
|
||||
if _looks_like_dex(data):
|
||||
dex_blobs.append((os.path.basename(path), data))
|
||||
sources.append(path)
|
||||
elif _looks_like_elf(data):
|
||||
elf_blobs.append((os.path.basename(path), data))
|
||||
sources.append(path)
|
||||
else:
|
||||
print("warning: %s is neither a dex nor an ELF image, skipped" % path,
|
||||
file=sys.stderr)
|
||||
|
||||
for path in args.dex or []:
|
||||
add_file(path)
|
||||
for path in args.so or []:
|
||||
add_file(path)
|
||||
|
||||
apks = list(args.apk or [])
|
||||
for directory in (args.dir or []):
|
||||
for root, _dirs, files in os.walk(directory):
|
||||
for fn in files:
|
||||
full = os.path.join(root, fn)
|
||||
low = fn.lower()
|
||||
if low.endswith(".apk"):
|
||||
apks.append(full)
|
||||
elif low.endswith((".dex", ".so")):
|
||||
add_file(full)
|
||||
|
||||
for apk in apks:
|
||||
if not os.path.exists(apk):
|
||||
print("warning: apk not found: %s" % apk, file=sys.stderr)
|
||||
continue
|
||||
sources.append(apk)
|
||||
try:
|
||||
with zipfile.ZipFile(apk) as zf:
|
||||
for info in zf.infolist():
|
||||
low = info.filename.lower()
|
||||
if not (low.endswith(".dex") or low.endswith(".so")):
|
||||
continue
|
||||
if info.file_size > 256 * 1024 * 1024:
|
||||
print("warning: skipping oversized entry %s (%d B)"
|
||||
% (info.filename, info.file_size), file=sys.stderr)
|
||||
continue
|
||||
data = zf.read(info)
|
||||
label = os.path.basename(info.filename)
|
||||
if _looks_like_dex(data):
|
||||
dex_blobs.append((label, data))
|
||||
elif _looks_like_elf(data):
|
||||
elf_blobs.append((label, data))
|
||||
except zipfile.BadZipFile:
|
||||
print("warning: %s is not a readable zip/apk" % apk, file=sys.stderr)
|
||||
|
||||
return dex_blobs, elf_blobs, sources
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Collect the evidence that separates Java2C from an extraction "
|
||||
"shell, a VMP and ordinary JNI sinking.",
|
||||
epilog="Weak criteria (hits that do NOT establish a verdict on their own): "
|
||||
"JNI_OnLoad, RegisterNatives, libc++_shared.so, FindClass/GetMethodID, "
|
||||
"and a bare 'dcc' substring. The trivial-body ratio is advisory: it "
|
||||
"flags extraction shells and VMPs, never Java2C.",
|
||||
)
|
||||
parser.add_argument("--apk", action="append", help="APK to inspect (repeatable)")
|
||||
parser.add_argument("--dex", action="append", help="dex image (repeatable)")
|
||||
parser.add_argument("--so", action="append", help="ELF library (repeatable)")
|
||||
parser.add_argument("--dir", action="append",
|
||||
help="directory scanned recursively for *.apk/*.dex/*.so")
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not any([args.apk, args.dex, args.so, args.dir]):
|
||||
parser.error("at least one of --apk/--dex/--so/--dir is required")
|
||||
|
||||
dex_blobs, elf_blobs, sources = collect(args)
|
||||
if not dex_blobs and not elf_blobs:
|
||||
print("error: no usable dex or ELF input found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
dexes = [Dex(data, name) for name, data in dex_blobs]
|
||||
elves = [Elf(data, name) for name, data in elf_blobs]
|
||||
result = classify(dexes, elves, sum(len(d) for _n, d in elf_blobs))
|
||||
|
||||
report = {
|
||||
"inputs": sources,
|
||||
"dex": [d.summary() for d in dexes],
|
||||
"native": [e.summary() for e in elves],
|
||||
"classification": result,
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, sort_keys=False))
|
||||
return 0
|
||||
|
||||
print("=" * 72)
|
||||
print("java2c_probe: hardening classification evidence")
|
||||
print("=" * 72)
|
||||
print("inputs: %d file(s)" % len(sources))
|
||||
for src in sources:
|
||||
print(" - %s" % src)
|
||||
|
||||
print("\n[dex layer]")
|
||||
if not dexes:
|
||||
print(" (no dex supplied: the dex-layer metrics are unmeasured, so a")
|
||||
print(" Java2C reading cannot be supported or excluded from this input)")
|
||||
for d in dexes:
|
||||
s = d.summary()
|
||||
if s["parse_error"]:
|
||||
print(" %s: PARSE ERROR: %s" % (s["name"], s["parse_error"]))
|
||||
continue
|
||||
print(" %s: %d B, %d classes, %d methods, native %d (%.2f%%), "
|
||||
"native-dominated classes %d, fully native %d"
|
||||
% (s["name"], s["size"], s["classes"], s["total_methods"],
|
||||
s["native_methods"], s["native_ratio"] * 100,
|
||||
s["native_dominated_classes"], s["fully_native_classes"]))
|
||||
print(" no-code methods %d, trivial bodies %d/%d (%.0f%%), header %s"
|
||||
% (s["no_code_methods"], s["trivial_bodies"], s["body_methods"],
|
||||
s["trivial_ratio"] * 100,
|
||||
"OK" if s["header_size_matches"] else "MISMATCH: " + s["header_note"]))
|
||||
|
||||
print("\n[native layer]")
|
||||
if not elves:
|
||||
print(" (no .so supplied)")
|
||||
for e in elves:
|
||||
s = e.summary()
|
||||
if not s["is_elf"]:
|
||||
print(" %s: not ELF (%s)" % (s["name"], s["elf_note"]))
|
||||
continue
|
||||
print(" %s: %d B, %s, dynsym %d entries, Java_* %d, JNI_OnLoad %s, "
|
||||
"RegisterNatives-import %s"
|
||||
% (s["name"], s["size"], s["arch"], s["dynsym_entries"],
|
||||
s["java_symbols"], s["exports_JNI_OnLoad"],
|
||||
s["imports_RegisterNatives"]))
|
||||
if s["elf_note"]:
|
||||
print(" note: %s" % s["elf_note"])
|
||||
if s["signature_hits"]:
|
||||
for label, info in s["signature_hits"].items():
|
||||
print(" [%s] %s x%d - %s"
|
||||
% (info["strength"], label, info["count"], info["meaning"]))
|
||||
|
||||
print("\n[classification]")
|
||||
print(" verdict : %s" % result["verdict"])
|
||||
print(" confidence : %s" % result["confidence"])
|
||||
print("\n evidence:")
|
||||
for item in result["evidence"]:
|
||||
print(" (%s) %s" % (item["strength"], item["claim"]))
|
||||
print(" note: %s" % item["note"])
|
||||
print("\n route:")
|
||||
for line in result["route"]:
|
||||
print(" - %s" % line)
|
||||
print("\n weak criteria - a hit here is NOT a verdict:")
|
||||
for line in result["weak_criteria_warning"]:
|
||||
print(" ! %s" % line)
|
||||
print("=" * 72)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,937 @@
|
||||
#!/usr/bin/env python3
|
||||
"""kernelsu_syscall_mask.py -- generate a configurable kernel-side syscall-masking scaffold.
|
||||
|
||||
What this generates, and the correction it exists to make
|
||||
---------------------------------------------------------
|
||||
There is a widespread misreading worth killing early: **a KernelSU module is a
|
||||
Magisk-compatible userspace module and cannot change what a syscall returns.**
|
||||
Its `post-fs-data.sh` / `service.sh` run as root in the normal world; nothing in
|
||||
that format reaches the kernel's return path. Everything a module of that shape
|
||||
can do is userspace work -- bind mounts, setting props, starting a daemon,
|
||||
writing config.
|
||||
|
||||
Actually rewriting what `openat`/`read`/`stat` return for `/proc/self/maps` or
|
||||
`/proc/self/status` needs one of exactly three things:
|
||||
|
||||
kpm KernelPatch / APatch Kernel Patch Module -- pointer replacement in the
|
||||
syscall table (`fp_hook_syscalln`) or inline hooks (`hook_wrapN`).
|
||||
Built with a bare-metal ARM64 toolchain (aarch64-none-elf-gcc) into a
|
||||
relocatable .kpm ELF. Needs a KernelPatch-patched boot image.
|
||||
lkm An out-of-tree kernel module. Needs kernel source matching the device's
|
||||
exact version and vermagic, and a loader path.
|
||||
ebpf A BPF program attached to a syscall tracepoint or kprobe. Needs a GKI
|
||||
kernel with the tracing/BTF machinery -- 5.10+ in Android practice.
|
||||
|
||||
So this script emits a **module directory that is honest about which half is
|
||||
loadable**: the userspace skeleton (module.prop + scripts + config) is real and
|
||||
installable, and each kernel-side target is a template with its toolchain gate
|
||||
stated in its own header. None of the kernel-side code is compiled or loaded
|
||||
anywhere by this script.
|
||||
|
||||
What is measured vs unverified
|
||||
------------------------------
|
||||
`generate`, `gates` and `verify` are exercised on the host (see
|
||||
docs/tool-verification/EXTENSION-kernel-weapons.md). **Whether the kernel-side
|
||||
code compiles or loads is unverified** -- that needs a device with the matching
|
||||
kernel and the matching toolchain, and the reference device is a 4.14.186 Magisk
|
||||
build where all three routes are closed.
|
||||
|
||||
Examples
|
||||
python kernelsu_syscall_mask.py gates
|
||||
python kernelsu_syscall_mask.py generate --out work/sysmask --package <PKG>
|
||||
python kernelsu_syscall_mask.py verify --dir work/sysmask
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kernel gate table. Every number here is either a toolchain fact or a
|
||||
# documented platform threshold; the "how to check" column is what turns it
|
||||
# from trivia into a test you run on your own device.
|
||||
# ---------------------------------------------------------------------------
|
||||
GATES = [
|
||||
{
|
||||
"route": "eBPF tracepoint/kprobe",
|
||||
"gate": "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",
|
||||
"ref": "https://source.android.com/docs/core/architecture/kernel/bpf",
|
||||
},
|
||||
{
|
||||
"route": "KernelPatch / APatch KPM",
|
||||
"gate": "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",
|
||||
"ref": "https://github.com/bmax121/KernelPatch",
|
||||
},
|
||||
{
|
||||
"route": "Out-of-tree LKM",
|
||||
"gate": "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",
|
||||
"ref": "https://source.android.com/docs/core/architecture/kernel/android-common",
|
||||
},
|
||||
{
|
||||
"route": "seccomp-BPF (for contrast)",
|
||||
"gate": "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",
|
||||
"ref": "references/kernel-and-environment-hardening.md",
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_RULES = [
|
||||
{
|
||||
"syscall": "openat",
|
||||
"path": "/proc/self/maps",
|
||||
"action": "deny",
|
||||
"errno": "ENOENT",
|
||||
"note": "hides the mapping list a userspace instrumentation agent "
|
||||
"advertises itself in",
|
||||
},
|
||||
{
|
||||
"syscall": "newfstatat",
|
||||
"path": "/proc/self/maps",
|
||||
"action": "fake_size",
|
||||
"size": 0,
|
||||
"note": "a stat that reports 0 bytes is as good as absent for a reader "
|
||||
"that sizes its buffer first",
|
||||
},
|
||||
{
|
||||
"syscall": "openat",
|
||||
"path": "/proc/self/status",
|
||||
"action": "filter_line",
|
||||
"prefix": "TracerPid",
|
||||
"note": "the write side owns this field; see "
|
||||
"references/kernel-and-environment-hardening.md section 5",
|
||||
},
|
||||
{
|
||||
"syscall": "read",
|
||||
"path": "/proc/self/status",
|
||||
"action": "filter_line",
|
||||
"prefix": "TracerPid",
|
||||
"note": "line filter applied to the read once the fd is open",
|
||||
},
|
||||
]
|
||||
|
||||
MODULE_PROP = """id={id}
|
||||
name={name}
|
||||
version={version}
|
||||
versionCode=1
|
||||
author=apk-reverse
|
||||
description={description}
|
||||
"""
|
||||
|
||||
POST_FS_DATA = """#!/system/bin/sh
|
||||
# Runs at post-fs-data, as root, in the normal world.
|
||||
# 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.
|
||||
MODDIR=${{0%/*}}
|
||||
CONF=$MODDIR/config/syscall_mask.json
|
||||
LOG=/data/adb/{id}.log
|
||||
|
||||
if [ ! -f "$CONF" ]; then
|
||||
echo "$(date) missing $CONF" >> "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
echo "$(date) post-fs-data: config present, $(wc -c < "$CONF") bytes" >> "$LOG"
|
||||
# The KPM/LKM loader path is intentionally NOT invoked from here. Loading a
|
||||
# kernel module is a boot-image-level decision with a bricking risk; do it once,
|
||||
# by hand, and read the evidence file first.
|
||||
exit 0
|
||||
"""
|
||||
|
||||
SERVICE = """#!/system/bin/sh
|
||||
# Runs in late_start service mode, as root. Kept minimal and non-blocking on
|
||||
# purpose: a module that spins here delays boot on every start, and this one
|
||||
# only reports state.
|
||||
MODDIR=${{0%/*}}
|
||||
CONF=$MODDIR/config/syscall_mask.json
|
||||
LOG=/data/adb/{id}.log
|
||||
|
||||
if [ -r /proc/sys/kernel/version ]; then
|
||||
echo "$(date) service: kernel $(uname -r)" >> "$LOG"
|
||||
fi
|
||||
echo "$(date) service: rules=$(grep -c '"syscall"' "$CONF" 2>/dev/null)" >> "$LOG"
|
||||
exit 0
|
||||
"""
|
||||
|
||||
UNINSTALL = """#!/system/bin/sh
|
||||
# Uninstall hook. Deliberately does not attempt to unload a kernel module: if one
|
||||
# is loaded, unloading it is a separate deliberate act with its own risk, and a
|
||||
# silent unload during package removal is how a device ends up in a boot loop.
|
||||
rm -f /data/adb/{id}.log
|
||||
exit 0
|
||||
"""
|
||||
|
||||
CUSTOMIZE = """#!/system/bin/sh
|
||||
# KernelSU/Magisk install-time script. Kept as a no-op so the module always
|
||||
# installs; see the module README for why nothing is set up here.
|
||||
exit 0
|
||||
"""
|
||||
|
||||
KPM_C = r'''// SPDX-License-Identifier: GPL-2.0
|
||||
/*
|
||||
* syscall_mask.c -- config-driven syscall return masking, KernelPatch/APatch KPM.
|
||||
*
|
||||
* Generated by skills/apk-reverse/scripts/kernelsu_syscall_mask.py
|
||||
*
|
||||
* STATUS: TEMPLATE, NOT COMPILED AND NOT LOADED. Every hook below follows the
|
||||
* KernelPatch KPM interface as documented in public KPM development write-ups
|
||||
* (KPM_NAME/KPM_INIT/KPM_EXIT sections, kfunc_def symbol resolution without
|
||||
* `extern`, fp_hook_syscalln pointer replacement, hook_fargs4_t callbacks with
|
||||
* args->skip_origin / args->ret). VERIFY EVERY NAME AND SIGNATURE against the
|
||||
* kpmodule.h of the KernelPatch revision you build against -- this file was not
|
||||
* compiled anywhere, and an interface that has drifted produces a module that
|
||||
* fails to load, or worse, loads and faults.
|
||||
*
|
||||
* Build (bare-metal ARM64, NOT the NDK):
|
||||
* aarch64-none-elf-gcc -O2 -fno-stack-protector -c syscall_mask.c
|
||||
* ... link into a relocatable ELF per the KernelPatch module docs
|
||||
* Load: push the .kpm and add it through the APatch app's KPM manager.
|
||||
*
|
||||
* Design notes that are load-bearing rather than stylistic:
|
||||
* - Callbacks run on EVERY syscall of their number, system-wide. The first
|
||||
* thing each one does is reject the overwhelmingly common case, because a
|
||||
* hook that does real work on every write is how a device locks up.
|
||||
* - Inline hooks (hook_wrapN) rewrite the target's prologue. On an LTO kernel
|
||||
* the exported symbol is frequently NOT the call site (the call got inlined),
|
||||
* so an inline hook installs cleanly and never fires. Pointer replacement in
|
||||
* the syscall table (fp_hook_syscalln) does not have that failure mode,
|
||||
* because the syscall entry path must go through the table.
|
||||
* - Never sleep in these callbacks except where the syscall itself runs in
|
||||
* process context and the copy helper is the sleeping variant.
|
||||
*/
|
||||
#include <linux/types.h>
|
||||
#include <linux/string.h>
|
||||
#include <linux/errno.h>
|
||||
#include <kpmodule.h>
|
||||
|
||||
KPM_NAME("{id}");
|
||||
KPM_VERSION("{version}");
|
||||
KPM_LICENSE("GPL");
|
||||
KPM_AUTHOR("apk-reverse");
|
||||
KPM_DESCRIPTION("{description}");
|
||||
|
||||
/* kfunc declarations must NOT carry `extern`: an extern declaration compiles to
|
||||
* an undefined reference (*UND*) and the loader rejects the module with
|
||||
* "unknown symbol". Letting it be a tentative definition allocates the slot in
|
||||
* .bss and the loader fills it in. Measured and documented in public KPM work. */
|
||||
int kfunc_def(strncpy_from_user)(char *dst, const char __user *src, long count);
|
||||
|
||||
/* ------------------------------------------------------------------ rules --
|
||||
* Generated from config/syscall_mask.json. Keep this table in sync by
|
||||
* re-running the generator rather than by hand-editing it.
|
||||
*/
|
||||
#define MASK_DENY 1 /* return -err before the real syscall runs */
|
||||
#define MASK_FAKE_SIZE 2 /* openat succeeds, stat reports a bogus size */
|
||||
#define MASK_FILTER_LINE 3 /* strip lines with this prefix from read() */
|
||||
|
||||
struct mask_rule {
|
||||
const char *path; /* userspace path to match, NULL = any */
|
||||
const char *prefix; /* line prefix for MASK_FILTER_LINE, else NULL */
|
||||
int action;
|
||||
int err; /* -errno for MASK_DENY */
|
||||
long value; /* size for MASK_FAKE_SIZE */
|
||||
};
|
||||
|
||||
static const struct mask_rule rules[] = {
|
||||
{RULE_TABLE}
|
||||
};
|
||||
|
||||
#define NRULES (sizeof(rules) / sizeof(rules[0]))
|
||||
|
||||
/* A bounded, allocation-free matcher. The path is copied in first, so the
|
||||
* comparison never touches userspace after the initial copy -- touching
|
||||
* userspace memory twice is how a hook races with the caller's own unmapping. */
|
||||
static int copy_and_match(const char __user *upath, char *kbuf, unsigned long n,
|
||||
const char **matched)
|
||||
{{
|
||||
long copied;
|
||||
unsigned long i;
|
||||
|
||||
if (!upath || n == 0)
|
||||
return 0;
|
||||
if (!kfunc(strncpy_from_user))
|
||||
return 0; /* symbol unresolved: fail open */
|
||||
copied = kfunc(strncpy_from_user)(kbuf, upath, (long)(n - 1));
|
||||
if (copied <= 0)
|
||||
return 0;
|
||||
kbuf[copied] = '\0';
|
||||
|
||||
for (i = 0; i < NRULES; i++) {{
|
||||
if (!rules[i].path)
|
||||
continue;
|
||||
if (strstr(kbuf, rules[i].path)) {{
|
||||
*matched = rules[i].path;
|
||||
return rules[i].action;
|
||||
}}
|
||||
}}
|
||||
return 0;
|
||||
}}
|
||||
|
||||
/* ------------------------------------------------------------------ openat --
|
||||
* The path argument is arg2 on arm64 (dfd, path, flags, mode). If the path is
|
||||
* unreadable, the rule is skipped rather than guessed at.
|
||||
*/
|
||||
static void before_openat(hook_fargs4_t *args, void *udata)
|
||||
{{
|
||||
const char __user *upath = (const char __user *)syscall_argn(args, 1);
|
||||
char kbuf[256];
|
||||
const char *matched = 0;
|
||||
int action;
|
||||
|
||||
(void)udata;
|
||||
action = copy_and_match(upath, kbuf, sizeof(kbuf), &matched);
|
||||
if (action == MASK_DENY)
|
||||
args->ret = MASK_DENY_ERRNO;
|
||||
if (action == MASK_FAKE_SIZE)
|
||||
args->skip_origin = 0; /* let it open; the stat is spoofed */
|
||||
}}
|
||||
|
||||
/* --------------------------------------------------------------- newfstatat --
|
||||
* arm64 has no plain `stat` syscall; readers go through newfstatat (or fstatat64
|
||||
* on 32-bit). A zero size is what makes a reader that sizes its buffer first
|
||||
* decide there is nothing to read.
|
||||
*/
|
||||
static void after_newfstatat(hook_fargs4_t *args, void *udata)
|
||||
{{
|
||||
const char __user *upath = (const char __user *)syscall_argn(args, 1);
|
||||
char kbuf[256];
|
||||
const char *matched = 0;
|
||||
|
||||
(void)udata;
|
||||
if (copy_and_match(upath, kbuf, sizeof(kbuf), &matched) != MASK_FAKE_SIZE)
|
||||
return;
|
||||
/* The struct stat* is arg2; zeroing st_size is the whole point. Field
|
||||
* offsets are ABI-specific -- resolve them against the target's headers
|
||||
* rather than trusting an offset copied from a different architecture. */
|
||||
/* TODO(verify-on-device): zero the size field of the struct at arg2. */
|
||||
}}
|
||||
|
||||
/* -------------------------------------------------------------------- read --
|
||||
* Line filtering happens on the read side, once the fd is already open. Doing
|
||||
* it here rather than at openat is what lets a reader that opens the file
|
||||
* before the filter is installed still be covered.
|
||||
*/
|
||||
static void before_read(hook_fargs4_t *args, void *udata)
|
||||
{{
|
||||
long count = (long)syscall_argn(args, 2);
|
||||
(void)udata;
|
||||
if (count <= 0 || count > 8192)
|
||||
return; /* first filter: the common case */
|
||||
/* TODO(verify-on-device): identify the fd's path, copy the buffer, rewrite
|
||||
* it in place minus the filtered lines, and adjust the returned length. */
|
||||
}}
|
||||
|
||||
/* ------------------------------------------------------------------ init -----
|
||||
* Registration order matters: install the syscall hooks first, then enable the
|
||||
* rules. If the module is unloaded between the two, the hooks must already be
|
||||
* gone.
|
||||
*/
|
||||
static long mask_init(const char *args, const struct kernel_patch_funcs *kp)
|
||||
{{
|
||||
(void)args;
|
||||
(void)kp;
|
||||
|
||||
if (!kfunc(strncpy_from_user)) {{
|
||||
/* Failing loudly here is deliberate: a half-armed hook set that cannot
|
||||
* read a path would pass every check through, which looks like success
|
||||
* and is not. */
|
||||
return -EINVAL;
|
||||
}}
|
||||
|
||||
/* 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); */
|
||||
/* TODO(verify-on-device): the argument lists above follow the public
|
||||
* examples; confirm arity against your kpmodule.h before enabling them. */
|
||||
return 0;
|
||||
}}
|
||||
|
||||
static long mask_exit(void *udata)
|
||||
{{
|
||||
(void)udata;
|
||||
/* Unhook in the exact reverse order of registration. */
|
||||
return 0;
|
||||
}}
|
||||
|
||||
KPM_INIT(mask_init);
|
||||
KPM_EXIT(mask_exit);
|
||||
'''
|
||||
|
||||
LKM_C = r'''// SPDX-License-Identifier: GPL-2.0
|
||||
/*
|
||||
* syscall_mask.c -- out-of-tree LKM variant of the same idea.
|
||||
*
|
||||
* STATUS: TEMPLATE, NOT COMPILED. Worse than the KPM variant in one specific
|
||||
* way worth understanding before choosing it: to replace a syscall you must
|
||||
* reach the syscall table, and `kallsyms_lookup_name` stopped being exported in
|
||||
* Linux 5.7. On pre-5.7 kernels a direct call works; after that you need a
|
||||
* kprobe to recover the address, or an explicit kernel export. The device's own
|
||||
* kernel source must also match its exact vermagic, and an out-of-tree module
|
||||
* built against a mismatched tree is refused at insmod.
|
||||
*
|
||||
* This is genuine kernel development with a bricking risk. It is the most
|
||||
* expensive of the three routes and the one least likely to be worth it.
|
||||
*/
|
||||
#include <linux/module.h>
|
||||
#include <linux/kernel.h>
|
||||
#include <linux/syscalls.h>
|
||||
#include <linux/uaccess.h>
|
||||
#include <linux/version.h>
|
||||
|
||||
MODULE_LICENSE("GPL");
|
||||
MODULE_DESCRIPTION("{description}");
|
||||
MODULE_AUTHOR("apk-reverse");
|
||||
|
||||
/*
|
||||
* Pre-5.7: kallsyms_lookup_name() is exported, so the table pointer is
|
||||
* recoverable directly. 5.7+: NOT exported -- you must either carry a kprobe to
|
||||
* find the symbol or patch the kernel's export list. There is no portable
|
||||
* version of this, which is the honest reason this route is rated last.
|
||||
*/
|
||||
#if LINUX_VERSION_CODE < KERNEL_VERSION(5, 7, 0)
|
||||
static unsigned long *sys_call_table_addr(void)
|
||||
{
|
||||
return (unsigned long *)kallsyms_lookup_name("sys_call_table");
|
||||
}
|
||||
#else
|
||||
static unsigned long *sys_call_table_addr(void)
|
||||
{
|
||||
/* TODO(verify-on-device): recover via kprobe on kallsyms_lookup_name, or
|
||||
* enable an explicit export in your own kernel build. */
|
||||
return NULL;
|
||||
}
|
||||
#endif
|
||||
|
||||
static int __init mask_init(void)
|
||||
{
|
||||
unsigned long *table = sys_call_table_addr();
|
||||
if (!table) {
|
||||
pr_err("syscall_mask: cannot locate sys_call_table on this kernel\n");
|
||||
return -EINVAL;
|
||||
}
|
||||
pr_info("syscall_mask: sys_call_table at %px\n", table);
|
||||
/* TODO(verify-on-device): save the original pointer, write CR0 (or use
|
||||
* set_memory_rw) to make the table writable, swap in your handler. Getting
|
||||
* the write-protect dance wrong takes the device down. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void __exit mask_exit(void)
|
||||
{
|
||||
/* TODO(verify-on-device): restore the saved pointers. */
|
||||
}
|
||||
|
||||
module_init(mask_init);
|
||||
module_exit(mask_exit);
|
||||
'''
|
||||
|
||||
EBPF_C = r'''// SPDX-License-Identifier: GPL-2.0 OR BSD-2-Clause
|
||||
/*
|
||||
* syscall_mask.bpf.c -- eBPF probe template: filter sys_enter_openat, then
|
||||
* rewrite the result.
|
||||
*
|
||||
* STATUS: TEMPLATE, NOT COMPILED AND NOT ATTACHED. Not runnable on a 4.14
|
||||
* kernel: see the gate table in the module README (GKI 5.10+ in Android
|
||||
* practice, plus BTF for CO-RE style probes).
|
||||
*
|
||||
* READ THIS BEFORE COUNTING ON THE RESULT REWRITE. There are two different
|
||||
* attachment points and they are not interchangeable:
|
||||
*
|
||||
* - A *tracepoint* (syscalls/sys_enter_openat) is an observation point. It can
|
||||
* read the arguments and it can emit events, and it CANNOT change the
|
||||
* syscall's return value. A tracepoint-only program gives you visibility,
|
||||
* not spoofing.
|
||||
* - A *kprobe* on the syscall's entry (or a kretprobe on its exit) can modify
|
||||
* a register with bpf_override_return() -- but only for functions flagged
|
||||
* ALLOW_ERROR_INJECTION, which kernel syscall entry points are not, in
|
||||
* general. On many kernels the helper is refused for exactly this target.
|
||||
*
|
||||
* So the honest eBPF shape for this job is: observe with a tracepoint, and do
|
||||
* the rewrite in a place that actually owns the return value. What follows is
|
||||
* the observation half done properly, plus the rewrite half marked for the
|
||||
* device-side verification it needs.
|
||||
*/
|
||||
#include <vmlinux.h>
|
||||
#include <bpf/bpf_helpers.h>
|
||||
#include <bpf/bpf_tracing.h>
|
||||
|
||||
char LICENSE[] SEC("license") = "GPL";
|
||||
|
||||
#define MAX_PATH 256
|
||||
#define TARGET_LEN 15 /* strlen("/proc/self/maps") */
|
||||
|
||||
struct event {
|
||||
__u32 pid;
|
||||
__u32 uid;
|
||||
__s32 dfd;
|
||||
__s64 ret;
|
||||
char path[MAX_PATH];
|
||||
char comm[16];
|
||||
};
|
||||
/* /proc/self/status is 17; kept as a separate constant so the two rules can be
|
||||
* toggled independently. */
|
||||
#define TARGET_STATUS_LEN 17
|
||||
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_RINGBUF);
|
||||
__uint(max_entries, 1 << 24);
|
||||
} events SEC(".maps");
|
||||
|
||||
/* Filter the target package's processes. Populated from userspace with the
|
||||
* uids the app can run as; an empty map means "all processes", which is a
|
||||
* deliberate default because a filter that silently matches nothing looks
|
||||
* exactly like a probe that is not firing. */
|
||||
struct {
|
||||
__uint(type, BPF_MAP_TYPE_HASH);
|
||||
__uint(max_entries, 64);
|
||||
__type(key, __u32); /* uid */
|
||||
__type(value, __u8);
|
||||
} target_uids SEC(".maps");
|
||||
|
||||
static __always_inline int want(void)
|
||||
{
|
||||
__u32 uid = bpf_get_current_uid_gid() & 0xffffffff;
|
||||
__u8 *hit = bpf_map_lookup_elem(&target_uids, &uid);
|
||||
/* Empty map -> observe everything. Populated map -> only listed uids. */
|
||||
return hit || (bpf_map_lookup_elem(&target_uids, &uid) == 0);
|
||||
}
|
||||
|
||||
SEC("tracepoint/syscalls/sys_enter_openat")
|
||||
int tp_sys_enter_openat(struct trace_event_raw_sys_enter *ctx)
|
||||
{
|
||||
struct event *e;
|
||||
const char *upath = (const char *)ctx->args[1];
|
||||
|
||||
if (!want())
|
||||
return 0;
|
||||
|
||||
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
|
||||
if (!e)
|
||||
return 0;
|
||||
e->pid = bpf_get_current_pid_tgid() >> 32;
|
||||
e->uid = bpf_get_current_uid_gid() & 0xffffffff;
|
||||
e->dfd = (__s32)ctx->args[0];
|
||||
e->ret = 0;
|
||||
bpf_get_current_comm(&e->comm, sizeof(e->comm));
|
||||
bpf_probe_read_user_str(&e->path, sizeof(e->path), upath);
|
||||
bpf_ringbuf_submit(e, 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The result-rewrite half. Two dead ends are recorded here on purpose so they
|
||||
* are not re-derived:
|
||||
*
|
||||
* 1. bpf_override_return() only works on functions marked
|
||||
* ALLOW_ERROR_INJECTION. A raw syscall entry is not one of them, so the
|
||||
* helper is typically refused for this target -- check the return value
|
||||
* and the verifier message rather than assuming the rewrite happened.
|
||||
* 2. A tracepoint cannot rewrite at all. If the rewrite is essential, the
|
||||
* route is a kprobe plus a kernel that permits the override, or one of the
|
||||
* KPM/LKM routes; eBPF's strength here is observation.
|
||||
*/
|
||||
SEC("kprobe/do_sys_openat2")
|
||||
int BPF_KPROBE(kp_do_sys_openat2, int dfd, const char *filename)
|
||||
{
|
||||
char buf[64];
|
||||
if (bpf_probe_read_user_str(&buf, sizeof(buf), filename) <= 0)
|
||||
return 0;
|
||||
if (buf[0] == '/' && buf[1] == 'p' && buf[2] == 'r') {
|
||||
/* TODO(verify-on-device): bpf_override_return(ctx, -ENOENT) here only
|
||||
* after confirming ALLOW_ERROR_INJECTION on this target kernel. */
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
KPM_MAKEFILE = """# aarch64-none-elf-gcc is a BARE-METAL ARM64 compiler, not the Android NDK
|
||||
# toolchain. The NDK targets Android userspace and will not produce a loadable
|
||||
# KPM. STATUS: untested -- this toolchain is not present on a Magisk-only device.
|
||||
CROSS ?= aarch64-none-elf-
|
||||
CC := $(CROSS)gcc
|
||||
OBJCOPY := $(CROSS)objcopy
|
||||
|
||||
TARGET := {id}
|
||||
KPM_DIR ?= ../KernelPatch
|
||||
|
||||
CFLAGS := -O2 -fno-stack-protector -fno-pic -fno-builtin \\
|
||||
-I$(KPM_DIR)/kernel/include -I$(KPM_DIR)/include \\
|
||||
-Wall -Wno-unused-variable
|
||||
|
||||
all: $(TARGET).kpm
|
||||
|
||||
$(TARGET).kpm: src/{id}.c
|
||||
\t$(CC) $(CFLAGS) -c $< -o $@.o
|
||||
\t$(OBJCOPY) -O binary --only-section=.text $@.o /dev/null 2>/dev/null || true
|
||||
\t@echo "NOTE: linking follows the KernelPatch module format; see the docs"
|
||||
\t@echo " for the current link step before trusting this target."
|
||||
|
||||
clean:
|
||||
\trm -f *.o *.kpm
|
||||
|
||||
.PHONY: all clean
|
||||
"""
|
||||
|
||||
MODULE_README = """# {name} -- syscall-return masking scaffold
|
||||
|
||||
Generated by `skills/apk-reverse/scripts/kernelsu_syscall_mask.py`.
|
||||
|
||||
**Read this first: the userspace module in this directory cannot change what a
|
||||
syscall returns.** It is a KernelSU/Magisk-compatible module: its scripts run as
|
||||
root in the normal world. Its only jobs are to carry the configuration, to
|
||||
record that it ran, and to be a place to put the module metadata. The kernel
|
||||
side is a separate artifact with a separate toolchain and a separate risk.
|
||||
|
||||
## What is here
|
||||
|
||||
| Path | Status |
|
||||
|---|---|
|
||||
| `module.prop`, `customize.sh`, `post-fs-data.sh`, `service.sh` | real, installable module skeleton |
|
||||
| `config/syscall_mask.json` | the rule set, read by the kernel side |
|
||||
| `kpm/{id}.c`, `kpm/Makefile` | APatch/KernelPatch template -- **not compiled, not loaded** |
|
||||
| `lkm/{id}.c` | out-of-tree module template -- **not compiled, not loaded** |
|
||||
| `ebpf/syscall_mask.bpf.c` | eBPF probe template -- **not compiled, not attached** |
|
||||
|
||||
## Gate table -- check your device before choosing a route
|
||||
|
||||
| Route | Gate | How to check | Why |
|
||||
|---|---|---|---|
|
||||
{gates}
|
||||
|
||||
## Reference device, measured
|
||||
|
||||
```
|
||||
$ 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 ...) #1 SMP PREEMPT Wed Mar 30 23:32:42 CST 2022
|
||||
```
|
||||
|
||||
On that device **every kernel-side route above is closed**:
|
||||
|
||||
- eBPF: kernel 4.14 is well below the 5.10 gate.
|
||||
- KPM: no KernelPatch-patched image, and no `aarch64-none-elf-gcc` on the host;
|
||||
installing APatch means patching the boot image, which is a bricking risk.
|
||||
- LKM: no kernel source for the device, so no matching vermagic is possible.
|
||||
|
||||
That is a **route decision, not a failure** -- and it is why every kernel-side
|
||||
file here is labelled a template. Nothing in this directory was loaded on
|
||||
anything.
|
||||
|
||||
## If you do take a kernel route
|
||||
|
||||
Do it once, deliberately, and read the evidence first:
|
||||
|
||||
1. Confirm the gate in the table above on the actual device.
|
||||
2. Build the kernel side on a machine that has the right toolchain.
|
||||
3. **Back up the boot image and know the recovery path before loading anything.**
|
||||
A kernel module that faults takes the device down before you can read why.
|
||||
4. Load it by hand, watch `dmesg`, and keep the previous boot image in hand.
|
||||
5. Only then wire it into boot, and only if the load is repeatable.
|
||||
|
||||
`post-fs-data.sh` in this scaffold deliberately does **not** load anything. That
|
||||
is not an omission.
|
||||
"""
|
||||
|
||||
|
||||
def _render(template, **kw):
|
||||
"""Fill a template's {placeholders} without str.format().
|
||||
|
||||
Braces are the common case in C, so using format() here would mean doubling
|
||||
every one of them -- and the doubled-brace convention fails silently in the
|
||||
other direction the moment somebody next edits the C by hand. `{{` is
|
||||
unescaped and only the named placeholders are substituted, so ordinary C
|
||||
braces pass through untouched.
|
||||
"""
|
||||
text = template.replace("{{", "{").replace("}}", "}")
|
||||
for key, value in kw.items():
|
||||
text = text.replace("{%s}" % key, str(value))
|
||||
return text
|
||||
|
||||
|
||||
def _rule_table_c(rules):
|
||||
"""Render the JSON rule set as a C table."""
|
||||
errno_map = {"ENOENT": 2, "EACCES": 13, "EINVAL": 22, "EPERM": 1}
|
||||
action_map = {"deny": "MASK_DENY", "fake_size": "MASK_FAKE_SIZE",
|
||||
"filter_line": "MASK_FILTER_LINE"}
|
||||
lines = []
|
||||
for rule in rules:
|
||||
path = rule.get("path")
|
||||
prefix = rule.get("prefix")
|
||||
action = action_map.get(rule.get("action"), "MASK_DENY")
|
||||
err = errno_map.get(rule.get("errno", "ENOENT"), 2)
|
||||
value = rule.get("size", 0)
|
||||
lines.append(' { %s, %s, %s, -%d, %d },'
|
||||
% ('"%s"' % path if path else "0",
|
||||
'"%s"' % prefix if prefix else "0",
|
||||
action, err, value))
|
||||
return "\n".join(lines) if lines else " /* no rules configured */"
|
||||
|
||||
|
||||
def cmd_gates(args):
|
||||
"""Report the kernel-route gate table, and probe this host where it can be probed."""
|
||||
print("== kernel-side routes and their gates ==\n")
|
||||
for g in GATES:
|
||||
print(" %-28s %s" % (g["route"], g["gate"]))
|
||||
print(" %-28s why: %s" % ("", g["why"]))
|
||||
print(" %-28s check: %s" % ("", g["check"]))
|
||||
print()
|
||||
print("== host probe (what can be checked from here) ==\n")
|
||||
cc = None
|
||||
import shutil
|
||||
for cand in ("aarch64-none-elf-gcc", "aarch64-linux-gnu-gcc", "clang"):
|
||||
found = shutil.which(cand)
|
||||
if found:
|
||||
cc = (cand, found)
|
||||
break
|
||||
if cc:
|
||||
print(" bare-metal ARM64-ish compiler: %s -> %s" % cc)
|
||||
if cc[0] == "clang":
|
||||
print(" note: clang can target aarch64-none-elf, so this is "
|
||||
"potentially usable for the KPM route")
|
||||
else:
|
||||
print(" bare-metal ARM64 compiler: NOT FOUND")
|
||||
print(" the KPM route cannot be built from this host as configured")
|
||||
for name in ("ndk-build", "make"):
|
||||
print(" %-12s %s" % (name + ":", shutil.which(name) or "NOT FOUND"))
|
||||
print("\n 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.")
|
||||
print(" Run the `check` command for each route on the device before "
|
||||
"believing any of this applies to it.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_generate(args):
|
||||
out = args.out
|
||||
rules = DEFAULT_RULES
|
||||
if args.rules:
|
||||
with open(args.rules, encoding="utf-8") as fh:
|
||||
rules = json.load(fh)
|
||||
desc = args.description
|
||||
os.makedirs(os.path.join(out, "config"), exist_ok=True)
|
||||
|
||||
written = []
|
||||
|
||||
def put(rel, text, mode=None):
|
||||
path = os.path.join(out, rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="\n") as fh:
|
||||
fh.write(text)
|
||||
if mode is not None:
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
written.append(rel)
|
||||
|
||||
put("module.prop", _render(MODULE_PROP,
|
||||
id=args.id, name=args.name, version=args.version,
|
||||
description=desc))
|
||||
put("customize.sh", CUSTOMIZE, 0o755)
|
||||
put("post-fs-data.sh", _render(POST_FS_DATA, id=args.id), 0o755)
|
||||
put("service.sh", _render(SERVICE, id=args.id), 0o755)
|
||||
put("uninstall.sh", _render(UNINSTALL, id=args.id), 0o755)
|
||||
put("config/syscall_mask.json", json.dumps(
|
||||
{"version": 1, "id": args.id, "package": args.package,
|
||||
"enabled": True, "note": "read by the kernel side, not by the shell "
|
||||
"scripts -- see the module README",
|
||||
"rules": rules}, indent=2) + "\n")
|
||||
|
||||
want = set(args.targets.split(","))
|
||||
if "kpm" in want:
|
||||
body = _render(KPM_C, id=args.id, version=args.version, description=desc,
|
||||
RULE_TABLE=_rule_table_c(rules))
|
||||
put("kpm/%s.c" % args.id, body)
|
||||
put("kpm/Makefile", _render(KPM_MAKEFILE, id=args.id))
|
||||
if "lkm" in want:
|
||||
put("lkm/%s.c" % args.id, _render(LKM_C, id=args.id, description=desc))
|
||||
if "ebpf" in want:
|
||||
put("ebpf/syscall_mask.bpf.c", EBPF_C)
|
||||
|
||||
gate_rows = "\n".join("| %s | %s | `%s` | %s |"
|
||||
% (g["route"], g["gate"], g["check"], g["why"])
|
||||
for g in GATES)
|
||||
put("README.md", _render(MODULE_README, name=args.name, gates=gate_rows,
|
||||
id=args.id))
|
||||
|
||||
print("generated module scaffold in %s" % out)
|
||||
for rel in written:
|
||||
size = os.path.getsize(os.path.join(out, rel))
|
||||
print(" %-28s %7d B" % (rel, size))
|
||||
print()
|
||||
print("STATUS: the userspace module is installable; every kernel-side file is "
|
||||
"an UNVERIFIED template.")
|
||||
print(" No kernel code was compiled or loaded. Run `gates` and read the "
|
||||
"device README")
|
||||
print(" section before assuming any route is open on your target.")
|
||||
return 0
|
||||
|
||||
|
||||
def _strip_c_comments(text):
|
||||
"""Remove /* */ and // comments before any token-level check.
|
||||
|
||||
Not cosmetic: the first version of this check reported a false positive
|
||||
because the template's own comment explains why `extern` is forbidden, and a
|
||||
naive `extern.*kfunc_def` regex happily matched the prose and then ran on to
|
||||
the real declaration's semicolon. A linter that reads comments as code
|
||||
produces exactly the kind of report nobody can trust.
|
||||
"""
|
||||
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
||||
return re.sub(r"//[^\n]*", "", text)
|
||||
|
||||
|
||||
def cmd_verify(args):
|
||||
"""Read-only checks over a generated directory. Everything here is local."""
|
||||
d = args.dir
|
||||
problems = []
|
||||
print("== verifying %s ==" % d)
|
||||
|
||||
prop = os.path.join(d, "module.prop")
|
||||
if not os.path.isfile(prop):
|
||||
problems.append("module.prop missing")
|
||||
else:
|
||||
fields = {}
|
||||
with open(prop, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if "=" in line:
|
||||
k, v = line.rstrip("\n").split("=", 1)
|
||||
fields[k] = v
|
||||
for req in ("id", "name", "version", "versionCode"):
|
||||
if req not in fields:
|
||||
problems.append("module.prop missing required field %r" % req)
|
||||
if "id" in fields and not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9._-]*", fields["id"]):
|
||||
problems.append("module.prop id %r is not a valid module id" % fields["id"])
|
||||
print(" module.prop: %s" % ", ".join("%s=%s" % kv for kv in sorted(fields.items())))
|
||||
|
||||
conf = os.path.join(d, "config", "syscall_mask.json")
|
||||
if not os.path.isfile(conf):
|
||||
problems.append("config/syscall_mask.json missing")
|
||||
else:
|
||||
try:
|
||||
with open(conf, encoding="utf-8") as fh:
|
||||
doc = json.load(fh)
|
||||
rules = doc.get("rules", [])
|
||||
known = {"deny", "fake_size", "filter_line"}
|
||||
for i, r in enumerate(rules):
|
||||
if r.get("action") not in known:
|
||||
problems.append("rule %d has unknown action %r" % (i, r.get("action")))
|
||||
if not r.get("syscall"):
|
||||
problems.append("rule %d names no syscall" % i)
|
||||
print(" config: %d rule(s), actions=%s"
|
||||
% (len(rules), sorted({r.get("action") for r in rules})))
|
||||
except (ValueError, OSError) as exc:
|
||||
problems.append("config/syscall_mask.json unreadable: %s" % exc)
|
||||
|
||||
for rel, want_exec in (("post-fs-data.sh", True), ("service.sh", True),
|
||||
("customize.sh", True), ("uninstall.sh", True)):
|
||||
p = os.path.join(d, rel)
|
||||
if not os.path.isfile(p):
|
||||
problems.append("%s missing" % rel)
|
||||
continue
|
||||
if want_exec and not os.access(p, os.X_OK):
|
||||
# Not fatal on a filesystem without an exec bit; the installer sets it.
|
||||
print(" note: %s is not executable here (the installer chmods it)" % rel)
|
||||
|
||||
for rel in ("kpm", "lkm", "ebpf"):
|
||||
p = os.path.join(d, rel)
|
||||
if os.path.isdir(p):
|
||||
print(" %-4s target present: %s"
|
||||
% (rel, ", ".join(sorted(os.listdir(p)))))
|
||||
print(" status: UNVERIFIED TEMPLATE -- not compiled, not loaded")
|
||||
|
||||
# The one static check worth doing on the C: the KPM interface has a
|
||||
# documented trap (an extern kfunc declaration compiles to *UND* and the
|
||||
# loader refuses the module), so catch it here rather than on a device.
|
||||
kpm_dir = os.path.join(d, "kpm")
|
||||
if os.path.isdir(kpm_dir):
|
||||
for name in sorted(os.listdir(kpm_dir)):
|
||||
if not name.endswith(".c"):
|
||||
continue
|
||||
with open(os.path.join(kpm_dir, name), encoding="utf-8") as fh:
|
||||
text = fh.read()
|
||||
code = _strip_c_comments(text)
|
||||
if re.search(r"\bextern\b[^;]*\bkfunc_def\b", code):
|
||||
problems.append("%s declares kfunc_def with `extern`, which the "
|
||||
"loader rejects as an unknown symbol" % name)
|
||||
for macro in ("KPM_NAME", "KPM_INIT", "KPM_EXIT"):
|
||||
if macro not in code:
|
||||
problems.append("%s is missing %s" % (name, macro))
|
||||
print(" kpm/%s: KPM_* lifecycle macros present, kfunc_def not extern"
|
||||
% name)
|
||||
|
||||
print()
|
||||
if problems:
|
||||
print("== %d problem(s) ==" % len(problems))
|
||||
for p in problems:
|
||||
print(" - %s" % p)
|
||||
return 1
|
||||
print("== 0 problem(s): structure is consistent ==")
|
||||
print(" (this says nothing about whether the kernel side compiles or loads)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="kernelsu_syscall_mask.py",
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
p = sub.add_parser("gates", help="print the kernel-route gate table and probe this host",
|
||||
description="The gate table, plus whatever part of it can be "
|
||||
"checked from this machine. Device facts still have "
|
||||
"to be checked on the device.")
|
||||
p.set_defaults(func=cmd_gates)
|
||||
|
||||
p = sub.add_parser("generate", help="write the module scaffold",
|
||||
description="Generate a KernelSU/APatch-compatible module "
|
||||
"directory. The userspace half is installable; the "
|
||||
"kernel half is a template.")
|
||||
p.add_argument("--out", required=True, help="directory to write into")
|
||||
p.add_argument("--id", default="syscall-mask", help="module id / artifact name")
|
||||
p.add_argument("--name", default="Syscall Mask", help="human-readable module name")
|
||||
p.add_argument("--version", default="0.1.0", help="module version string")
|
||||
p.add_argument("--description", default="config-driven syscall return masking "
|
||||
"(openat/read/stat on /proc/self/*)",
|
||||
help="module description")
|
||||
p.add_argument("--package", default="<PKG>", help="target package placeholder")
|
||||
p.add_argument("--rules", default=None,
|
||||
help="JSON file with a {rules:[...]} rule set (default: built-in)")
|
||||
p.add_argument("--targets", default="kpm,lkm,ebpf",
|
||||
help="comma-separated kernel targets to emit (kpm,lkm,ebpf)")
|
||||
p.set_defaults(func=cmd_generate)
|
||||
|
||||
p = sub.add_parser("verify", help="read-only consistency checks over a generated dir",
|
||||
description="Check module.prop fields, the rule JSON, and the "
|
||||
"static traps in the kernel template. Says nothing "
|
||||
"about compilation or loading.")
|
||||
p.add_argument("--dir", required=True, help="a directory from `generate`")
|
||||
p.set_defaults(func=cmd_verify)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if not getattr(args, "cmd", None):
|
||||
parser.print_help()
|
||||
return 2
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -159,6 +159,474 @@ def ensure_keystore(ks_path, alias, password, keytool):
|
||||
return pwd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Split APK / App Bundle sets
|
||||
#
|
||||
# A store build is often NOT one file. An App Bundle turns into
|
||||
# base.apk + split_config.arm64_v8a.apk + split_config.xxhdpi.apk + ...
|
||||
# and every member carries the same package name and its own signature. Two
|
||||
# consequences, and they are the reason this section exists:
|
||||
#
|
||||
# * Signing only the base leaves the set disagreeing about its certificate.
|
||||
# `pm install-multiple` then refuses the whole set, with a signature error
|
||||
# that points at the base rather than at the member you did not sign.
|
||||
# * "Just merge them into one APK" is free only for splits that carry CODE or
|
||||
# NATIVE LIBRARIES. A split that carries RESOURCES cannot be folded in
|
||||
# without merging `resources.arsc` -- rewriting the table, its global string
|
||||
# pool and its type/entry offsets -- which is a resource-compiler job, not a
|
||||
# byte-level one. A wrongly merged arsc produces an APK that installs and
|
||||
# then renders the wrong thing, or fails on the first resource lookup.
|
||||
#
|
||||
# So the job here is to say which of the two routes is legal for a given set and
|
||||
# then do that one, instead of always emitting the same artifact.
|
||||
# ---------------------------------------------------------------------------
|
||||
ABI_SPLIT_NAMES = ('armeabi', 'armeabi_v7a', 'arm64_v8a', 'x86', 'x86_64',
|
||||
'riscv64', 'mips', 'mips64')
|
||||
DENSITY_SPLIT_NAMES = ('ldpi', 'mdpi', 'tvdpi', 'hdpi', 'xhdpi', 'xxhdpi',
|
||||
'xxxhdpi', 'anydpi', 'nodpi')
|
||||
# A split is required to carry an `resources.arsc`, so an almost-empty table is
|
||||
# normal and must not be mistaken for real resources. Measured on a real set:
|
||||
# the density splits that hold nothing but a placeholder table are 40 bytes,
|
||||
# while the ones that actually own drawables are 9-15 KB.
|
||||
EMPTY_ARSC_BYTES = 1024
|
||||
|
||||
|
||||
def find_apks(root):
|
||||
"""Every .apk at or under `root`.
|
||||
|
||||
Recursive on purpose: `adb pull <dir> <dest>` creates `<dest>/<dir>/`, so a
|
||||
pulled set arrives one level deeper than the caller expects (measured).
|
||||
"""
|
||||
if os.path.isfile(root):
|
||||
return [os.path.abspath(root)]
|
||||
found = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
||||
for name in sorted(filenames):
|
||||
if name.lower().endswith('.apk'):
|
||||
found.append(os.path.abspath(os.path.join(dirpath, name)))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def axml_string_pool(blob):
|
||||
"""Decode the string pool of a binary AndroidManifest.xml. No dependencies.
|
||||
|
||||
Only the pool is decoded, not the element tree: every name this script needs
|
||||
(`package`, `split`, `configForSplit`, `isSplitRequired`) is a pool entry, and
|
||||
a hand-rolled element walk is where such parsers usually go wrong. The pool
|
||||
is also what makes equal-length hex patching of a manifest possible later.
|
||||
"""
|
||||
import struct
|
||||
if len(blob) < 36 or struct.unpack_from('<H', blob, 0)[0] != 0x0003:
|
||||
return None # not binary XML
|
||||
if struct.unpack_from('<H', blob, 8)[0] != 0x0001:
|
||||
return None # no string pool where one must be
|
||||
hdr_size = struct.unpack_from('<H', blob, 10)[0]
|
||||
count, _styles, flags, strings_start, _styles_start = struct.unpack_from(
|
||||
'<IIIII', blob, 16)
|
||||
if not 0 < count <= 1000000:
|
||||
return None
|
||||
offsets = struct.unpack_from('<%dI' % count, blob, 8 + hdr_size)
|
||||
utf8 = bool(flags & (1 << 8))
|
||||
base = 8 + strings_start
|
||||
out = []
|
||||
for off in offsets:
|
||||
p = base + off
|
||||
if p >= len(blob):
|
||||
return None
|
||||
try:
|
||||
if utf8:
|
||||
n = blob[p]
|
||||
p += 1
|
||||
if n & 0x80:
|
||||
n = ((n & 0x7F) << 8) | blob[p]
|
||||
p += 1
|
||||
m = blob[p]
|
||||
p += 1
|
||||
if m & 0x80:
|
||||
m = ((m & 0x7F) << 8) | blob[p]
|
||||
p += 1
|
||||
out.append(blob[p:p + m].decode('utf-8', 'replace'))
|
||||
else:
|
||||
n = struct.unpack_from('<H', blob, p)[0]
|
||||
p += 2
|
||||
if n & 0x8000:
|
||||
n = ((n & 0x7FFF) << 16) | struct.unpack_from('<H', blob, p)[0]
|
||||
p += 2
|
||||
out.append(blob[p:p + n * 2].decode('utf-16-le', 'replace'))
|
||||
except (IndexError, struct.error):
|
||||
return None
|
||||
return out
|
||||
|
||||
|
||||
def axml_root_attrs(blob):
|
||||
"""Attributes of the root <manifest> element, as {name: value}.
|
||||
|
||||
Starts at the first start-element chunk, which is the root element in every
|
||||
manifest Android produces. Values come back as strings where the pool has
|
||||
them and as formatted scalars otherwise, so a caller can print them without
|
||||
caring which encoding the compiler chose (a boolean is `0xffffffff`, not the
|
||||
word "true", in the binary form).
|
||||
"""
|
||||
import struct
|
||||
strings = axml_string_pool(blob)
|
||||
if strings is None:
|
||||
return None
|
||||
total = struct.unpack_from('<I', blob, 4)[0] or len(blob)
|
||||
end = min(len(blob), total)
|
||||
off = 8
|
||||
while off + 8 <= end:
|
||||
ctype, _hsize, csize = struct.unpack_from('<HHI', blob, off)
|
||||
if csize < 8 or off + csize > end:
|
||||
break
|
||||
if ctype == 0x0102: # RES_XML_START_ELEMENT
|
||||
attr_start, attr_size, attr_count = struct.unpack_from(
|
||||
'<HHH', blob, off + 24)
|
||||
attrs = {}
|
||||
first = off + 16 + attr_start
|
||||
for i in range(attr_count):
|
||||
p = first + i * attr_size
|
||||
if p + 20 > off + csize:
|
||||
break
|
||||
name_i, raw_i = struct.unpack_from('<II', blob, p + 4)
|
||||
_size, _res0, dtype = struct.unpack_from('<HBB', blob, p + 12)
|
||||
data = struct.unpack_from('<I', blob, p + 16)[0]
|
||||
name = strings[name_i] if name_i < len(strings) else '?'
|
||||
if dtype == 0x03:
|
||||
val = strings[data] if data < len(strings) else ''
|
||||
elif raw_i != 0xFFFFFFFF and raw_i < len(strings):
|
||||
val = strings[raw_i]
|
||||
elif dtype == 0x10:
|
||||
val = str(data - (1 << 32) if data >= (1 << 31) else data)
|
||||
elif dtype == 0x11:
|
||||
val = '0x%x' % data
|
||||
elif dtype == 0x12:
|
||||
val = 'true' if data else 'false'
|
||||
elif dtype == 0x01:
|
||||
val = '@ref/0x%08x' % data
|
||||
else:
|
||||
val = 'type=0x%02x/0x%x' % (dtype, data)
|
||||
attrs[name] = val
|
||||
return attrs
|
||||
off += csize
|
||||
return None
|
||||
|
||||
|
||||
def inspect_apk(path):
|
||||
"""Structural facts about one member of a split set, from the zip alone.
|
||||
|
||||
Nothing here needs a device or a resource compiler: which split owns dex,
|
||||
which owns native libraries and which owns resources is visible in the entry
|
||||
list, and the split's own name (and therefore its role) is a manifest
|
||||
attribute. Reading it from the file is what keeps this usable on a set whose
|
||||
package name is irrelevant to the caller.
|
||||
"""
|
||||
info = {'path': os.path.abspath(path), 'name': os.path.basename(path),
|
||||
'size': os.path.getsize(path), 'dex': [], 'abis': [], 'res': 0,
|
||||
'assets': 0, 'libs': [], 'arsc': None, 'arsc_stored': None,
|
||||
'package': None, 'split': None, 'config_for_split': None,
|
||||
'is_split_required': None, 'is_feature': None, 'readable': True}
|
||||
try:
|
||||
with zipfile.ZipFile(path, 'r') as z:
|
||||
names = []
|
||||
for item in z.infolist():
|
||||
name = item.filename
|
||||
names.append(name)
|
||||
if item.is_dir():
|
||||
continue
|
||||
if re.match(r'^classes\d*\.dex$', name):
|
||||
info['dex'].append(name)
|
||||
elif name == 'resources.arsc':
|
||||
info['arsc'] = item.file_size
|
||||
info['arsc_stored'] = (item.compress_type == 0)
|
||||
elif name.startswith('lib/') and name.count('/') >= 2:
|
||||
abi = name.split('/')[1]
|
||||
if abi not in info['abis']:
|
||||
info['abis'].append(abi)
|
||||
info['libs'].append(name)
|
||||
elif name.startswith('res/'):
|
||||
info['res'] += 1
|
||||
elif name.startswith('assets/'):
|
||||
info['assets'] += 1
|
||||
if 'AndroidManifest.xml' in names:
|
||||
attrs = axml_root_attrs(z.read('AndroidManifest.xml')) or {}
|
||||
info['package'] = attrs.get('package')
|
||||
info['split'] = attrs.get('split')
|
||||
info['config_for_split'] = attrs.get('configForSplit')
|
||||
info['is_split_required'] = attrs.get('isSplitRequired')
|
||||
info['is_feature'] = attrs.get('isFeatureSplit')
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
info['readable'] = False
|
||||
info['error'] = str(exc)
|
||||
return info
|
||||
|
||||
|
||||
def split_role(info):
|
||||
"""What a member of a set contributes: base / abi / code / resources.
|
||||
|
||||
The declared split name is consulted first (it is the authoritative label),
|
||||
then the actual contents, because a set pulled off a device is often named
|
||||
`Foo-arm64_v8a.apk` while its manifest still says `config.arm64_v8a`.
|
||||
"""
|
||||
if not info.get('readable'):
|
||||
return 'unreadable'
|
||||
if not info.get('split'):
|
||||
return 'base'
|
||||
name = info['split']
|
||||
tail = name.split('.', 1)[1] if '.' in name else name
|
||||
if info['abis'] or tail in ABI_SPLIT_NAMES:
|
||||
return 'abi'
|
||||
if name.startswith('config.'):
|
||||
if tail in DENSITY_SPLIT_NAMES:
|
||||
return 'density'
|
||||
return 'config'
|
||||
if info['dex']:
|
||||
return 'code'
|
||||
return 'split'
|
||||
|
||||
|
||||
def split_has_real_resources(info):
|
||||
"""True when folding this split into the base would require merging arsc."""
|
||||
if info.get('res'):
|
||||
return True
|
||||
return (info.get('arsc') or 0) > EMPTY_ARSC_BYTES
|
||||
|
||||
|
||||
def pick_base(items):
|
||||
"""The base is the member with no `split` attribute; ties break toward dex."""
|
||||
cands = [i for i in items if not i.get('split')]
|
||||
if not cands:
|
||||
return None, []
|
||||
cands.sort(key=lambda i: (not i['dex'], -i['size']))
|
||||
return cands[0], cands[1:]
|
||||
|
||||
|
||||
def split_report(items):
|
||||
"""Human-readable inventory of a set: the input to the route decision."""
|
||||
rows = []
|
||||
for i in items:
|
||||
role = split_role(i)
|
||||
extra = []
|
||||
if i['dex']:
|
||||
extra.append('dex=%s' % ','.join(i['dex']))
|
||||
if i['abis']:
|
||||
extra.append('libs=%s' % ','.join(i['abis']))
|
||||
if i['res']:
|
||||
extra.append('res=%d entries' % i['res'])
|
||||
if i['arsc'] is not None:
|
||||
extra.append('arsc=%d%s' % (i['arsc'], '' if i['arsc_stored'] else ' COMPRESSED'))
|
||||
if i['assets']:
|
||||
extra.append('assets=%d' % i['assets'])
|
||||
rows.append('%-10s %-40s %10d B split=%-18s %s'
|
||||
% (role, i['name'], i['size'], i.get('split') or '-',
|
||||
' '.join(extra)))
|
||||
return rows
|
||||
|
||||
|
||||
def plan_split_set(items):
|
||||
"""Decide merge vs resign for this set, and say why in one sentence each.
|
||||
|
||||
Returns (base, others, blockers, notes). `blockers` non-empty means merging
|
||||
is not legal without explicitly accepting a loss, and the message names the
|
||||
splits responsible.
|
||||
"""
|
||||
notes, blockers = [], []
|
||||
base, extras = pick_base(items)
|
||||
if base is None:
|
||||
return None, [], ['no base APK in the set (no member lacks a `split` '
|
||||
'attribute) -- this does not look like a split set'], notes
|
||||
others = [i for i in items if i is not base]
|
||||
for i in extras:
|
||||
notes.append('extra candidate without a `split` attribute: %s' % i['name'])
|
||||
pkgs = sorted({i['package'] for i in items if i.get('package')})
|
||||
if len(pkgs) > 1:
|
||||
notes.append('members disagree about the package name: %s -- a set must '
|
||||
'share one, so these files probably do not belong together'
|
||||
% ', '.join(pkgs))
|
||||
if base.get('is_split_required') == 'true':
|
||||
notes.append('base sets isSplitRequired=true: after a merge the platform '
|
||||
'will refuse to start it unless the installer still knows the '
|
||||
'set, so a merged build is only safe once that attribute is '
|
||||
'cleared')
|
||||
if not base['dex']:
|
||||
notes.append('base carries no classes*.dex (unusual: %s)'
|
||||
% (', '.join(base['dex']) or 'none'))
|
||||
carrying = [i for i in others if split_has_real_resources(i)]
|
||||
if carrying:
|
||||
blockers.append('merging is not legal for %d of %d splits: %s 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'
|
||||
% (len(carrying), len(others),
|
||||
', '.join(i.get('split') or i['name'] for i in carrying)))
|
||||
elif others:
|
||||
notes.append('every non-base split carries only code/native libraries, so '
|
||||
'a merged single APK is legal')
|
||||
fabis = [i for i in others if i['abis']]
|
||||
for i in fabis:
|
||||
notes.append('%s provides libs for %s -- keep only the ABIs the target '
|
||||
'device runs, selected with --abi'
|
||||
% (i.get('split') or i['name'], ','.join(i['abis'])))
|
||||
return base, others, blockers, notes
|
||||
|
||||
|
||||
def collect_split_inputs(args):
|
||||
"""Every APK the caller pointed at as part of one set (empty = not a set)."""
|
||||
if not (args.split_dir or args.split):
|
||||
return []
|
||||
paths = []
|
||||
if args.split_dir:
|
||||
paths += find_apks(os.path.abspath(args.split_dir))
|
||||
for p in (args.split or []):
|
||||
ap = os.path.abspath(p)
|
||||
paths += find_apks(ap) if os.path.isdir(ap) else [ap]
|
||||
if args.apk:
|
||||
ap = os.path.abspath(args.apk)
|
||||
if ap not in paths:
|
||||
paths.append(ap)
|
||||
# A set is a set: dedupe by real path so `--split-dir X --split X/base.apk`
|
||||
# does not try to merge an APK into itself.
|
||||
seen, out = set(), []
|
||||
for p in paths:
|
||||
key = os.path.normcase(p)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def abi_of_split(info):
|
||||
"""The ABI a split provides, from its lib directories or its split name."""
|
||||
if info['abis']:
|
||||
return info['abis'][0]
|
||||
name = (info.get('split') or '').split('.', 1)[-1]
|
||||
return name.replace('_', '-') if name in ABI_SPLIT_NAMES else None
|
||||
|
||||
|
||||
def merge_split_set(base, others, out_apk, allow_drop_resources=False, keep_abi=None):
|
||||
"""Fold a set into one standalone APK: base plus every foldable member.
|
||||
|
||||
Folded: `classes*.dex` (renumbered so the second dex becomes classes2.dex),
|
||||
`lib/**` and `assets/**`. Not folded: `res/**` and `resources.arsc`, because
|
||||
a correct resource merge rewrites the table, and a wrong one is invisible
|
||||
until the app renders.
|
||||
|
||||
Returns (stats, dropped) so the caller can report the cost out loud instead
|
||||
of shipping a silently downgraded build.
|
||||
"""
|
||||
plan = []
|
||||
taken = set()
|
||||
stats = {'dex': 0, 'libs': 0, 'assets': 0, 'conflicts': 0}
|
||||
dropped = []
|
||||
|
||||
with zipfile.ZipFile(base['path'], 'r') as z:
|
||||
for item in z.infolist():
|
||||
name = item.filename
|
||||
if item.is_dir() or is_signature_entry(name):
|
||||
continue
|
||||
taken.add(name)
|
||||
plan.append((name, item, z.read(name),
|
||||
name in STORE_ONLY or os.path.basename(name) in STORE_ONLY))
|
||||
base_dex = len([n for n in taken if re.match(r'^classes\d*\.dex$', n)])
|
||||
next_dex = base_dex + 1 # a base with no dex still wants classes.dex first
|
||||
|
||||
for info in others:
|
||||
if info['abis'] and keep_abi and keep_abi not in abi_of_split(info):
|
||||
dropped.append('%s (ABI %s, keeping %s)' % (info['name'], abi_of_split(info), keep_abi))
|
||||
continue
|
||||
if split_has_real_resources(info) and not allow_drop_resources:
|
||||
raise SystemExit(
|
||||
'error: refusing to merge %s -- it carries its own resources '
|
||||
'(%d res entries, arsc=%s bytes).\n'
|
||||
' Folding it in would require merging resources.arsc, which this '
|
||||
'script does not do.\n'
|
||||
' Either use --split-mode resign (recommended for a resource '
|
||||
'split), or pass --drop-split-resources to accept that the '
|
||||
'resources it owns are lost.'
|
||||
% (info['name'], info['res'], info['arsc']))
|
||||
with zipfile.ZipFile(info['path'], 'r') as z:
|
||||
for item in z.infolist():
|
||||
name = item.filename
|
||||
if item.is_dir() or is_signature_entry(name):
|
||||
continue
|
||||
if name == 'AndroidManifest.xml':
|
||||
continue # the base manifest already exists
|
||||
if name == 'resources.arsc' or name.startswith('res/'):
|
||||
dropped.append('%s: %s' % (info['name'], name))
|
||||
continue
|
||||
if re.match(r'^classes\d*\.dex$', name):
|
||||
new = 'classes%d.dex' % next_dex if next_dex > 1 else 'classes.dex'
|
||||
while new in taken:
|
||||
next_dex += 1
|
||||
new = 'classes%d.dex' % next_dex
|
||||
data = z.read(name)
|
||||
zi = zipfile.ZipInfo(new, date_time=(2024, 1, 1, 0, 0, 0))
|
||||
plan.append((new, zi, data, False))
|
||||
taken.add(new)
|
||||
stats['dex'] += 1
|
||||
log('[merge] + %s (%d bytes <- %s:%s)' % (new, len(data), info['name'], name))
|
||||
next_dex += 1
|
||||
continue
|
||||
data = z.read(name)
|
||||
if name in taken:
|
||||
existing = next((d for n, _i, d, _k in plan if n == name), None)
|
||||
if existing == data:
|
||||
continue
|
||||
stats['conflicts'] += 1
|
||||
log('[merge] CONFLICT %s: %s and the base both provide it with '
|
||||
'different content; keeping the base copy' % (name, info['name']))
|
||||
continue
|
||||
plan.append((name, item, data, name in STORE_ONLY))
|
||||
taken.add(name)
|
||||
if name.startswith('lib/'):
|
||||
stats['libs'] += 1
|
||||
elif name.startswith('assets/'):
|
||||
stats['assets'] += 1
|
||||
log('[merge] + %s (%d bytes <- %s)' % (name, len(data), info['name']))
|
||||
|
||||
_write_aligned_zip(out_apk, plan)
|
||||
return stats, dropped
|
||||
|
||||
|
||||
def resign_split_set(items, outdir, sign_one):
|
||||
"""Sign every member of a set with the same keystore, keeping the structure.
|
||||
|
||||
Each member is de-signed and rewritten as an aligned archive first, exactly
|
||||
like a single-APK repack -- a member whose `resources.arsc` is compressed or
|
||||
unaligned is refused by the installer on its own, and the error names the
|
||||
member, not the set.
|
||||
"""
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
scratch = os.path.join(outdir, '.unsigned')
|
||||
if os.path.isdir(scratch):
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
os.makedirs(scratch, exist_ok=True)
|
||||
outs = []
|
||||
for info in items:
|
||||
unsigned = os.path.join(scratch, info['name'])
|
||||
build_unsigned(info['path'], {}, unsigned)
|
||||
problems = check_alignment(unsigned)
|
||||
for p in problems:
|
||||
log('[resign] alignment problem in %s: %s' % (info['name'], p))
|
||||
signed = sign_one(unsigned, os.path.join(outdir, info['name']))
|
||||
outs.append((info, signed))
|
||||
log('[resign] %s -> %s (%d bytes)' % (info['name'], signed, os.path.getsize(signed)))
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
return outs
|
||||
|
||||
|
||||
def cert_fingerprints(apk, tools):
|
||||
"""SHA-256 of each signer certificate, as reported by apksigner."""
|
||||
apksigner = tools.get('apksigner')
|
||||
if not apksigner:
|
||||
return []
|
||||
cmd = signer_invocation(apksigner, tools) + ['verify', '--print-certs', apk]
|
||||
r = run(cmd)
|
||||
text = (r.stdout or '') + (r.stderr or '')
|
||||
return sorted({m.lower() for m in re.findall(r'SHA-256 digest:\s*([0-9a-fA-F:]+)', text)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Packing
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -422,20 +890,26 @@ def check_alignment(apk):
|
||||
|
||||
|
||||
def zip_report(apk):
|
||||
"""Entry summary, including the storage/alignment facts Android gates on."""
|
||||
"""Entry summary, including the storage/alignment facts Android gates on.
|
||||
|
||||
Only entries that MUST be aligned are judged on alignment. `AndroidManifest.xml`
|
||||
must be STORED but not aligned, and printing "NOT ALIGNED" next to it read as a
|
||||
failing build when it is the normal, correct layout.
|
||||
"""
|
||||
import struct
|
||||
rows = []
|
||||
with zipfile.ZipFile(apk, 'r') as z, open(apk, 'rb') as fh:
|
||||
for i in z.infolist():
|
||||
base = os.path.basename(i.filename)
|
||||
needs = (i.filename in STORE_ONLY or base in STORE_ONLY
|
||||
or _needs_alignment(i.filename))
|
||||
must_align = _needs_alignment(i.filename)
|
||||
needs = (i.filename in STORE_ONLY or base in STORE_ONLY or must_align)
|
||||
if needs:
|
||||
fh.seek(i.header_offset)
|
||||
lh = fh.read(30)
|
||||
nlen, elen = struct.unpack('<HH', lh[26:30])
|
||||
data_off = i.header_offset + 30 + nlen + elen
|
||||
verdict = 'aligned' if data_off % ALIGN == 0 else 'NOT ALIGNED'
|
||||
verdict = ('aligned' if data_off % ALIGN == 0
|
||||
else 'NOT ALIGNED') if must_align else '-'
|
||||
stored = 'STORED' if i.compress_type == 0 else \
|
||||
'COMPRESSED(method=%d)' % i.compress_type
|
||||
rows.append('%-40s %-22s offset=0x%-8x %s'
|
||||
@@ -449,6 +923,88 @@ def zip_report(apk):
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sign / verify
|
||||
# ---------------------------------------------------------------------------
|
||||
def signer_invocation(path, tools):
|
||||
"""Command prefix that runs apksigner, whatever shape it was shipped as.
|
||||
|
||||
A build-tools directory offers `apksigner.bat` (Windows), `apksigner` (a
|
||||
shell wrapper) or the bare `lib/apksigner.jar`. All three are legitimate
|
||||
values for --apksigner, and only the jar needs java in front of it. A bare
|
||||
shell wrapper does not run on Windows, which is why the jar is the value to
|
||||
pass there.
|
||||
"""
|
||||
if path.lower().endswith('.jar'):
|
||||
java = require(tools, 'java', 'run apksigner.jar')
|
||||
return [java, '-jar', path]
|
||||
return [path]
|
||||
|
||||
|
||||
def sign_with_apksigner(unsigned_apk, out_apk, ks, alias, password, tools):
|
||||
"""zipalign + apksigner (v1+v2+v3) -- the route that needs no uber-apk-signer.
|
||||
|
||||
Order is load-bearing: align first, sign second. apksigner adds signature
|
||||
entries without reshuffling the archive; the v1 (JAR) path emulates
|
||||
jarsigner, whose rewrite of the zip would destroy the alignment that Android
|
||||
R+ requires of `resources.arsc`.
|
||||
"""
|
||||
apksigner = require(tools, 'apksigner', 'sign the apk')
|
||||
src = unsigned_apk
|
||||
aligned = None
|
||||
if tools.get('zipalign'):
|
||||
aligned = unsigned_apk + '.aligned'
|
||||
r = run([tools['zipalign'], '-p', '-f', '4', unsigned_apk, aligned])
|
||||
log('[sign:zipalign] rc=%d' % r.returncode)
|
||||
if r.returncode != 0 or not os.path.exists(aligned):
|
||||
log(((r.stdout or '') + (r.stderr or '')).strip())
|
||||
raise SystemExit('error: zipalign failed; refusing to sign an '
|
||||
'unaligned build')
|
||||
src = aligned
|
||||
else:
|
||||
log('[sign:zipalign] skipped: zipalign not available; the archive written '
|
||||
'by this script is already 4-byte aligned (see the alignment gate)')
|
||||
try:
|
||||
cmd = signer_invocation(apksigner, tools) + [
|
||||
'sign', '--ks', ks, '--ks-key-alias', alias,
|
||||
'--ks-pass', 'pass:' + password, '--key-pass', 'pass:' + password,
|
||||
'--v1-signing-enabled', 'true', '--v2-signing-enabled', 'true',
|
||||
'--v3-signing-enabled', 'true', '--out', out_apk, src]
|
||||
r = run(cmd)
|
||||
log('[sign:apksigner] rc=%d' % r.returncode)
|
||||
log(((r.stdout or '') + (r.stderr or '')).strip())
|
||||
if r.returncode != 0 or not os.path.exists(out_apk):
|
||||
raise SystemExit('error: apksigner failed (output above)')
|
||||
finally:
|
||||
if aligned and os.path.exists(aligned):
|
||||
os.remove(aligned)
|
||||
return out_apk
|
||||
|
||||
|
||||
def choose_signer(args, tools):
|
||||
"""Pick the signing route: the jar when it exists, else apksigner directly.
|
||||
|
||||
The jar stays first so an existing workflow does not change behaviour. The
|
||||
apksigner branch exists because a machine can have a complete build-tools
|
||||
directory and no uber-apk-signer at all -- the previous single-route design
|
||||
made every signed build impossible there, with an error that named a jar the
|
||||
user never had.
|
||||
"""
|
||||
jar = args.signer_jar or os.environ.get('APK_SIGNER_JAR', 'uber-apk-signer.jar')
|
||||
kind = getattr(args, 'signer', 'auto')
|
||||
if kind in ('auto', 'jar') and os.path.exists(jar):
|
||||
return {'kind': 'jar', 'jar': jar}
|
||||
if kind == 'jar':
|
||||
raise SystemExit('error: --signer jar requested but %s does not exist'
|
||||
% jar)
|
||||
if tools.get('apksigner'):
|
||||
return {'kind': 'apksigner', 'path': tools['apksigner']}
|
||||
raise SystemExit(
|
||||
'error: no signing route available.\n'
|
||||
' * uber-apk-signer.jar not found at %s\n'
|
||||
' * apksigner not found on PATH either (pass --apksigner <path>; it may\n'
|
||||
' be apksigner.bat, an apksigner wrapper, or lib/apksigner.jar)\n'
|
||||
' A machine with Android build-tools and no uber-apk-signer is supported:\n'
|
||||
' give it --apksigner and this script aligns and signs on its own.' % jar)
|
||||
|
||||
|
||||
def sign_apk(unsigned_apk, workdir, ks, alias, password, tools, signer_jar):
|
||||
java = require(tools, 'java', 'run the signer jar')
|
||||
if not os.path.exists(signer_jar):
|
||||
@@ -498,8 +1054,9 @@ def verify_apk(apk, tools, signer_jar=None, expect_signed=True):
|
||||
# are meaningful.
|
||||
if tools.get('apksigner'):
|
||||
sig_checks += 1
|
||||
r = run([tools['apksigner'], 'verify', '--print-certs', '--verbose',
|
||||
'--min-sdk-version', '21', '--max-sdk-version', '34', apk])
|
||||
r = run(signer_invocation(tools['apksigner'], tools) + [
|
||||
'verify', '--print-certs', '--verbose',
|
||||
'--min-sdk-version', '21', '--max-sdk-version', '34', apk])
|
||||
log('[verify:apksigner] rc=%d\n%s' % (r.returncode, ((r.stdout or '') +
|
||||
(r.stderr or '')).strip()[:3000]))
|
||||
if r.returncode != 0:
|
||||
@@ -559,27 +1116,295 @@ def verify_apk(apk, tools, signer_jar=None, expect_signed=True):
|
||||
|
||||
|
||||
def fingerprint(apk, tools):
|
||||
if not tools.get('keytool'):
|
||||
return []
|
||||
r = run([tools['keytool'], '-printcert', '-jarfile', apk])
|
||||
return re.findall(r'(SHA1|SHA256):\s*([0-9A-F:]+)', r.stdout or '')
|
||||
"""Signer fingerprints. keytool when there is one, apksigner otherwise.
|
||||
|
||||
keytool is frequently absent (it lives in a JDK bin directory that is not on
|
||||
PATH), and reporting no fingerprint at all reads as "unsigned". apksigner is
|
||||
the tool that actually signed the file, so its answer is the authoritative
|
||||
one anyway.
|
||||
"""
|
||||
if tools.get('keytool'):
|
||||
r = run([tools['keytool'], '-printcert', '-jarfile', apk])
|
||||
found = re.findall(r'(SHA1|SHA256):\s*([0-9A-F:]+)', r.stdout or '')
|
||||
if found:
|
||||
return found
|
||||
return [('SHA-256', f) for f in cert_fingerprints(apk, tools)]
|
||||
|
||||
|
||||
def resolve_keystore(args, tools):
|
||||
"""Return (keystore path, password), creating the keystore on first use."""
|
||||
if not args.ks:
|
||||
raise SystemExit(
|
||||
"error: --ks is required when signing (there is no default keystore).\n"
|
||||
" e.g. --ks work/out/release.keystore")
|
||||
keytool = require(tools, 'keytool', 'create the keystore on first use')
|
||||
ks = os.path.abspath(args.ks)
|
||||
return ks, ensure_keystore(ks, args.ks_alias, args.ks_pass, keytool)
|
||||
|
||||
|
||||
def sign_one_artifact(unsigned_apk, out_path, args, tools, signer, ks, password, workdir):
|
||||
"""Sign one unsigned APK through whichever route choose_signer() picked."""
|
||||
if signer['kind'] == 'jar':
|
||||
signed = sign_apk(unsigned_apk, workdir, ks, args.ks_alias, password,
|
||||
tools, signer['jar'])
|
||||
if os.path.abspath(signed) != os.path.abspath(out_path):
|
||||
shutil.copy2(signed, out_path)
|
||||
else:
|
||||
sign_with_apksigner(unsigned_apk, out_path, ks, args.ks_alias, password, tools)
|
||||
return out_path
|
||||
|
||||
|
||||
def scratch_dir(args, fallback):
|
||||
workdir = os.path.abspath(args.workdir) if args.workdir else fallback
|
||||
if not workdir:
|
||||
workdir = os.getcwd()
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
return workdir
|
||||
|
||||
|
||||
def run_split_mode(args, tools):
|
||||
"""Handle --split-dir / --split: inventory the set, pick a legal route, act.
|
||||
|
||||
Nothing here guesses which route "should" work: the set is inspected, merge
|
||||
legality is decided from what the splits actually carry, and the reason is
|
||||
printed before anything is written.
|
||||
"""
|
||||
items = [inspect_apk(p) for p in collect_split_inputs(args)]
|
||||
broken = [i['name'] for i in items if not i['readable']]
|
||||
if broken:
|
||||
raise SystemExit('error: not readable as an APK: %s' % ', '.join(broken))
|
||||
|
||||
log('== split set: %d apk(s), %d bytes total'
|
||||
% (len(items), sum(i['size'] for i in items)))
|
||||
for row in split_report(items):
|
||||
log(' ' + row)
|
||||
|
||||
base, others, blockers, notes = plan_split_set(items)
|
||||
if base is None or not others:
|
||||
for b in blockers:
|
||||
log('[plan] BLOCKED: %s' % b)
|
||||
if base is not None:
|
||||
log('[plan] %s is the only member -- a single APK: run without '
|
||||
'--split/--split-dir' % base['name'])
|
||||
return 2
|
||||
log('[plan] base = %s (package=%s)' % (base['name'], base.get('package')))
|
||||
for n in notes:
|
||||
log('[plan] note: %s' % n)
|
||||
for b in blockers:
|
||||
log('[plan] MERGE BLOCKED: %s' % b)
|
||||
if not blockers:
|
||||
log('[plan] merge = legal: %d member(s) fold into the base' % len(others))
|
||||
log('[plan] resign = legal: one keystore for all %d members, install the set with'
|
||||
% len(items))
|
||||
log('[plan] adb install-multiple -r %s'
|
||||
% ' '.join(i['name'] for i in items))
|
||||
|
||||
mode = args.split_mode
|
||||
if mode == 'auto':
|
||||
if blockers:
|
||||
mode = 'resign'
|
||||
elif (args.out or '').lower().endswith('.apk'):
|
||||
mode = 'merge'
|
||||
else:
|
||||
mode = 'resign'
|
||||
log('[plan] --split-mode auto resolved to: %s' % mode)
|
||||
|
||||
if mode == 'analyze':
|
||||
log('[result] analyze only: nothing was written. Pass --split-mode '
|
||||
'merge|resign|auto to act on this set.')
|
||||
return 0
|
||||
|
||||
if mode == 'merge':
|
||||
return _split_merge(args, tools, base, others)
|
||||
|
||||
outdir = args.split_out_dir or (os.path.abspath(args.out) if args.out else None)
|
||||
if not outdir:
|
||||
raise SystemExit('error: --split-mode resign needs --split-out-dir <dir> '
|
||||
'(or --out, which is then treated as a directory).')
|
||||
outdir = os.path.abspath(outdir)
|
||||
log('[resign] output directory: %s' % outdir)
|
||||
workdir = scratch_dir(args, os.path.join(outdir, '.work'))
|
||||
signer = None
|
||||
ks = password = None
|
||||
if not args.no_sign:
|
||||
signer = choose_signer(args, tools)
|
||||
ks, password = resolve_keystore(args, tools)
|
||||
log('[resign] signer route: %s' % signer['kind'])
|
||||
|
||||
if args.no_sign:
|
||||
def sign_member(unsigned, out_path):
|
||||
shutil.copy2(unsigned, out_path)
|
||||
return out_path
|
||||
else:
|
||||
def sign_member(unsigned, out_path):
|
||||
return sign_one_artifact(unsigned, out_path, args, tools, signer,
|
||||
ks, password, workdir)
|
||||
|
||||
outs = resign_split_set(items, outdir, sign_member)
|
||||
|
||||
fps = set()
|
||||
ok_all = True
|
||||
signer_jar = signer['jar'] if signer and signer['kind'] == 'jar' else None
|
||||
for info, path in outs:
|
||||
if not verify_apk(path, tools, signer_jar, expect_signed=not args.no_sign):
|
||||
ok_all = False
|
||||
for f in cert_fingerprints(path, tools):
|
||||
fps.add(f)
|
||||
log('[cert] %s %s' % (info['name'], f))
|
||||
|
||||
if not args.no_sign:
|
||||
if len(fps) == 1:
|
||||
log('[result] OK: %d members, one certificate (sha256 %s)'
|
||||
% (len(outs), sorted(fps)[0]))
|
||||
else:
|
||||
log('[result] CHECK-FAILED: %d distinct certificates across the set '
|
||||
'(a mismatch is exactly what an installer rejects): %s'
|
||||
% (len(fps), ', '.join(sorted(fps))))
|
||||
ok_all = False
|
||||
else:
|
||||
log('[result] UNSIGNED (--no-sign): de-signed and aligned set, not installable '
|
||||
'as-is')
|
||||
log('[next] adb install-multiple -r %s'
|
||||
% ' '.join(os.path.join(outdir, i['name']) for i in items))
|
||||
return 0 if ok_all else 2
|
||||
|
||||
|
||||
def _split_merge(args, tools, base, others):
|
||||
"""The merge half of run_split_mode (kept separate to stay readable)."""
|
||||
if not args.out:
|
||||
raise SystemExit('error: --split-mode merge needs --out <merged.apk>')
|
||||
out = os.path.abspath(args.out)
|
||||
folder = os.path.dirname(out)
|
||||
if folder:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
unsigned = out + '.unsigned'
|
||||
if os.path.exists(unsigned):
|
||||
os.remove(unsigned)
|
||||
|
||||
stats, dropped = merge_split_set(base, others, unsigned,
|
||||
allow_drop_resources=args.drop_split_resources,
|
||||
keep_abi=args.abi)
|
||||
log('[merge] folded: dex=%d libs=%d assets=%d conflicts=%d'
|
||||
% (stats['dex'], stats['libs'], stats['assets'], stats['conflicts']))
|
||||
if dropped:
|
||||
log('[merge] DROPPED %d entry/ies. This is a DOWNGRADED build -- say so when '
|
||||
'you deliver it:' % len(dropped))
|
||||
for d in dropped[:40]:
|
||||
log(' - %s' % d)
|
||||
if len(dropped) > 40:
|
||||
log(' ... and %d more' % (len(dropped) - 40))
|
||||
|
||||
for row in zip_report(unsigned):
|
||||
log('[zip] ' + row)
|
||||
problems = check_alignment(unsigned)
|
||||
if problems:
|
||||
log('== ALIGNMENT/STORAGE GATE FAILED -- do not ship this build:')
|
||||
for p in problems:
|
||||
log(' - %s' % p)
|
||||
else:
|
||||
log('== alignment gate: resources.arsc STORED and 4-byte aligned (OK)')
|
||||
|
||||
if args.no_sign:
|
||||
shutil.move(unsigned, out)
|
||||
log('[out] %s (%d bytes)' % (out, os.path.getsize(out)))
|
||||
log('[result] UNSIGNED (--no-sign): inspection only, not installable as-is')
|
||||
return 0
|
||||
|
||||
workdir = scratch_dir(args, folder or os.getcwd())
|
||||
signer = choose_signer(args, tools)
|
||||
ks, password = resolve_keystore(args, tools)
|
||||
log('[merge] signer route: %s' % signer['kind'])
|
||||
sign_one_artifact(unsigned, out, args, tools, signer, ks, password, workdir)
|
||||
os.remove(unsigned)
|
||||
log('[out] %s (%d bytes)' % (out, os.path.getsize(out)))
|
||||
|
||||
ok = verify_apk(out, tools,
|
||||
signer['jar'] if signer['kind'] == 'jar' else None)
|
||||
for algo, val in fingerprint(out, tools):
|
||||
log('[cert] %s %s' % (algo, val))
|
||||
log('[result] %s' % ('OK' if ok else 'CHECK-FAILED'))
|
||||
log('[next] adb install -r %s (one file, no set to keep together)' % out)
|
||||
return 0 if ok else 2
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
description='Repack an APK with replaced dex files, drop only the signature '
|
||||
'entries, re-sign and verify.')
|
||||
ap.add_argument('--apk', required=True, help='source apk to use as the template')
|
||||
ap.add_argument('--out', required=True, help='output apk path')
|
||||
'entries, re-sign and verify.\n'
|
||||
'Also handles SPLIT APK / App Bundle sets (base.apk plus\n'
|
||||
'split_config.*.apk): inventory them, then either sign the whole\n'
|
||||
'set with one keystore (installable with `pm install-multiple`)\n'
|
||||
'or fold the code/native members into one standalone APK.',
|
||||
epilog='usage examples:\n'
|
||||
' # single APK (unchanged behaviour)\n'
|
||||
' python repack.py --apk original.apk --dexdir dex/ --out out.apk '
|
||||
'--ks ks.jks\n'
|
||||
'\n'
|
||||
' # inspect a split set and get the route decision (writes nothing)\n'
|
||||
' python repack.py --split-dir pulled_set/ --split-mode analyze\n'
|
||||
'\n'
|
||||
' # resign every member with ONE keystore -> pm install-multiple\n'
|
||||
' python repack.py --split-dir pulled_set/ --split-mode resign \\\n'
|
||||
' --split-out-dir signed_set/ --ks ks.jks \\\n'
|
||||
' --apksigner /path/to/lib/apksigner.jar\n'
|
||||
'\n'
|
||||
' # fold code/native splits into one standalone APK\n'
|
||||
' python repack.py --split-dir pulled_set/ --split-mode merge \\\n'
|
||||
' --out merged.apk --ks ks.jks\n'
|
||||
'\n'
|
||||
' # no uber-apk-signer on this machine: --apksigner replaces it\n'
|
||||
' python repack.py --apk app.apk --out out.apk --ks ks.jks \\\n'
|
||||
' --apksigner /path/to/lib/apksigner.jar --zipalign '
|
||||
'/path/to/zipalign\n'
|
||||
'\n'
|
||||
'split route decision (enforced, not guessed):\n'
|
||||
' merge is legal only when every non-base split carries code or\n'
|
||||
' native libraries. A split that carries its own resources\n'
|
||||
' cannot be folded in without merging resources.arsc, which\n'
|
||||
' needs a resource compiler; pass --drop-split-resources to\n'
|
||||
' accept losing exactly those resources.\n'
|
||||
' resign always works: one keystore, v1+v2+v3 on every member.\n')
|
||||
ap.add_argument('--apk', help='source apk to use as the template (in split mode: '
|
||||
'an explicit base; optional)')
|
||||
ap.add_argument('--out', help='output apk path (in --split-mode resign: the '
|
||||
'output directory, or use --split-out-dir)')
|
||||
ap.add_argument('--dexdir', help='directory of classes*.dex to swap in')
|
||||
ap.add_argument('--dex', action='append', default=[],
|
||||
help='name=path, repeatable, e.g. classes8.dex=work/patch/classes8.dex')
|
||||
ap.add_argument('--split-dir', default=None,
|
||||
help='directory holding a split set (searched recursively for '
|
||||
'*.apk); every member must share one package name')
|
||||
ap.add_argument('--split', action='append', default=[],
|
||||
help='one split member, repeatable; a directory is searched '
|
||||
'recursively. Supplying any --split/--split-dir switches '
|
||||
'this script into split-set mode')
|
||||
ap.add_argument('--split-mode', choices=('analyze', 'auto', 'merge', 'resign'),
|
||||
default='analyze',
|
||||
help='analyze (default): print the inventory and the route '
|
||||
'decision, write nothing. auto: merge when that is legal '
|
||||
'and --out ends in .apk, else resign. merge: fold into one '
|
||||
'standalone APK (--out). resign: sign every member with one '
|
||||
'keystore (--split-out-dir)')
|
||||
ap.add_argument('--split-out-dir', default=None,
|
||||
help='output directory for --split-mode resign')
|
||||
ap.add_argument('--drop-split-resources', action='store_true',
|
||||
help='merge mode: explicitly allow dropping splits whose '
|
||||
'resources cannot be merged (produces a downgraded build)')
|
||||
ap.add_argument('--abi', default=None,
|
||||
help='merge mode: keep only the ABI split matching this ABI '
|
||||
'(e.g. arm64-v8a). Compare with the device via '
|
||||
'`getprop ro.product.cpu.abilist`; split names use '
|
||||
'underscores (arm64_v8a), lib/ directories use hyphens')
|
||||
ap.add_argument('--workdir', default=None,
|
||||
help='scratch directory (default: next to --out)')
|
||||
ap.add_argument('--ks', default=None, help='keystore path (created on first use)')
|
||||
ap.add_argument('--ks-alias', default='apkreverse', help='keystore alias')
|
||||
ap.add_argument('--ks-pass', default=None,
|
||||
help='keystore password; default: <ks>.pass.txt, else generated')
|
||||
ap.add_argument('--signer', choices=('auto', 'jar', 'apksigner'), default='auto',
|
||||
help='signing route. auto (default): uber-apk-signer jar when it '
|
||||
'exists, otherwise zipalign+apksigner directly')
|
||||
ap.add_argument('--signer-jar', default=None,
|
||||
help='jar used for zipalign+sign (default: $APK_SIGNER_JAR or '
|
||||
'uber-apk-signer.jar)')
|
||||
@@ -588,25 +1413,32 @@ def main():
|
||||
ap.add_argument('--keytool', default=None, help='path to keytool (default: from PATH)')
|
||||
ap.add_argument('--jarsigner', default=None, help='path to jarsigner (default: from PATH)')
|
||||
ap.add_argument('--zipalign', default=None, help='path to zipalign (default: from PATH)')
|
||||
ap.add_argument('--apksigner', default=None, help='path to apksigner (default: from PATH)')
|
||||
ap.add_argument('--apksigner', default=None,
|
||||
help='path to apksigner (default: from PATH). Accepts '
|
||||
'apksigner.bat, an apksigner wrapper, or lib/apksigner.jar')
|
||||
args = ap.parse_args()
|
||||
|
||||
tools = resolve_tools(args)
|
||||
signer_jar = args.signer_jar or os.environ.get('APK_SIGNER_JAR', 'uber-apk-signer.jar')
|
||||
|
||||
split_mode = bool(args.split_dir or args.split)
|
||||
if not split_mode:
|
||||
if not args.apk:
|
||||
raise SystemExit('error: --apk is required (or supply --split/--split-dir '
|
||||
'for a split set).')
|
||||
if not args.out:
|
||||
raise SystemExit('error: --out is required.')
|
||||
|
||||
if split_mode:
|
||||
return run_split_mode(args, tools)
|
||||
|
||||
apk = os.path.abspath(args.apk)
|
||||
out = os.path.abspath(args.out)
|
||||
if not os.path.exists(apk):
|
||||
raise SystemExit('missing apk: %s' % apk)
|
||||
|
||||
# Default the scratch dir next to --out; never to a machine-specific path.
|
||||
workdir = os.path.abspath(args.workdir) if args.workdir else os.path.dirname(out)
|
||||
if not workdir:
|
||||
workdir = os.getcwd()
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
out_folder = os.path.dirname(out)
|
||||
if out_folder:
|
||||
os.makedirs(out_folder, exist_ok=True)
|
||||
workdir = scratch_dir(args, os.path.dirname(out))
|
||||
|
||||
repl = collect_replacement_dex(args.dexdir, args.dex)
|
||||
log('[in ] %s (%d bytes)' % (apk, os.path.getsize(apk)))
|
||||
@@ -622,19 +1454,14 @@ def main():
|
||||
for row in zip_report(unsigned):
|
||||
log('[zip] ' + row)
|
||||
|
||||
signer = None
|
||||
if args.no_sign:
|
||||
shutil.copy2(unsigned, out)
|
||||
else:
|
||||
if not args.ks:
|
||||
raise SystemExit(
|
||||
"error: --ks is required when signing (there is no default keystore).\n"
|
||||
" e.g. --ks work/out/release.keystore")
|
||||
keytool = require(tools, 'keytool', 'create the keystore on first use')
|
||||
password = ensure_keystore(os.path.abspath(args.ks), args.ks_alias,
|
||||
args.ks_pass, keytool)
|
||||
signed = sign_apk(unsigned, workdir, os.path.abspath(args.ks), args.ks_alias,
|
||||
password, tools, signer_jar)
|
||||
shutil.copy2(signed, out)
|
||||
signer = choose_signer(args, tools)
|
||||
log('[sign] route: %s' % signer['kind'])
|
||||
ks, password = resolve_keystore(args, tools)
|
||||
sign_one_artifact(unsigned, out, args, tools, signer, ks, password, workdir)
|
||||
|
||||
log('[out] %s (%d bytes)' % (out, os.path.getsize(out)))
|
||||
log('== zip layout of final apk')
|
||||
@@ -650,7 +1477,9 @@ def main():
|
||||
else:
|
||||
log('== alignment gate: resources.arsc STORED and 4-byte aligned (OK)')
|
||||
|
||||
ok = verify_apk(out, tools, signer_jar, expect_signed=not args.no_sign)
|
||||
ok = verify_apk(out, tools,
|
||||
signer['jar'] if signer and signer['kind'] == 'jar' else None,
|
||||
expect_signed=not args.no_sign)
|
||||
if not args.no_sign:
|
||||
for algo, val in fingerprint(out, tools):
|
||||
log('[cert] %s %s' % (algo, val))
|
||||
|
||||
@@ -11,13 +11,29 @@ The same app launched normally, after Frida has left, renders correctly.
|
||||
|
||||
The fix is an ordering rather than a different hook:
|
||||
|
||||
1. spawn and attach,
|
||||
2. let the probe write its patch into memory (``Memory.patchCode``),
|
||||
3. **detach** -- the write is a plain memory write and survives; ``Interceptor``
|
||||
hooks go away with the session, which is usually what you want for an
|
||||
observation run because it removes the instrumentation from the picture,
|
||||
1. spawn (`device.spawn()` leaves the target **paused**),
|
||||
2. attach and load the probe,
|
||||
3. resume, then **wait for the probe to report `PATCHED`** and only detach after
|
||||
that -- the write is a plain memory write and survives; ``Interceptor`` hooks go
|
||||
away with the session, which is usually what you want for an observation run
|
||||
because it removes the instrumentation from the picture,
|
||||
4. start the Activity normally and capture.
|
||||
|
||||
**The two orderings that do not work, both measured on MASTG UnCrackable-Level3
|
||||
(``libfoo.so`` self-destruct, ``goodbyev`` at ``base+0x3080``):**
|
||||
|
||||
- *Detach immediately after ``script.load()``.* The probe's ``Process.findModuleByName``
|
||||
poll has not seen the library yet, so the write never happens and the target kills
|
||||
itself on its own schedule. The measured run reported ``patched=False`` and the
|
||||
target was gone by the first capture.
|
||||
- *Hold the target paused until ``PATCHED``.* While the process is frozen its
|
||||
libraries are not mapped, so the poll can never succeed: 15 s paused produced no
|
||||
module, and ``libfoo.so base=0x764c0aa000`` appeared 0.11 s after resume.
|
||||
|
||||
So the target must be running for the patch to be possible at all, and the time
|
||||
between resume and the write is race time, not slack. Report ``patched=False`` loudly
|
||||
rather than presenting an unpatched run as a result.
|
||||
|
||||
What the probe may contain
|
||||
--------------------------
|
||||
Anything self-contained. A minimal probe that only neutralises a death site and
|
||||
@@ -63,7 +79,16 @@ def build_parser():
|
||||
p.add_argument("--serial", default=None, help="adb device serial (needed when several are online)")
|
||||
p.add_argument("--adb", default=DEFAULT_ADB, help="path to adb (default: from PATH)")
|
||||
p.add_argument("--wait-patched", type=float, default=15.0,
|
||||
help="seconds to wait for the probe to report PATCHED (default %(default)s)")
|
||||
help="seconds to wait for the probe to report PATCHED after resume; "
|
||||
"the detach happens only when it arrives (default %(default)s)")
|
||||
p.add_argument("--rpc-timeout", type=float, default=20.0,
|
||||
help="seconds to bound each frida RPC (attach / load), so a dead "
|
||||
"device server fails instead of hanging (default %(default)s)")
|
||||
p.add_argument("--playground", default=None,
|
||||
help="package to ask for the launcher activity when --activity is "
|
||||
"omitted (default: --package)")
|
||||
p.add_argument("--launch-timeout", type=float, default=30.0,
|
||||
help="seconds to bound the post-detach `am start` (default %(default)s)")
|
||||
p.add_argument("--detach-settle", type=float, default=3.0,
|
||||
help="pause after detach, before launching (default %(default)s)")
|
||||
p.add_argument("--captures", type=int, default=3, help="number of screenshots (default %(default)s)")
|
||||
@@ -86,6 +111,25 @@ def root_shell(cmd, adb, serial, timeout=200):
|
||||
return adb_run(["shell", "su -c '%s'" % cmd], adb, serial, timeout)
|
||||
|
||||
|
||||
def resolve_launcher(adb, serial, arg_activity, package, playground=None):
|
||||
"""Return the component to start, asking the device when none was given.
|
||||
|
||||
`am start <package>` alone is not accepted on every ROM, and starting nothing
|
||||
leaves the spawned process dead with the screen showing whatever was there
|
||||
before -- which reads exactly like "the app died". Ask the package manager.
|
||||
"""
|
||||
if arg_activity:
|
||||
return arg_activity
|
||||
pkg = playground or package
|
||||
r = adb_run(["shell", "cmd", "package", "resolve-activity", "--brief", pkg], adb, serial)
|
||||
for line in (r.stdout or "").splitlines():
|
||||
line = line.strip()
|
||||
if "/" in line and not line.startswith("priority") and "No activity" not in line:
|
||||
if line.count("/") == 1 and not line.startswith("Starting"):
|
||||
return line
|
||||
return None
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
@@ -95,6 +139,7 @@ def main(argv=None):
|
||||
return 2
|
||||
|
||||
patched = {"done": False}
|
||||
failed = {"done": False}
|
||||
|
||||
def on_message(msg, data):
|
||||
if msg.get("type") == "send":
|
||||
@@ -102,6 +147,8 @@ def main(argv=None):
|
||||
print("[MSG] %s" % payload, flush=True)
|
||||
if payload == "PATCHED":
|
||||
patched["done"] = True
|
||||
elif payload == "PATCHFAIL":
|
||||
failed["done"] = True
|
||||
elif msg.get("type") == "error":
|
||||
print("[ERR] %s" % (msg.get("stack") or msg.get("description")), flush=True)
|
||||
else:
|
||||
@@ -112,27 +159,47 @@ def main(argv=None):
|
||||
|
||||
dev = frida.get_device_manager().add_remote_device(args.frida_host)
|
||||
pid = dev.spawn([args.package])
|
||||
print("[i] spawned pid=%d" % pid, flush=True)
|
||||
print("[i] spawned pid=%d (paused)" % pid, flush=True)
|
||||
|
||||
session = dev.attach(pid)
|
||||
script = session.create_script(open(args.js, encoding="utf-8").read())
|
||||
script.on("message", on_message)
|
||||
script.load()
|
||||
|
||||
# Resume, then wait for the probe to report. Holding the target paused until
|
||||
# PATCHED does NOT work for a probe that waits on a module: while the process is
|
||||
# frozen its libraries are not mapped, so Process.findModuleByName() never
|
||||
# returns. Measured on MASTG UnCrackable-Level3: 15 s paused -> no module, then
|
||||
# "libfoo.so base=0x764c0aa000" 0.11 s after resume. What matters is that we do
|
||||
# NOT detach before the write has landed, and that we report patched=False rather
|
||||
# than pretending the target was patched.
|
||||
dev.resume(pid)
|
||||
print("[i] resumed pid=%d" % pid, flush=True)
|
||||
|
||||
deadline = time.time() + args.wait_patched
|
||||
while time.time() < deadline and not patched["done"]:
|
||||
while time.time() < deadline and not patched["done"] and not failed["done"]:
|
||||
time.sleep(0.2)
|
||||
if not patched["done"]:
|
||||
print("[!] the probe did not report PATCHED in %.1fs. Detaching anyway -- the "
|
||||
"target runs UNPATCHED, so nothing about this run tests the probe."
|
||||
% args.wait_patched, flush=True)
|
||||
print("[i] patched=%s" % patched["done"], flush=True)
|
||||
|
||||
time.sleep(1.0)
|
||||
time.sleep(0.5)
|
||||
session.detach()
|
||||
print("[i] detached (memory writes persist, hooks are gone)", flush=True)
|
||||
time.sleep(args.detach_settle)
|
||||
|
||||
if args.activity:
|
||||
out = adb_run(["shell", "am start -n %s" % args.activity], args.adb, args.serial)
|
||||
print("[i] am start: %s" % ((out.stdout or out.stderr or "").strip()), flush=True)
|
||||
component = resolve_launcher(args.adb, args.serial, args.activity, args.package,
|
||||
args.playground)
|
||||
if component:
|
||||
out = adb_run(["shell", "am start -n %s" % component], args.adb, args.serial,
|
||||
timeout=args.launch_timeout)
|
||||
print("[i] am start %s: %s" % (component,
|
||||
(out.stdout or out.stderr or "").strip()), flush=True)
|
||||
else:
|
||||
print("[!] no launcher component resolved; the spawned process may be dead. "
|
||||
"Pass --activity pkg/.Activity.", flush=True)
|
||||
|
||||
for i in range(max(1, args.captures)):
|
||||
time.sleep(args.interval)
|
||||
|
||||
@@ -43,6 +43,16 @@
|
||||
* exec = per-instruction (ENORMOUS; only for a few hundred
|
||||
* instructions around a known point; off by default);
|
||||
* ret = return edges (off by default).
|
||||
* excludeModules: modules Stalker must not translate (see the CONFIG comment).
|
||||
* Followed through libc/libart, an arm64 device pays a large
|
||||
* multiplier and the trace becomes a trace of the OS. Emitted
|
||||
* as one `EXCL excluded=N/M [names]` line before the follow --
|
||||
* read it, because it states what the trace was protected by.
|
||||
* A module not yet loaded cannot be excluded; exclusion is
|
||||
* best-effort and applies to modules already mapped.
|
||||
* zeroEventWarnMs: emits `WARN zero events ...` when a follow delivered
|
||||
* nothing by then, so a dead pipeline is not mistaken for
|
||||
* "the code did not run".
|
||||
*
|
||||
* HOW TO RUN
|
||||
* ----------
|
||||
@@ -102,6 +112,25 @@ var CONFIG = {
|
||||
maxBlocks: 100000, // stop recording past this many reported events
|
||||
autoStart: true, // false = arm triggers but wait for rpc start()
|
||||
|
||||
// Modules Stalker must NOT translate, resolved by name at follow time.
|
||||
// Cost is the reason this exists. A followed thread whose execution runs
|
||||
// through libc/libart pays a large multiplier on arm64 (community reports
|
||||
// 20-50x) and drags the whole device down with it, and a hot library
|
||||
// reached through a follow/unfollow cycle is where target processes have
|
||||
// died. Excluding the system libraries you are not studying is the
|
||||
// difference between a trace of your target and a trace of the OS.
|
||||
// The target module is never excluded, even if its name is listed here.
|
||||
excludeModules: [
|
||||
'libc.so', 'libm.so', 'libdl.so', 'libc++.so', 'libc++_shared.so',
|
||||
'libart.so', 'libartbase.so', 'libnativehelper.so',
|
||||
'libutils.so', 'libbinder.so', 'libcutils.so', 'libbase.so',
|
||||
'libui.so', 'libgui.so', 'libinput.so', 'libhwui.so', 'libskia.so',
|
||||
'libEGL.so', 'libGLESv2.so', 'libvulkan.so', 'libandroid.so',
|
||||
'liblog.so', 'libziparchive.so', 'libz.so'
|
||||
],
|
||||
excludeModulesExtra: [], // your own: 'libfoo.so', or 'libfoo.so+0x1000' for a sub-range
|
||||
zeroEventWarnMs: 1500, // warn when a follow has produced nothing by then
|
||||
|
||||
events: {
|
||||
compile: true, // first translation of each block (skeleton)
|
||||
block: true, // every executed block (weights)
|
||||
@@ -224,6 +253,40 @@ function onStalkEvents(events) {
|
||||
flush(true);
|
||||
}
|
||||
|
||||
/* Exclude every configured module that is already mapped. Best-effort by
|
||||
* design: a module that is not loaded yet cannot be excluded, so the count
|
||||
* emitted here is what the trace is actually protected by -- read it before
|
||||
* interpreting a bad trace. Exclusion must happen before follow(). */
|
||||
function applyExclusions() {
|
||||
var names = (CONFIG.excludeModules || []).concat(CONFIG.excludeModulesExtra || []);
|
||||
var excluded = [], attempted = 0, missing = [];
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
var spec = names[i];
|
||||
if (!spec) continue;
|
||||
var plus = spec.indexOf('+');
|
||||
var name = plus > 0 ? spec.substring(0, plus) : spec;
|
||||
if (state.target && name === state.target.name) continue; // never the target
|
||||
var mod = null;
|
||||
try { mod = Process.findModuleByName(name); } catch (e) { mod = null; }
|
||||
if (!mod) { missing.push(name); continue; }
|
||||
attempted++;
|
||||
try {
|
||||
if (plus > 0) {
|
||||
var off = parseInt(spec.substring(plus + 1), 16) || 0;
|
||||
Stalker.exclude({ base: mod.base.add(off), size: mod.size - off });
|
||||
} else {
|
||||
Stalker.exclude(mod);
|
||||
}
|
||||
excluded.push(name);
|
||||
} catch (e) {
|
||||
emit('EXCL-FAIL', spec + ': ' + e);
|
||||
}
|
||||
}
|
||||
emit('EXCL', 'excluded=' + excluded.length + '/' + attempted +
|
||||
' [' + excluded.join(',') + ']' +
|
||||
(missing.length ? ' not-loaded=' + missing.length : ''));
|
||||
}
|
||||
|
||||
function beginFollow(tid, why) {
|
||||
if (state.following) return;
|
||||
if (!state.target) {
|
||||
@@ -237,6 +300,8 @@ function beginFollow(tid, why) {
|
||||
line('MOD ' + state.target.name + ' base=' + state.target.base +
|
||||
' size=' + state.target.size + ' path=' + state.target.path);
|
||||
|
||||
applyExclusions();
|
||||
|
||||
Stalker.follow(tid, {
|
||||
events: {
|
||||
call: CONFIG.events.call,
|
||||
@@ -248,6 +313,20 @@ function beginFollow(tid, why) {
|
||||
onReceive: onStalkEvents
|
||||
});
|
||||
|
||||
/* A zero-event trace has two very different meanings -- "nothing executed"
|
||||
* and "the pipeline never delivered" -- and the log alone cannot tell them
|
||||
* apart. This warning exists so the second reading is the default one. */
|
||||
if (CONFIG.zeroEventWarnMs > 0) {
|
||||
state.warnTimer = setTimeout(function () {
|
||||
if (state.following && state.nBB === 0 && state.nBLK === 0) {
|
||||
emit('WARN', 'zero events ' + CONFIG.zeroEventWarnMs +
|
||||
'ms after follow (blocks=0 blk=0 calls=0) -- the pipeline is ' +
|
||||
'NOT proven; do not report this as "the code did not run". ' +
|
||||
'Control: follow a thread running a known loop first.');
|
||||
}
|
||||
}, CONFIG.zeroEventWarnMs);
|
||||
}
|
||||
|
||||
if (CONFIG.followMs > 0) {
|
||||
state.timer = setTimeout(function () { endFollow('timeout'); },
|
||||
CONFIG.followMs);
|
||||
@@ -258,6 +337,7 @@ function endFollow(reason) {
|
||||
if (!state.following) return;
|
||||
state.following = false;
|
||||
if (state.timer !== null) { clearTimeout(state.timer); state.timer = null; }
|
||||
if (state.warnTimer) { clearTimeout(state.warnTimer); state.warnTimer = null; }
|
||||
try { Stalker.flush(); } catch (e) { /* already gone */ }
|
||||
try { Stalker.unfollow(state.followTid); } catch (e) { /* already gone */ }
|
||||
flush(true);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
# Benchmark set — the regression baseline for this skill
|
||||
|
||||
This file is a **test matrix**, not a sample collection. It answers one question per row: *when an
|
||||
agent meets this shape of target, does the route this repository documents actually work, and what
|
||||
is the strength of the evidence?* Every claim in the `Result` column must be reproducible from the
|
||||
`Target` column plus the named script — nothing here is a summary of what "should" happen.
|
||||
|
||||
**Samples are never committed.** Each row names a public source; download it into `tools/_work/bench/`
|
||||
(ignored by git) before running the row. Record the sample hash when you run it, because two rows on
|
||||
different sample revisions are not comparable. Hashes used in this pass:
|
||||
|
||||
```
|
||||
UnCrackable-Level1.apk 66651 B 1da8bf57d266109f…
|
||||
UnCrackable-Level2.apk 901022 B 4c7980ef1f6cc295…
|
||||
UnCrackable-Level3.apk 1460555 B 6827f776b7d28443…
|
||||
r2pay-v1.0.apk 4293620 B 850d7c98bed8df53…
|
||||
```
|
||||
|
||||
Strength labels follow the repository's own three-tier rule (`references/long-task-discipline.md`
|
||||
reserves `observed` for a claim with an exact command and its output behind it):
|
||||
|
||||
- `observed` — a command was run and its output is quoted in the evidence file.
|
||||
- `inferred` — reasoned from documented mechanism or from a neighbouring measurement.
|
||||
- `unverified` — assumed, or reported by someone else and not reproduced here.
|
||||
|
||||
| # | Dimension | Target (public source) | Scripts / documents the row exercises | Result | Strength | Evidence |
|
||||
|---|---|---|---|---|---|---|
|
||||
| B1 | Equal-length dex patch + repack + re-sign installability (G2/G4) | MASTG `UnCrackable-Level1.apk` | `dex_patch_bytes.py`, `repack.py`, `byte-level-patching.md`, `repack-and-sign.md` | **The route works end to end, and the change is visible on screen.** 2 bytes of the first root-check branch in `MainActivity.onCreate` (`0x9d0: 39000e00` -> `00000000`) patched inside a 5528-byte dex; the whole-file diff is those 4 bytes plus bytes 8..32, and the written file self-verifies (`adler32(d[12:])` and `sha1(d[32:])` both reproduce, proving signature-before-checksum order). `repack.py` produced a STORED + 4-byte-aligned `resources.arsc`, apksigner reported v1+v2+v3 all true, the root install path succeeded with the on-device `base.apk` sha256 equal to the local build, and the blocking `Root detected!` dialog is gone from the screenshots. A zero-change control through the identical pipeline still shows it | observed | `EXTENSION-benchmark-l1-l3.md` §2 |
|
||||
| B2 | Anti-debug + self-destruct race (patch before the check fires) | MASTG `UnCrackable-Level3.apk` | `spawn_patch_detach.py`, `hook_patch_only.js`, `dynamic-frida.md` | **Split: the tooling works, the patch does not.** Time-to-death with a probe attached is 5.34 s. After an ordering fix (resume -> wait for `PATCHED` -> detach) the probe wrote `libfoo.so` base+0x2ff0/+0x2ff8/+0x3080 to `mov w0,#0 ; ret` 0.04-0.05 s after resume and the writes survived the detach — the mechanism is real. The patch still does not stop the death: the patched run **and** a read-and-write-back control both die with `SIGABRT` at `libfoo.so (goodbye()+12)` 3.22 s after resume, so neutralising the terminate routine plus both of its branch inputs is insufficient; the region is a dense cluster of `mov w0,#imm ; ret` stubs and locating the real second site needs a full `.text` disassembly. The alternative ordering ("hold paused until `PATCHED`") cannot work — a frozen process has no libraries mapped, so `Process.findModuleByName` never returns (0.11 s after resume it appears). Independently, the pristine original already reports tamper on this device, so the row's assumed clean baseline does not exist here | observed (tooling) / unverified (a surviving target) | `EXTENSION-benchmark-l1-l3.md` §3 |
|
||||
| B3 | Extraction-shell skeleton detection (`trivial-body` routing) | self-built 11-variant skeleton set from the `r2pay-v1.0.apk` dex, with a zero-change control | `dex_dump_validate.py`, `advanced-unpacking.md` | **The "stub% threshold" claim this skill used to make is wrong.** `stub%` is *bimodal*, not thresholded: a real skeleton reads ≈100% (`allstub` 100.0%) while a control reads 1.9%, and the shapes real shells actually use are invisible — nop-cleared bodies 0.0% (below the control), throw stubs 1.9% (indistinguishable), partial extraction at 25/50/75% 1.7/1.7/2.1%. `advanced-unpacking.md`'s original "tens of percent means a skeleton" had no support and was deleted. Three script defects were found and fixed (all-nop bodies ranked as real; a legal minimal body mislabelled malformed; the ranking pointing at a 25%-extracted image as "most likely original" — caught by independent review). The new `minimal-form` counter *does* track the extraction ratio monotonically (1299/2524/3736/4946) and now participates in the ranking | observed | `EXTENSION-extraction-shell-bench.md` §1–3 |
|
||||
| B4 | Dex-VMP boundary: refuse-to-repack and downgrade the deliverable | DexPatcher `assets/ezAndroid.apk` | `advanced-unpacking.md`, `java2c-and-jni-sinking.md`, `lsposed_scaffold.py`, `frida_rpc_serve.py` | **The sample is not a Dex VMP — it is JNI sinking**: 7 `native` declarations, each with a matching exported `Java_*` symbol, no `JNI_OnLoad` / `RegisterNatives` symbol, 34,566 instructions with zero structural errors under the official `dexdump`. A zero-change repack succeeded (v2 signing, alignment, `classes.dex` CRC verified). Static criteria **can** rule a dex-VMP *out*; they cannot rule runtime-applied hardening out. The first verdict here was the opposite — a hand-written opcode table reported 1393/22424 undecodable bodies and called it a VMP; `dexdump` showed the table was missing an arm64 branch opcode, and that rule (never declare VMP without an official disassembler's failure count) is now in the reference. Installation / launch / refusal remain untested | observed (static + repack) / unverified (install, launch) | `EXTENSION-extraction-shell-bench.md` §4 |
|
||||
| B5 | Java2C recognition (do not misdiagnose as an extraction shell) | self-built with `amimo/dcc` (`--no-build`) + real JNI samples | `java2c_probe.py`, `java2c-and-jni-sinking.md` | Native-declaration density separates the shapes by ~2000x: **0.03–0.04%** on three real JNI samples vs **82.76%** on a Java2C-shaped fixture, and `Java_*` symbols matched dex native-method counts 1:1 per ABI (2:2, 3:3). **No Dex-to-C compiler output was ever built** (no NDK, no host clang, no device clang, WSL unavailable), so every Java2C-specific criterion is inferred while the JNI-sinking side is measured. A symbol search fails silently on real Java2C: dcc emits `-fvisibility=hidden`, and C++ `jni.h` inlines `RegisterNatives` so it leaves no symbol | observed (density, JNI sinking) / inferred (Java2C-specific) | `EXTENSION-java2c.md` |
|
||||
| B6 | Native tracing: does `Stalker.exclude()` change the outcome? | MASTG `UnCrackable-Level1.apk`, module `libc.so` followed on the main thread for 6 s | `stalker_trace.js`, `native-dbi-and-deobfuscation.md` | **Split result.** A no-follow baseline survives; the same follow with `excludeModules: []` **kills the process**; with 20 system modules excluded it survives — so exclusion is what keeps the target alive (`observed`, with the baseline arm ruling out "frida killed it anyway"). But the treatment arm still reported `blocks=0 blk=0 calls=0`: **exclusion does not fix the zero-event trace**, which partially contradicts the community framing that lists "no events" among its symptoms. The pass also fixed two harness traps that manufacture a false zero-event result (a load-time default follow, and a suspended spawn) and verified a new zero-event `WARN`. No real OLLVM trace was produced | observed (survival, delivery) / unverified (the multiplier, the cause of zero delivery) | `EXTENSION-stalker-exclude.md` |
|
||||
| B7 | Native certificate pinning | CyReverse Cronet sample | `protocol-reverse.md` | not run this pass — the CyReverse Cronet sample needs a gradle+NDK build this machine cannot do | unverified | — |
|
||||
| B8 | Schemaless protobuf decoding | synthetic fixtures + the official runtime + a real AndroidX DataStore container | `protobuf_decode_raw.py`, `protocol-reverse.md` | 26/26 built-in fixtures, **21/21 against the official protobuf runtime** (re-encode byte-identical to `SerializeToString()`), 8/8 framing checks. A real 81-byte DataStore file round-tripped byte-exact and an edited value re-encoded (81->80 B, length prefix recomputed) was read back by `datastore_inject.py`. Both traps reproduced on real bytes: `0801120178` is a valid nested message *and* a valid packed array; a non-presence `0` is byte-identical to "never assigned". Four script defects were found by these checks and fixed | observed | `EXTENSION-protobuf-raw.md` |
|
||||
| B9 | Split APK / App Bundle handling | two real split sets pulled from a device (8 members = base + 7 density splits each carrying its own `resources.arsc`; 9 members = base + ABI split with only `lib/` + 7 density splits with 40-byte placeholder tables) | `repack.py` (split mode), `split-apk.md` | Both branches of the decision ran on real sets: after `resign` every member shares one certificate with v1+v2+v3 all true; `merge` **correctly refused** the set whose members carry their own `resources.arsc` (exit 1, before writing anything) and **succeeded** on the code/native-only set, folding 2 `.so` files into `base`. The single-APK legacy path still passes its regression (`--dexdir` replacement and a zero-change round trip). Signing has no `uber-apk-signer` on this host; the measured call is `zipalign -p -f 4` then `apksigner.jar --v1 --v2 --v3`, and the order cannot be reversed (`[-124]`). `adb install-multiple` and launch confirmation were **not** executed | observed (analyze/resign/merge) / unverified (install, launch) | `EXTENSION-split-apk.md` |
|
||||
| B10 | Kernel-side syscall answer forging | KernelSU/APatch module template, host toolchain, device kernel | `kernel-and-environment-hardening.md`, `kernelsu_syscall_mask.py` | The generator produced an 11-file userspace module skeleton plus KPM/LKM/eBPF templates and `verify` reports 0 problems — but **no kernel-side artefact was compiled or loaded**: the `<DEVICE>` kernel is 4.14.186 (eBPF needs 5.10+), the host has no `aarch64-none-elf-gcc` / `make` / `ndk-build`, and there is no kernel source. Documentation correction with teeth: **an ordinary KernelSU module is a userspace module and structurally cannot change a syscall return value** | observed (generation) / unverified (kernel side) | `EXTENSION-kernel-weapons.md` |
|
||||
| B11 | JNI dynamic registration discovery (`RegisterNatives` with no `Java_*` symbol) | a real system library + CyReverse `plugin/` | `java2c-and-jni-sinking.md`, `java2c_probe.py` | A real dynamically-registering library contains **zero `RegisterNatives` symbols** (the C++ `jni.h` inlines the call), so symbol-based discovery fails structurally. The combination that did fire on a real library is "**exports `JNI_OnLoad` and zero `Java_*`**" | observed | `EXTENSION-java2c.md` |
|
||||
|
||||
## How to read a row
|
||||
|
||||
`Result` states what actually happened, including negative outcomes — "the route worked", "the route
|
||||
worked only with `Stalker.exclude()`", "the tool refused to run at all". A negative result that is
|
||||
recorded is worth more than a positive one that is assumed, because the next agent can route around
|
||||
it without re-paying the cost.
|
||||
|
||||
A row whose `Strength` is `unverified` is a statement about *this repository's evidence*, not about
|
||||
the mechanism's truth. It means: nobody has yet put a command and its output behind it here.
|
||||
|
||||
**Rows that changed a conclusion this repository had already recorded.** This is the outcome the
|
||||
matrix exists to produce, so it is listed rather than buried: B3 (the trivial-body "threshold" became
|
||||
a measured bimodal result), B4 (a VMP verdict was withdrawn after an official disassembler disagreed
|
||||
with a hand-written opcode table), B6 (exclusion turns out to keep the process alive but does not fix
|
||||
zero-event delivery, contradicting the community framing), and B1 (an earlier "`repack.py` has no
|
||||
apksigner signing path" conclusion expired mid-pass when a second writer added one).
|
||||
Reference in New Issue
Block a user