Extension pass: module route, extraction shells, emulation/RPC, DBI, kernel layer, protocols

Closes the six coverage gaps named in an external review of this skill, and moves the
deliverable-form decision out of the single assumption it rested on.

New references (7):
- lsposed-and-modules.md      deliver a hook module when a repack is refused
- advanced-unpacking.md       extraction shells, FART-style recovery, the root-side dump
- emulation-and-rpc.md        call the routine instead of reading it
- native-dbi-and-deobfuscation.md  OLLVM, Stalker traces, two measured boundaries
- protocol-reverse.md         protobuf/gRPC/QUIC and native-side pinning
- kernel-and-environment-hardening.md  when userspace hooking cannot reach the check
- on-device-tooling.md        MT Manager, its APK MCP, working from the phone

New scripts (8): dex_dump_validate.py (+ --trim for page-aligned captures),
dex_mem_scan.py, lsposed_scaffold.py, frida_rpc_serve.py + rpc_template.js,
stalker_trace.js + stalker_report.py, mt_mcp_probe.py

SKILL.md:
- G1 carries a four-form deliverable table (rebuilt APK / module / RPC service / report)
  with the evidence that says when to leave the repack column, plus a matching hand-off
  section on how "verified" moves with the form
- Coverage gains a documented-routes tier carrying its own strength note; Not-covered
  loses the now-inaccurate "general unpacker" line and gains the limits that do apply
- 13 new symptom rows, 7 reference rows, 8 script rows, a fifth tooling obligation
- the pid-drift symptom is stated with its competing causes, not a single attribution

toolchain.md gains measured ASC/ddc cards; README gains the structure, capability and
scope entries for everything above; .gitignore stops tracking the local workspace.

Evidence: docs/tool-verification/EXTENSION-*.md (7 files), same three labels as the rest
of the record. Measured: the gradle-free module build chain (5.27 s end to end), a
successful injection confirmed through the LSPosed file channel (9 rounds) after three
logcat queries came back empty on a ROM with a broken logd, 17 dex images recovered by
root-side /proc/<pid>/mem dumping, the trivial-body metric separating a shell skeleton
from real bodies, ASC/ddc on a hardened APK, and Stalker's zero-event and crash
boundaries. The pid-drift attribution was corrected twice against better measurements.
This commit is contained in:
inliver233
2026-09-21 20:57:41 +08:00
parent 5fa80a5bea
commit ce2e4f98e1
27 changed files with 6602 additions and 9 deletions
@@ -0,0 +1,252 @@
# Extension pass — on-device run against a hardened Flutter sample
This file records the **device-side** half of the extension pass: what was actually executed against
a live, hardened sample, what the sample did in response, and which conclusions the evidence supports.
It follows the same three labels the rest of this directory uses:
- **observed** — reproduced here, with the exact command and its output.
- **inferred** — follows from observed facts, but the step itself was not executed.
- **unverified** — assumed, or reported by a tool and not independently confirmed.
**Target identity is deliberately absent.** The sample is referred to as *the sample*, and its package
identifier appears as `<PKG>`. The findings are about the tools and the methods, and stay valid
whichever hardened APK produced them.
## Environment (observed)
| Item | Value |
|---|---|
| Device | one physical arm64 device, Android 11 (API 30), kernel 4.14.186, Magisk root |
| Host | Windows 11, Python 3.14.0, frida 16.7.19, frida-tools 13.7.1, frida-dexdump 2.0.1, java 17, adb 1.0.41 |
| Device server | `frida-server` 16.7.19 (version-matched to the host by `frida-server --version`) |
| Sample | 360-jiagu hardened, `application android:name` = a third-party stub class; Flutter AOT payload |
| Host capability report | `python scripts/doctor.py` → static/repack/smali/native-patch/device/root/frida all OK; `28 / 28` scripts runnable |
| Pre-flight warnings | clock skew 3.32 s; leftover `adb forward` entries (8787, 8651, 65534, 27043); a device-side `frida-server` already running |
Two environment facts from `doctor.py` are worth carrying forward, because each of them silently
invalidates a measurement: the clock skew, and a **frida process already up on the device** — the
second one means the target may already be reacting to instrumentation before your first command.
## The measurement that mattered: the sample fights instrumentation
### (1) Control — the sample starts normally with no instrumentation (observed)
```
$ adb shell 'su -c "am force-stop <PKG>; sleep 1; am start -n <PKG>/.MainActivity"'
$ sleep 12 && adb shell 'su -c "ps -A | grep -i <PKG>"'
u0_a623 22691 17278 14123672 642488 SyS_epoll_wait 0 S <PKG>
```
Resident, 642 MB, healthy. **This is the control build the rest of the section is measured against.**
### (2) The pid drifts — but **not** because of frida (observed, corrected)
During the session the sample's pid changed repeatedly:
```
22691 → 23452 → 24267 → 27274 → 31284 → 32259 → 1368 → 8161
```
The natural reading is "the sample fights instrumentation", and the first write-up of this file said
exactly that. **The follow-up measurement refuted it.** With `frida-server` stopped and no attach of
any kind, the sample was restarted under observation for 90 seconds:
```
20:28:22 pid=[9961] load=36.43 | Mem: 11484 total 11077 used 406 free
20:28:33 pid=[9961] load=36.26 | Mem: 11484 total 10767 used 716 free
20:28:45 pid=[9961] load=35.61 | Mem: 11484 total 10739 used 745 free
20:28:56 pid=[9961] load=34.64 | Mem: 11484 total 10767 used 716 free
20:29:08 pid=[11246] load=34.91 | Mem: 11484 total 11151 used 333 free
20:29:19 pid=[11246] load=35.69 | Mem: 11484 total 11142 used 342 free
20:29:31 pid=[12238] load=35.14 | Mem: 11484 total 11131 used 353 free
20:29:42 pid=[13212] load=37.03 | Mem: 11484 total 11059 used 424 free
```
The pid changes with **no frida present**, roughly every 10-20 seconds, while the device sits at
300-750 MB of free memory out of 11.5 GB. The first correction to this file attributed that to memory
pressure. **That attribution was also wrong, and the device-side dumping run refuted it** with a
better instrument than sampling: `logcat`.
```
$ adb logcat | grep <PKG>
Process <PKG> (pid N) has died: fg TOP
Start proc <pid> for top-activity <PKG>
```
The process **dies as the foreground top activity, with no crash, no tombstone and no ANR record**,
and the platform immediately relaunches it — a clean self-exit in a loop, on a 7-18 second cycle. A
reclaiming device does not do that to a foreground process, and a memory-pressure kill leaves an
`lmkd` line and a low-memory record. What produces exactly this shape is the sample **checking its own
environment and quitting on purpose**.
And the environment it finds is decisive: on this device `magisk --denylist status` reports **enforced
with an empty list**, Zygisk-Assistant is installed but nothing is scoped, and
`/data/adb/shamiko` does not exist — so **no application on this device is hidden from root**
(`EXTENSION-lsposed.md`, observed). A hardened sample that refuses to run on a rooted device will do
exactly this, forever, and no amount of dump-retrying changes it.
**Reading, corrected twice:** the pid drift is an artefact of the **root environment**, not of
instrumentation and not of memory pressure. The practical consequence is the useful part — *before any
dynamic analysis of a hardened sample, configure root hiding and re-run the baseline*, because with
nothing hidden every dynamic result on this device is measured against a process that is trying to die.
The observer's first control run showed a "stable" sample for 12 seconds only because it happened to
sample inside one 17-second lifetime.
This is the skill's own rule applied twice to its own record: a conclusion was reopened each time a
measurement disagreed with it (`SKILL.md` §Stop conditions, last bullet). Two wrong attributions were
cheaper to delete than for a reader to inherit — and the second one only fell because a different
method (`logcat`, not sampling) was pointed at the same question.
The platform's own view of a failed launch was also captured, and is worth keeping as a *shape*:
```
W ActivityManager: Process ProcessRecord{…:<PKG>/u0a623} failed to attach
I ActivityManager: Killing <pid>:<PKG>/u0a623 (adj -10000): start timeout
```
A `start timeout` kill reads the same whether the cause is a slow shell init, a reclaiming device, or a
deliberate delay. It does not discriminate, and it is not evidence either way.
### (3) `frida-dexdump` cannot complete a memory dump — reproduced twice (observed)
Both spawn and attach were tried, with the parameter mistakes fixed between attempts:
```
$ frida-dexdump -U -f <PKG> -d -o <out> # spawn
Failed to spawn: need Gadget to attach on jailed Android; its default location is: …gadget-android-arm64.so
$ frida-dexdump -U -f <PKG> -d 12 -o <out> # 12 was parsed as an argv entry, not a delay
Failed to spawn: the 'argv' option is not supported when spawning Android apps
$ frida-dexdump -U -f <PKG> -d -o <out> # corrected
Failed to spawn: unexpectedly timed out while waiting for app to launch
$ frida-dexdump -U -p <pid> -d -o <out> # attach, twice, second run with the device idle
frida.InvalidOperationError: script has been destroyed: {'addr': '0x721e24c02c', 'size': 409556}
INFO:frida-dexdump:[*] All done... Process terminated
```
Attach was run twice. The **second** run was the clean one: no Stalker traffic anywhere on the device
(see the crash note below), the package enabled and verified running, the pid read immediately before
the attach, and a single variable changed relative to the control. It produced the same
`script has been destroyed` failure, and the sample's pid changed again in the aftermath.
**What the failed runs left behind, checked with the kit's own validator** (`observed`):
```
$ python skills/apk-reverse/scripts/dex_dump_validate.py <dump_dir>
== dex dump validation: 2 file(s), 2 parse, 0 rejected ==
name size sha256[:12] ver cksum sig class noco code stub%
classes.dex 500 9461987e5464 035 BAD BAD 1986754560 0 0 0.0%
walk stopped early: index out of range
classes02.dex 10285489 116d8b84b486 035 BAD BAD 1986754560 0 0 0.0%
walk stopped early: index out of range
```
Both artifacts pass the magic check and then fail everything else: checksum and signature both BAD,
no classes recovered, the walk stopping on an out-of-range index. They are **memory fragments cut
short by the process dying mid-read**, not dex images — and this is exactly the case
`dex_dump_validate.py` exists for. A size-based heuristic would have kept the 10.2 MB file and
discarded the 500-byte one; both are worthless, and only a structural walk says so.
**Reading:** under these conditions the memory-dump route is refused by the target, not by the host
tooling. Two shape-identical failures on a single variable is the skill's own stop signal: the correct
move is to re-classify (`references/advanced-unpacking.md` §the four shapes, then
`references/detection-and-anti-analysis.md`), **not** to run a third variant of the same attach.
Two of the four failures above were the observer's own mistakes, and they are worth keeping because
they are cheap to repeat: `-d` is a switch, not a delay, and any extra positional argument reaches
`spawn(argv)`. `-n <name>` on this version of `frida-dexdump` also still routes through the spawn
path, so "attach by name" does not exist as a way around the spawn refusal — use `-p <pid>`.
### (4) What the sample looks like at the moment it is attacked (observed)
```
$ adb shell 'su -c "top -n 1 -b"'
PID USER PR NI VIRT RES SHR S[%CPU] %MEM TIME+ ARGS
31284 u0_a623 10 -10 13G 561M 268M S 439 4.8 0:05.52 <PKG>
154 root 20 0 0 0 0 S 66.6 0.0 0:02.18 [kswapd0]
17638 system 18 -2 12G 590M 419M S 60.6 5.1 1:15.93 system_server
```
A Flutter application in its startup window saturates several cores (439 % CPU) while the Dart VM
initialises, and the device — 11.7 GB RAM with 357 MB free at that moment — runs its reclaim thread at
two thirds of a core. **The load on this device during the experiment was the sample's, not the
observer's instrumentation.** That matters for attribution: "the device is slow, so the dump failed"
is not available as an explanation here, because the failing command is a *protocol* failure
(`script has been destroyed`) rather than a timeout.
## The device's own framework was already damaged (observed)
Recorded here because it is a **third candidate mechanism** for "things keep restarting", alongside the
sample's self-exit and the memory pressure — and because it invalidates any dynamic result that
depends on the hooking framework:
- `LSPosed Manager` opened to **"LSPosed is not installed"**, with no modules tab, while the `lspd`
daemon was still alive.
- The LSPosed configuration database had not been updated since **19:37** — it still pointed at the
`codePath` of an app that had been uninstalled, and never registered the replacement.
- `logcat` showed a loop: `system server died`, `am is dead`, `no response from bridge`, and
`Magisk: zygote crashed too many times, rolling-back`.
A zygote in a crash-and-rollback cycle restarts application processes by itself, so **"the pid changed"
had at least three independent possible causes on this device** (the sample exiting, reclaim, and a
broken framework). That is exactly why the corrected §2 attributes the drift to *instability of the
observation target* rather than to any single mechanism, and why a dynamic result needs its device's
framework state recorded next to it — `scripts/preflight.py` reads device state for the same reason.
Recovery is a reboot (Magisk re-initialises Zygisk and `lspd` at `late_start`; the LSPosed daemon is
started once and does not return if killed). Whether the framework came back after the reboot, and
whether the sample's self-exit behaviour changed with it, is recorded in
`EXTENSION-lsposed.md` — and any change there must stay **inferred**, because a reboot moves several
variables at once.
## A shared-device hazard, measured (observed)
While the run above was in progress, a second agent on the same device used **Frida Stalker** against
`com.android.settings` and `com.android.systemui`, including a `libc` `malloc` export trigger that
entered and left `follow` hundreds of times. Result:
- `Stalker.follow` on the main thread reported `blocks=0`, `blk=0`, `calls=0` — **the follow installed
and no events arrived**.
- `systemui` then died with `SIGSEGV`, the faulting frame sitting in gum's anonymous code cache and
the backtrace continuing into `libart` at `art::Reference_getReferent`; the process restarted
(`18030 → 26932`).
- Device load average reached **34.11** on an eight-core phone.
**Reading:** two conclusions, kept separate on purpose. First, on this ROM + `frida-server` 16.7.19
combination a Stalker follow produced no events — that is a *boundary* for
`references/native-dbi-and-deobfuscation.md`, not a contradiction of Stalker's documentation. Second,
high-frequency `follow`/`unfollow` on every `malloc` entry crashed a system process — a measured
failure mode for the pattern, and one worth stating before anyone puts it in a shipped script.
The third conclusion is about method rather than about Stalker: **instrumentation on a shared device
is a shared variable.** The concurrent load damaged the *other* experiment's validity — the dumped-run
above could not be attributed until it was repeated with the device idle, and the repeat is what
turned "maybe the load broke it" into a reproducible protocol refusal. R2 (one variable at a time)
applies across agents, not only within one script.
## Conclusions, graded
| Claim | Label |
|---|---|
| The sample runs healthy with no instrumentation on the device | observed |
| The sample's pid drifts repeatedly during an experiment, roughly every 10-20 s | observed — **cause not instrumentation**: reproduced with `frida-server` stopped, under 300-750 MB free memory (§2) |
| `frida-dexdump` spawn refuses (jailed / argv / timeout) and attach fails with `script has been destroyed`, reproduced twice with the parameter mistakes fixed | observed |
| The attach failure is deliberate armour rather than a host-tool bug | **unverified** — memory pressure leaving the process an unstable target is a competing explanation this pass did **not** rule out; the single-variable repeat eliminated *concurrent* load, not *baseline* load |
| The shipped shell `classes.dex` is 8.9 MB and defines only 4 classes / 29 methods with code | observed (recorded in `EXTENSION-unpacking.md`) |
| Recovering the real bodies needs a route other than a `frida` memory dump on this target | inferred |
| Stalker follow delivers no events on this ROM with this frida-server version | observed, single sample, `unverified` as a general rule |
| High-frequency follow/unfollow on `malloc` can crash a system process | observed, once |
| No app on this device is currently hidden from root (`magisk --denylist status` enforced, list empty; Zygisk-Assistant installed but nothing configured) | observed (recorded in `EXTENSION-lsposed.md`) — offered here because a hardened sample's sensitivity to a rooted device has a configuration, not just a cause |
## What this pass did **not** establish
- **Not** a successful end-to-end unpack of the sample. The memory-dump route was refused, and the
pass stopped at the skill's own two-strike rule instead of escalating into an evasion arms race.
- **Not** evidence that the sample is un-unpackable. The routes that do not use `frida` on the device
— root-side `/proc/<pid>/mem` extraction, the module route in `references/lsposed-and-modules.md`,
and the static route already recorded for the Flutter payload — were not exhausted here.
- **Not** a general claim about 360-jiagu, Flutter, or Android 11. Everything above is one device,
one ROM, one server version, one sample.
@@ -0,0 +1,431 @@
# Extension verification — offline emulation (unidbg) and Frida RPC
Covers the 2026-09 extension pass that added `references/emulation-and-rpc.md`,
`scripts/frida_rpc_serve.py` and `scripts/rpc_template.js`. 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, host `frida` 16.7.19 +
`frida-python` 16.7.19 (frida-tools 13.7.1), `adb` 37.0.1, JDK 17.0.4.1 (with `JAVA_HOME`
**unset** in the shell — see §5). Device `<DEVICE>`: Android 11 / API 30, arm64-v8a, rooted
(Magisk), on-device frida-server 16.7.19. Workbench artifacts live in `tools/_work/t3-rpc/` (not part
of the skill).
**Device-exclusivity constraint during this pass**: another task was unpacking the hardened Flutter
sample on the same device. This pass never attached to, spawned or force-stopped that sample's
process, and never killed or restarted the device-side frida-server. All RPC work targeted unrelated
processes (system apps and third-party apps). De-identification: the sample's package label, real
package name and md5 are elided in this document as `com.example.app` / `<label>`; command shapes,
counts, class names and timings are verbatim.
---
## 0. Summary of what this pass established
| Claim | Label |
|---|---|
| `droidasc` 0.1.1.post1 and `ddc` 0.1.8 both run on this host; both read the hardened sample's shell dex | observed |
| Both tools see only the 4 shell classes in the APK's dex; business classes are absent (shell dex only) | observed |
| `frida_rpc_serve.py` `--help`, all three modes, reconnect, spawn and attach-by-pid work against a live device | observed |
| Attach **by package name** fails on this ROM while attach **by pid** succeeds for the same process | observed |
| The device-side server may be (re)started on a non-default port; `--remote HOST:PORT` recovers, device-serial lookup does not | observed |
| unidbg 0.9.10-SNAPSHOT builds and boots an Android emulator on this host after a pom fix; one bundled test suite passes | observed |
| Any target `.so` actually emulated under unidbg | **not done** (unverified) |
| frida 17 compatibility of `rpc_template.js` export lookup | unverified (host has 16.7.19 only) |
---
## 1. ASC (`droidasc`) — observed
### 1.1 Install and version
```
$ python -m pip show droidasc
Name: droidasc
Version: 0.1.1.post1
Summary: ASC is a super FAST python Android Decompiler for Agents/Mobile Researchers
License-Expression: Apache-2.0
Location: C:\Python314\Lib\site-packages
Requires: androguard
```
Install line used (recorded after the fact; the package was already present on this host):
`python -m pip install droidasc`. Distribution metadata declares only `androguard` as a dependency —
**no JVM is required** (unlike the `asc` successor tooling in the upstream MG1937/ASC repository,
which is a separate project). The console script is named **`droidasc`**, not `asc`: there is no
`asc.exe` in `C:\Python314\Scripts`, and `asc --version` fails with
`The term 'asc' is not recognized...`. `python -m droidasc` is the equivalent entry point.
### 1.2 Subcommands
```
$ python -m droidasc --help
usage: python.exe -m droidasc [-h]
{getclass,listclass,getmanifest,findrefs} ...
ASC tooling entry.
positional arguments:
{getclass,listclass,getmanifest,findrefs}
getclass Locate the target class in APK, extract one DEX in memory, then decompile.
listclass List classes across all DEX entries; use --prefix to filter by package/class prefix.
getmanifest Decode AndroidManifest.xml from APK and print it as XML.
findrefs Find code references for string/type/method/field across all DEX entries in APK.
examples:
droidasc app.apk --gui
droidasc getclass app.apk Lcom/poc/Main; -o Main.java
droidasc listclass app.apk --prefix com.poc
droidasc getmanifest app.apk -o AndroidManifest.xml
droidasc findrefs app.apk string token -o string_refs.txt
```
`--gui` and the bundled `asc_client` web UI were **not** exercised (unverified, headless pass).
### 1.3 Measured against the sample
```
$ droidasc getmanifest <sample>.apk -o asc-manifest-verify.xml # 0.44 s, 32,345 B
<manifest ... package="com.example.app" platformBuildVersionCode="34" ... android:versionCode="1143"
android:versionName="0.9.0" android:compileSdkVersion="34" ...>
<uses-sdk android:minSdkVersion="19" android:targetSdkVersion="28"/>
<uses-permission android:name="android.permission.INTERNET"/>
... (permissions list follows; manifest decodes cleanly)
$ droidasc findrefs <sample>.apk string jiagu -o asc-findrefs-jiagu.txt # 0.42 s
classes.dex | Lcom/stub/StubApp;-><clinit> | matched=(libjiagu)
classes.dex | Lcom/stub/StubApp;->attachBaseContext | matched=(/.jiagu; jiagu; jiagu_x86)
$ droidasc getclass <sample>.apk com.stub.StubApp -o asc-StubApp-verify.java # 0.33 s, 22,802 B
package com.stub;
public final class StubApp extends android.app.Application { ... }
$ droidasc listclass <sample>.apk --prefix com.stub # 0.23 s
Lcom/stub/StubApp;
$ droidasc listclass <sample>.apk -o asc-listclass-all.txt # 0.18 s, 4 lines
Lcom/stub/StubApp;
Lcom/tianyu/util/Configuration;
Lcom/tianyu/util/DtcLoader;
Lcom/tianyu/util/a;
```
`findrefs` reports the shell's own strings (`libjiagu`, `jiagu_x86`, `/.jiagu`) — i.e. the packer
signature is visible without unpacking, but nothing of the application is.
### 1.4 Limits hit
```
$ droidasc getclass <sample>.apk com.example.app.MainActivity -o asc-mainactivity.java
Error: Class Lcom/example/app/MainActivity; not found in APK.
exit = 1
```
**This is the limit that matters for this sample class:** the full class listing is 4 classes, so on
a packed APK ASC is a manifest + shell-inspector, not an application decompiler. The application code
lives in that shell's payload (`assets/`, the native unpacker and the AOT/Flutter layer), which ASC
does not touch. ASC still earns its place in the flow: `getmanifest` in under half a second is the
fastest manifest pass measured in this repository's tool set, and `findrefs` on shell class names is
how the packer was identified here.
Observed behaviours worth knowing: `droidasc` prints per-command timing nowhere, writes to `-o` only,
and returns a clean `Error: ...` line plus exit 1 when a class is missing.
---
## 2. ddc — observed
### 2.1 Version and acquisition
```
$ bin/ddc.exe -V
ddc 0.1.8 — DEX → Java decompiler
https://github.com/ejfkdev/ddc
```
`ddc` is a Rust binary (upstream: [ejfkdev/ddc](https://github.com/ejfkdev/ddc)); the Windows build
used here is `tools/_work/t3-rpc/bin/ddc.exe`, 1,028,096 B, **not on `PATH`** and with no version
resource in the PE header (`VersionInfo` empty), so `ddc -V` is the only way to learn its version.
No JVM, no Python. Subcommands advertised by `ddc help`: `info`, `listclasses`, `manifest`,
`mainactivity`, `res`, `strings`, `findrefs`, `callers`, `members`, `hierarchy`, `largest`, `disasm`,
`getclass`, `getmethod`, plus a full-decompile mode (`ddc <INPUT>... [OUTPUT]`).
### 2.2 Measured against the sample
```
$ bin/ddc.exe info <sample>.apk # 0.24 s
label <label>
package com.example.app
version 0.9.0 (1143)
application com.stub.StubApp
launcher com.example.app.MainActivity
sdk 19–28
size 36.9 MB (38688120 bytes)
md5 1a037a045205184d4e8272092997334f
image dex classes methods fields strings
<sample>!classes.dex 035 4 266 23 481
total: 1 image(s), 4 classes
$ bin/ddc.exe findrefs <sample>.apk string jiagu # 0.13 s
dex kind class method refs
classes.dex const-string com/stub/StubApp <clinit>()V "libjiagu"
classes.dex const-string com/stub/StubApp attachBaseContext(...)V "jiagu_x86"; "jiagu"; "/.jiagu"
$ bin/ddc.exe strings <sample>.apk -f jiagu --with-locations # 0.14 s
dex string used-by
classes.dex "/.jiagu" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "jiagu" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "jiagu_x86" com/stub/StubApp attachBaseContext(Landroid/content/Context;)V
classes.dex "libjiagu" com/stub/StubApp <clinit>()V
```
`ddc info` reaches the same conclusion as ASC by a different route (class/method/string counts per
dex image) and adds the resources-derived label and the launcher verification; `findrefs` output is
tabular and one line per (method, string) pair, which is directly pasteable into a report.
### 2.3 Limits hit
```
$ bin/ddc.exe getclass <sample>.apk com.example.app.MainActivity
ddc: class com.example.app.MainActivity not found in the selected image(s)
(try `ddc listclasses <input> <pattern>`)
exit = 2
```
Exit codes measured on this build: `info` → 0, `-V` → 0, `getclass` with a missing class → **2**.
The missing-class path also reprints the full help text on stdout, so a wrapper that only checks
"did anything print" will misread it as success — key off the exit code. As with ASC, the packed APK
exposes only the shell dex; `ddc` cannot see application classes either, which is a property of the
artifact, not of the tool.
---
## 3. Prior-attempt outputs under test — and the defects found
`skills/apk-reverse/references/emulation-and-rpc.md`, `scripts/frida_rpc_serve.py` and
`scripts/rpc_template.js` were already on disk from an interrupted attempt of this task. This pass
read all three, ran the script, and fixed four defects. `rpc_template.js` needed no change.
| # | Defect (observed) | Fix |
|---|---|---|
| 1 | `RpcBridge._teardown()` never detached the session and used `obj.off(...) if obj is self.session else None` / `obj.unload() if hasattr(obj, "unload")` — a script object has `unload()`, a session has `detach()`, so the expression released only the script. Every `reload()`/reconnect cycle leaked one attached session on the device. | Rewritten to unload the script, remove the `detached` handler and `session.detach()` explicitly. |
| 2 | `list_exports()` called `Script.list_exports()`, which frida 16.7.19 answers with `DeprecationWarning: Script.list_exports will become asynchronous in the future, use the explicit Script.list_exports_sync instead` (printed on every `list` command). | Tries `list_exports_sync` first, then the old name, then local introspection. |
| 3 | `--mode call` raised out of `main()`: an unknown export produced `frida`'s own `unable to find method 'x'` and a raw Python traceback; a bad `--args` value dumped a `JSONDecodeError` traceback. Scripted callers got a stack trace instead of a result. | `run_call_mode()` now returns one JSON object (`{"ok": false, "error": ...}`) and exit code 1 for both cases; `call()` consults the advertised export list first so the message is `no such rpc export: nope (available: add ping ...)`. |
| 4 | `frida.TimedOutError` was absent from the retry whitelist. It is **not** a `TransportError` subclass (`frida.TimedOutError.__mro__` → `(TimedOutError, Exception, BaseException, object)`), so a device that timed out during attach produced a traceback in an otherwise recoverable situation — reproduced live (§4.4). | Retryable types are now collected by name into `RETRYABLE` (TransportError, NotSupportedError, ServerNotRunningError, ProcessNotFoundError, InvalidOperationError, TimedOutError) and used by both `ensure()` and the initial connect in `main()`. |
Post-fix evidence:
```
$ python skills/apk-reverse/scripts/frida_rpc_serve.py --remote 127.0.0.1:27043 --pid 19938 \
--script skills/apk-reverse/scripts/rpc_template.js --mode call --export add --args "[40, 2]"
{"ok": true, "export": "add", "result": 42} exit = 0
$ ... --mode call --export nope
{"ok": false, "export": "nope", "error": "no such rpc export: nope
(available: add ping getpackagename callnative callnativeaddr)"} exit = 1
$ ... --mode call --export add --args "not-json"
{"ok": false, "error": "--args is not valid JSON: Expecting value: line 1 column 1 (char 0)"}
exit = 1
```
Known cosmetic behaviour left as is: in `--mode call` the bridge connects before `--args` is parsed,
so a bad-args invocation still performs a device attach.
## 4. Frida RPC against the live device — observed
### 4.1 Attach path: by name fails, by pid works
Attach **by package name** failed for every package tried, with the process demonstrably alive in
both `frida-ps -Uai` and `ps -A`:
```
$ python ...frida_rpc_serve.py --device-serial <DEVICE> --package com.android.settings ...
[bridge] connect failed (unable to find process with name 'com.android.settings'); retry 1 in 1.0s
... (5 attempts) ...
initial connect failed: could not (re)connect after 5 attempts
$ python ... --package io.github.vvb2060.magisk ...
[bridge] connect failed (unable to find process with name 'io.github.vvb2060.magisk'); retry 1 in 0.5s
initial connect failed: could not (re)connect after 1 attempts
```
The same processes attach normally by pid:
```
$ python ... --pid 19938 --mode call --export getpackagename
{"ok": true, "export": "getpackagename", "result": "com.android.settings"}
$ python ... --pid 21493 --mode call --export getpackagename
{"ok": true, "export": "getpackagename", "result": "com.android.email"}
```
So the gap is in the **name→pid lookup** the server serves, not in injection: `--pid` definitely
does bypass it. `references/emulation-and-rpc.md` had claimed the opposite ("`--pid` does not bypass
a gap that hides the process from the server's own table"); that row was corrected in this pass, and
the corrected wording is what the reference now carries.
### 4.2 The transport itself
| # | Command (pid 19938 = `com.android.settings`, unrelated to the sample) | Result |
|---|---|---|
| 1 | `--mode call --export add --args "[40, 2]"` | `{"ok": true, "result": 42}` |
| 2 | `--mode call --export ping` | `{"ok": true, "result": "pong"}` |
| 3 | `--mode call --export getpackagename` | `{"ok": true, "result": "com.android.settings"}` |
| 4 | `--mode call --export callnative --args '["libc.so","getpid","int",[],[]]'` | `{"ok": true, "result": 19938}` (matches the pid — `NativeFunction` wrapping works) |
| 5 | `--mode repl`, feeding `list` / `reload` / `list` / `add [40, 2]` / `nope` | `add ping getpackagename callnative callnativeaddr`; `reloaded`; same list; `42`; `error: no such rpc export: nope (available: ...)` |
| 6 | `--mode http`, `GET /exports` | `{"ok":true,"exports":[...5 names...]}` |
| 7 | `--mode http`, `POST /call {"export":"add","args":[40,2]}` | `{"ok":true,"result":42}` |
| 8 | `--mode http`, `POST /call {"export":"getpackagename","args":[]}` | `{"ok":true,"result":"com.android.settings"}` |
| 9 | `--mode http`, `POST /call` with an unknown export | HTTP 502 + `{"ok":false,"error":"no such rpc export: nope (available: ...)"}` |
| 10 | `--mode http`, `POST /reload` then `POST /call add [1,2]` | `{"ok":true}` then `{"ok":true,"result":3}` — the re-attach path works, and no `DeprecationWarning` is printed any more |
| 11 | `--spawn --package io.github.vvb2060.magisk --mode call --export getpackagename` | `{"ok": true, "result": "io.github.vvb2060.magisk"}` (spawn → script load → resume → RPC) |
Every script load also delivered the template's own liveness message:
`[script] {"ev": "RPC-READY", "exports": ["add","ping","getpackagename","callnative","callnativeaddr"]}`.
That line is the cheapest health check for a session and is worth grepping for in automation.
### 4.3 The server's port moved under us
Mid-pass, `--device-serial <DEVICE>` began failing on attach while `frida-ps -U` kept working:
```
$ python ... --device-serial <DEVICE> --pid 19938 ...
[bridge] connect failed (unable to connect to remote frida-server: closed); retry 1 in 1.0s
... (3 attempts) ...
$ python -c "import frida;d=frida.get_device('<DEVICE>');d.attach(19938)"
attach failed: ServerNotRunningError unable to connect to remote frida-server: closed
$ adb -s <DEVICE> shell 'su -c "ps -A"' | Select-String 'svc16|frida'
root 1876 1 2227684 55288 do_sys_poll S .svc16 # only this instance alive
$ adb -s <DEVICE> shell 'su -c "netstat -ltnp"' | Select-String 2704
tcp 0 0 0.0.0.0:27043 0.0.0.0:* LISTEN 1876/.svc16 # nothing on 27042
```
The visible `frida-server` process (pid 23405) was gone; a disguised copy (`.svc16`, pid 1876) was
listening on **27043 only**. `frida-ps -U` still worked because frida's USB transport probes a range
of ports, while the scripted device-serial path landed on a dead default port. Recovery, with the
forward already present (`adb forward --list` shows `tcp:27043 tcp:27043`):
```
$ python ... --remote 127.0.0.1:27043 --pid 19938 --mode call --export getpackagename
{"ok": true, "result": "com.android.settings"}
```
This pass neither started nor killed any server instance; it only observed that the instance changed
and switched to the explicit `--remote` form. That form is now the recommended one in the reference's
failure table.
### 4.4 A third-party process that refuses the handshake
```
$ python ... --remote 127.0.0.1:27043 --pid 19984 --mode call --export getpackagename
frida.TimedOutError: unexpectedly timed out while waiting for signal from process with PID 19984
```
An ordinary third-party app process (`com.xunmeng.pinduoduo`, listed by `frida-ps -Uai`) simply did
not answer the agent handshake within frida's timeout. Pre-fix this surfaced as a traceback; post-fix
the bridge treats it as retryable and reports `initial connect failed: ...` with exit 2. This is an
app-side property, not a bridge property — for connectivity probes prefer a system or stable app
process.
### 4.5 Device state after the pass
`frida-ps -U` and `frida-ps -Uai` both still answer; the sample's process was never touched; the
device-side server was never killed or restarted by this pass.
---
## 5. unidbg — observed (build/boot) and unverified (target emulation)
### 5.1 Build
```
$ git clone --depth 1 https://github.com/zhkl0228/unidbg.git # version 0.9.10-SNAPSHOT
$ .\mvnw.cmd -B -pl unidbg-android -am -DskipTests compile
Error: JAVA_HOME not found in your environment. # JAVA_HOME was unset on this host
...
[ERROR] Failed to execute goal ...maven-compiler-plugin:3.3:compile on project unidbg-api:
[ERROR] .../AbstractARMDebugger.java:[79,43] reference to Module is ambiguous
[ERROR] both com.github.unidbg.Module and java.lang.Module match
```
The shipped pom pins `<source>8</source><target>8</target>` with `maven-compiler-plugin` 3.3. On
JDK 9+ that pair does **not** select the JDK 8 API, so `java.lang.Module` stays visible and every
unqualified `Module` in `AbstractARMDebugger` becomes ambiguous — the build dies in `unidbg-api`.
Two local work-copy changes fix it (recorded in `tools/_work/t3-rpc/unidbg/pom.xml`, not part of the
skill and not committed):
```
maven-compiler-plugin 3.3 -> 3.8.1 # 3.13 is rejected: the bundled wrapper is Maven 3.5.4,
# and 3.13 requires Maven >= 3.6.3
<source>8</source><target>8</target> -> <release>8</release> # hides java.lang.Module
```
Result with `JAVA_HOME=C:\Program Files\Java\jdk-17.0.4.1`:
```
$ .\mvnw.cmd -B -pl unidbg-android -am -DskipTests compile
[INFO] unidbg-api ......... SUCCESS [ 40.838 s]
[INFO] unidbg-android ..... SUCCESS [ 4.941 s]
[INFO] BUILD SUCCESS (total 48.4 s, after a one-off dependency download)
```
### 5.2 Boot check
The pom sets `<maven.test.skip>true</maven.test.skip>`, so `-Dmaven.test.skip=false` is required to
run anything:
```
$ .\mvnw.cmd -B -pl unidbg-android -am -Dmaven.test.skip=false \
-Dtest=MemoryTrackerTest -DfailIfNoTests=false test
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
Backend: DynarmicBackend64
=== Memory Leak Report === ... Address: 0x12000000, Size: 4096 ...
[INFO] BUILD SUCCESS
```
This proves the toolchain end to end at the *environment* level: emulator construction, memory
allocator, backend selection (Dynarmic, with Hypervisor and Unicorn2 factories registered) and
Android 11 (`AndroidResolver(23)`-style) library resolution all execute on this host.
### 5.3 What was **not** done — unverified
* Loading and calling **any** target `.so`, including this sample's `libjiagu*.so` or `libapp.so`.
No `createDalvikVM`, `loadLibrary`, `callJNI_OnLoad` or `AbstractJni` stub was exercised.
* Consequently every per-library statement in `references/emulation-and-rpc.md` §Part A — which JNI
callbacks a hardened library demands, how many stub rounds it costs, which `/proc` or signature
checks it runs — remains **inferred** from the library's shape and community practice. The
reference's "Measured here" section was rewritten in this pass to say exactly that.
* Note for anyone porting an older tutorial: `emulator.createCode()` / the `Assembly` helper class
do **not** exist in 0.9.10-SNAPSHOT (no `Assembly*.java` under the tree, no `createCode` symbol in
any module). Recipes that assemble raw ARM machine code through that API need the current
backend/JNI entry points instead.
* `unidbg-android`'s bundled demos (`TTEncrypt`, `SignUtil`, `QDReaderJni`, …) each expect their own
`.so`/APK to be placed first; none were run, so the demo names in the reference are pointers, not
verified commands.
---
## 6. Consolidated list of things that did **not** work (for the pass summary)
1. Attach by package name on this ROM (both `com.android.settings` and `io.github.vvb2060.magisk`
failed while listed by `frida-ps -Uai`). Workaround: pid, from `frida-ps -U` / `pidof`.
2. Device-serial attach once the device's server came back on 27043 — `ServerNotRunningError:
unable to connect to remote frida-server: closed`. Workaround: `adb forward` + `--remote`.
3. Attach by pid to `com.xunmeng.pinduoduo` — `frida.TimedOutError`; the process never answered the
handshake.
4. `asc` as a command name (the console script is `droidasc`), and `mvnw` without `JAVA_HOME`.
5. unidbg out of the box on JDK 17 (`Module` ambiguity) and unidbg tests out of the box
(`maven.test.skip=true`).
6. Application-class decompilation of a packed APK by either ASC or ddc (nothing to fix — the dex
simply is not there; this is the case that justifies the whole emulation/RPC route).
## 7. Artifacts produced by this pass
`tools/_work/t3-rpc/` (not committed): `asc-manifest-verify.xml`, `asc-StubApp-verify.java`,
`asc-listclass-all.txt`, `asc-listclass-stub.log`, `asc-getmanifest.log`, `asc-findrefs.log`,
`asc-findrefs-jiagu.txt`, `asc-getclass.log`, `ddc-info-verify.txt`, `ddc-findrefs-jiagu.txt`,
`ddc-strings-jiagu.txt`, `rpc-*.log` (one per command in §4), `bin/ddc.exe`, `unidbg/` (patched work
copy).
Files changed in the repository by this pass: `references/emulation-and-rpc.md` (corrected failure
table, corrected unidbg build recipe, corrected "Measured here" labelling, and a stale section
reference repointed to `SKILL.md` §Stop conditions) and `scripts/frida_rpc_serve.py` (the four fixes
in §3).
@@ -0,0 +1,121 @@
# Extension verification — kernel/environment hardening + on-device tooling
Covers the 2026-09 extension pass that added `references/kernel-and-environment-hardening.md`,
`references/on-device-tooling.md` and `scripts/mt_mcp_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: rooted physical device, Android 11 (API 30), arm64-v8a,
kernel **4.14.186**, Magisk alpha + Zygisk, LSPosed v1.9.2 activated, Zygisk-Assistant v2.1.4
and MoveCertificate v1.6.2 installed (pending reboot merge), MT Manager 2.26.9 installed.
---
## 1. MT APK MCP probe — measured
### 1.1 `scripts/mt_mcp_probe.py` runs and reports correctly
`--help` works (argparse, three options). Observed:
```
$ python skills/apk-reverse/scripts/mt_mcp_probe.py --help
usage: mt_mcp_probe.py [-h] [--url URL] [--timeout TIMEOUT] [--json]
```
Full **observed** output of the probe in the service-down state, with the port forward in place:
```
$ adb forward --list
<serial> tcp:8787 tcp:8787
<serial> tcp:8651 tcp:8651
<serial> tcp:65534 tcp:65534
$ python skills/apk-reverse/scripts/mt_mcp_probe.py
[waiting] MCP server is not answering at http://127.0.0.1:8787/mcp
The MT APK MCP must be started by hand in the MT UI (adb cannot start it):
1. On the phone: MT Manager -> side drawer -> Tools -> APK MCP -> Start
...
exit code 2
```
The failure was localised to the service (not the transport) on the device side — **observed**:
```
$ adb -s <serial> shell "su -c 'netstat -tln | grep 8787 || echo NOT_LISTENING'"
NOT_LISTENING
```
So at pass time: port forward alive, device reachable, MT's MCP service not started in the MT UI
(it requires a manual start that adb cannot perform). The script's designed degraded-mode
behaviour (waiting instructions + exit 2) is therefore **verified by observation**; the online
path (initialize handshake → grouped `mt_apk_*` inventory) is **unverified on this device** — it
was not exercised because the service was never started during the pass. The JSON-RPC shape
(initialize with `protocolVersion 2024-11-05`, `notifications/initialized`, `tools/list`,
`Accept: application/json, text/event-stream`) follows the MCP Streamable HTTP specification and
MT's documented transport; treat the exact response parsing as inferred until a live run.
### 1.2 What was deliberately not done
No attempt was made to start MT's MCP from adb (documented as impossible), and no scripted
tap-driver was built to press the UI button — that is a human step. The probe script doubling as
a "wait until up" check (exit 2 → retry after manual start) is the supported loop.
## 2. Phone-module environment — observed earlier, cited here
These facts were measured in the prior configuration pass and archived in
`tools/_phone-modules/README-手机模块环境.md` and `README-MCP与网络环境.md`; this pass cites them
rather than re-measuring. They count as **observed** (exact commands and outputs exist in those
archives):
- LSPosed v1.9.2 (7024) activation: log lines `ZygiskCompanion: welcome to LSPosed!`, version
banner, `lspd` daemon running as system, manager UI reporting activated.
- Zygisk requires one reboot after enable before `/data/adb/zygisk` exists; modules install to
`/data/adb/modules_update/` and merge into `/data/adb/modules/` only on reboot.
- Shamiko v1.2.5 kept as backup, **not** installed — documented conflict with Zygisk-Assistant
(both do root hiding); Zygisk-Assistant chosen because it does not depend on DenyList
enforcement.
- mcp-termux / stackplz eBPF route closed on this device: requires kernel 5.10+, device has
4.14.186; its bundled kmodules ship only 5.10/5.15/6.1/6.6/6.12 variants.
- MT Manager 2.26.9 installed; MCP port confirmed as 8787 in MT settings; forwards 8787/8651/
65534 established.
## 3. Kernel-level content — unverified / inferred, with sources
No kernel-level mechanism in `kernel-and-environment-hardening.md` was executed, on this device
or any other. Specifically **unverified here**: eBPF kprobe/uprobe tracing, seccomp-BPF
installation into a target, KPM/LKM hooking, self-ptrace anti-anti-debug, and every hiding claim
of Shamiko / Zygisk-Assistant against a real detector (the module archives also record that
hiding was never validated against an actual detection scenario).
The **inferred** claims that carry external sources (fetched 2026-09, URLs in the reference
file):
| Claim | Source |
|---|---|
| KernelSU: kernel-based su, official support GKI 2.0 = kernel 5.10+ (Android 12-era), backport to 4.14 requires self-built kernel; module system conflicts with Magisk magic mount; su-only coexistence possible (kernel vs ramdisk patching) | tiann/KernelSU README + FAQ (github.com) |
| APatch: KernelPatch patches stock boot.img kernel (no kernel source needed — the difference from KernelSU); SuperKey-gated SuperCall syscall; KPM kernel-space modules with inline/syscall-table hooks; SELinux hooked not rewritten; Shamiko officially unsupported | apatch.dev FAQ |
| Shamiko: reads Magisk denylist but requires DenyList enforcement OFF; whitelist mode via `/data/adb/shamiko/whitelist` with documented performance cost | Shamiko README (LSPosed distribution) |
| Zygisk-Assistant: Magisk use = target on denylist + Enforce OFF; KernelSU/APatch use = ZygiskNext + "Umount modules/Exclude modifications" | snake-4/Zygisk-Assistant README |
| GKI 2.0 starts at android12-5.10; eBPF extension docs target GKI kernels | source.android.com (kernel architecture section) |
The version gate consequence (eBPF row closed on 4.14) is **observed** for this device (uname)
and **inferred** as a general gate from the GKI sources above.
## 4. Not run / open items
- Live MCP handshake: blocked on a human UI action (start APK MCP in MT). The probe script is
ready; re-run and record the real `initialize` + `tools/list` output when it happens.
- Zygisk-Assistant + MoveCertificate merge: pending the device reboot recorded in the module
archive; their post-merge behaviour is untested.
- No LSPosed hook module was built or run against a real target in either pass — the framework
is activated (observed), the module workflow remains inferred.
## 5. Reproduction one-liners
```
python skills/apk-reverse/scripts/mt_mcp_probe.py # probe + inventory or waiting help
adb forward --list # confirm 8787 forward exists
adb -s <serial> shell "su -c 'netstat -tln | grep 8787'" # device-side listen check
adb -s <serial> shell uname -r # the 4.14 kernel gate, observed
```
+567
View File
@@ -0,0 +1,567 @@
# Extension verification — LSPosed module route
Covers the 2026-09 extension pass that added `skills/apk-reverse/references/lsposed-and-modules.md`
and `skills/apk-reverse/scripts/lsposed_scaffold.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: rooted physical device, Android 11 (API 30), arm64-v8a,
Magisk alpha + Zygisk, LSPosed v1.9.2 (7024) activated, Zygisk-Assistant v2.1.4 installed, MT Manager
2.26.9 installed, one hardened Flutter sample installed (referred to below as *the sample*; no target
identity is recorded here). Host: Windows, JDK 17.0.4.1, Android build-tools 34.0.0, API 28
`android.jar`, Xposed `api-82` stub jar.
**Identifier convention:** every occurrence of the sample's package identifier in the transcripts
below is normalized to `<PKG>` (`<PKG>.MainActivity`, `<PKG>.BaseApplication`), and its sub-package
prefixes are shortened the same way. The transcripts are otherwise verbatim, including the class
names a hook reported — those are what make the evidence checkable.
This pass closes an item left open by the previous extension record, which noted that "no LSPosed hook
module was built or run against a real target" (`EXTENSION-kernel-ondevice.md` §4). The build half is
now **observed**; the injection half is **partially observed** and the gap is stated in §6.
---
## 1. Scaffold script — observed
### 1.1 `--help` and generation
```
$ python skills/apk-reverse/scripts/lsposed_scaffold.py --help
usage: lsposed_scaffold.py [-h] --package PACKAGE --name NAME
[--hook-target TARGETS] --out OUT
[--class-name CLASS_NAME] [--tag TAG]
[--description DESCRIPTION] [--min-sdk MIN_SDK]
[--target-sdk TARGET_SDK] [--hook-class HOOK_CLASS]
[--hook-method HOOK_METHOD]
[--hook-params HOOK_PARAMS] [--force]
```
Generation of a throwaway module, **observed**:
```
$ python skills/apk-reverse/scripts/lsposed_scaffold.py \
--package com.example.probe --name "Example probe" \
--hook-target com.example.target \
--hook-class com.example.target.Helper --hook-method check \
--hook-params "java.lang.String" --out <work>/scaffold-gen
[+] <work>/scaffold-gen/AndroidManifest.xml
[+] <work>/scaffold-gen/assets/xposed_init
[+] <work>/scaffold-gen/src/com/example/probe/MainHook.java
[+] <work>/scaffold-gen/README.md
```
`assets/xposed_init` contains exactly one line, `com.example.probe.MainHook` (**observed**). Re-running
without `--force` leaves existing files alone and says so, so the script is safe to re-run.
### 1.2 The generated source compiles as written
The point of the scaffold is that its output builds, so this was checked rather than assumed —
**observed**, exit code 0, four class files (entry class plus three anonymous hook classes):
```
$ javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp "<android.jar>;<api-82.jar>" \
-d <work>/build/classes <work>/scaffold-gen/src/com/example/probe/MainHook.java
$ echo $?
0
MainHook.class MainHook$1.class MainHook$2.class MainHook$3.class
```
## 2. Gradle-free build chain — observed end to end
Built with a parameterised driver that reproduces the chain documented in the reference. Times are
wall-clock on the host above; the module is one class.
| Step | Command shape | Time |
|---|---|---|
| compile | `javac -encoding UTF-8 -source 8 -target 8 -nowarn -cp <android.jar>;<api-82.jar> -d build/classes src/**/*.java` | 850 ms |
| jar | `jar cf build/classes.jar -C build/classes .` | 259 ms |
| dex | `d8.bat --min-api 24 --lib <android.jar> --output build build/classes.jar` | 1143 ms |
| link | `aapt2.exe link -o build/base.apk -I <android.jar> --manifest AndroidManifest.xml --min-sdk-version 24 --target-sdk-version 28 -A assets` | 126 ms |
| add dex | `(cd build && aapt.exe add unsigned.apk classes.dex)` | 97 ms |
| align | `zipalign.exe -f -p 4 build/unsigned.apk build/aligned.apk` | 76 ms |
| sign | `apksigner.bat sign --ks build/test.keystore --ks-pass pass:android --key-pass pass:android --v2-signing-enabled true --out build/module.apk build/aligned.apk` | 1794 ms |
| verify | `apksigner.bat verify --min-sdk-version 24 --print-certs build/module.apk` | 725 ms |
| | **total** | **5.27 s** |
Resulting artifact, **observed**: `module.apk` 12,745 bytes, `classes.dex` 5,024 bytes,
`apksigner verify` reporting v2 signature valid with signer `CN=module, O=local`. APK entry list
(**observed**, read back with zipfile):
```
AndroidManifest.xml 1680
resources.arsc 40
assets/xposed_init 27
classes.dex 5024
META-INF/MANIFEST.MF 375
```
A second, independent build of a hand-written one-class module (different package, different source)
produced the same shape and also verified, so the chain is not specific to the scaffold's output.
### 2.1 Three build-chain traps, all measured rather than assumed
These are the failures that cost time, quoted from the actual runs:
1. **`d8` rejects a directory as program input.** Pointing it at the compiled `classes/` directory
fails with:
```
Error in program input '<work>/build/classes':
com.android.tools.r8.internal.Sb: Unsupported source file type
Compilation failed
```
Jarring the classes first (`jar cf classes.jar -C classes .`) and passing the jar succeeds. This is
the opposite of what several command-line guides imply.
2. **`aapt add` fails with `Unable to add 'classes.dex' to 'unsigned.apk': file not found`** when the
dex is not in the current working directory. It resolves its argument relative to the cwd and stores
the same name, so the dex must be copied out of `dex/` to sit beside the apk.
3. **`jar` and `keytool` are not on `PATH` even where `javac` is.** On this host `javac` resolved to
Oracle's `javapath` shim while `keytool` was absent entirely; both tools had to be called from the
JDK's `bin` directory explicitly. A build script that assumes they are on `PATH` breaks on a
working machine.
Additionally the reference notes that `-bootclasspath` no longer exists on JDK 9+, so the Android API
comes from `-cp` alongside the Xposed stub jar — **observed** (that is the form used above, and it
compiled and dexed cleanly).
## 3. Module state on the device — observed
A previously built probe module was already installed on the device from an earlier pass. Its state
was read, not modified, except where noted:
```
$ adb -s <serial> shell "pm path com.t1.lsphook"
package:/data/app/~~<hash>==/com.t1.lsphook--<hash>==/base.apk
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep -E 'User 0|enabled'"
User 0: ceDataInode=35322 installed=true ... stopped=true notLaunched=true enabled=0 ofs=0 ...
```
`enabled=0` here is the **package manager's** component state, not the LSPosed module switch. Enabling
the package was the one state change this pass made on the device:
```
$ adb -s <serial> shell "pm enable com.t1.lsphook"
Package com.t1.lsphook new state: enabled
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep 'User 0'"
User 0: ... enabled=1 ...
```
LSPosed itself, **observed**:
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/module.prop'"
id=zygisk_lsposed
name=Zygisk - LSPosed
version=v1.9.2 (7024)
$ adb -s <serial> shell "su -c 'ps -A -o PID,NAME | grep lspd'"
744 lspd
$ adb -s <serial> shell "dumpsys package org.lsposed.manager | grep versionName"
versionName=1.9.2
```
### 3.1 The installed module could never have run — observed
Before reusing that module for an injection attempt, its internals were checked. `assets/xposed_init`
declared one entry class while the dex in the same APK contained a different one:
```
$ python -c "... zipfile.ZipFile('installed-module.apk').read('assets/xposed_init') ..."
com.t1.lsphook.MainHook
$ dexdump -f installed.dex | grep 'Class descriptor'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$1;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$2;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook$3;'
Class descriptor : 'Lcom/example/t1probe/ProbeHook;'
```
The entry class named in `xposed_init` does not exist in that dex (`MainHook` appears nowhere in the
string table; `ProbeHook` does). LSPosed resolves the class named by `xposed_init`, so this module
could only fail at load — and it would fail **silently** from the outside, which is precisely the
failure mode the reference lists. Recorded because "the package is installed, the `xposedmodule`
meta-data is present and `pm path` returns a path" is still not evidence that the module can run:
the entry class has to exist.
Two consequences measured in the same check:
- A second APK produced by that earlier pass **did** contain the matching class, but was signed with a
different key — signer DN `O=local` against the installed build's `O=tools`, SHA-256
`892c18be…` against `9c86f386…`. Replacing the installed module therefore requires an uninstall
first; `adb install -r` would have failed on a signature mismatch.
- A module rebuilt from the same source with this pass's chain (`build_module.ps1`, the chain in §2)
was verified to contain the entry class, the `T1HOOK` log tag, the `injected: ` literal and the
target package string in its dex, and is the artifact intended for the injection attempt in §6.
Building it took 5.07 s and produced 12,745 bytes.
The **linked** manifest was checked as well, because LSPosed reads the compiled manifest rather than
the source file. `aapt2 dump xmltree <apk> --file AndroidManifest.xml` on the rebuilt APK shows all
three keys intact (`xposedmodule=true`, `xposeddescription="Minimal lifecycle hook probe for route
verification"`, `xposedminversion=82`). The broken APK's manifest was **equally intact** — which is the
point of recording this pair: a correct manifest, an installed package and a valid signature still do
not mean the module can run. Entry-class existence is a separate check, and it is the one that failed
here.
### 3.2 Replacing it, and an install failure that belongs to the ROM — observed
The broken module was removed and the rebuilt one installed. The install path is worth recording
because it failed for a reason that has nothing to do with LSPosed:
```
$ adb -s <serial> uninstall com.t1.lsphook
Success
$ adb -s <serial> install module.apk
[timed out after 240000 ms] # never landed; pm path returned nothing afterwards
$ adb -s <serial> push module.apk /data/local/tmp/<work>/module.apk
$ adb -s <serial> shell "pm install -r /data/local/tmp/<work>/module.apk"
Failure [-99]
$ adb -s <serial> shell "su -c 'pm install -r /data/local/tmp/<work>/module.apk'"
Success
```
Three findings from that sequence:
- `Failure [-99]` from a shell-identity `pm install`, with logcat showing the ROM's own security
centre (`com.coloros.safecenter`) taking the foreground from `com.android.packageinstaller` at the
same moment. The vendor layer is intercepting the install; the framework is not rejecting the APK.
- The same APK installs without complaint as **root**. That is the working path on this ROM, and the
one used here.
- The streaming `adb install` did not merely fail — it **hung for four minutes** and left no package
behind. Retrying as root is cheaper than waiting that out.
Installed state afterwards, **observed**:
```
$ adb -s <serial> shell "pm path com.t1.lsphook"
package:/data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/com.t1.lsphook-<hash>/base.apk
$ adb -s <serial> shell "pm enable com.t1.lsphook"
Package com.t1.lsphook new state: enabled
$ adb -s <serial> shell "dumpsys package com.t1.lsphook | grep 'User 0'"
User 0: ... installed=true ... enabled=1 ...
```
So the **package-side** half of the deployment was completed in this pass: correct entry class,
correct meta-data, installed, PM-enabled. The scope half did not happen, for the reason in §6.
## 4. Where LSPosed keeps module and scope state — observed
The configuration database was copied off the device (three WAL files) and parsed read-only:
```
$ adb -s <serial> shell "su -c 'cp -f /data/adb/lspd/config/modules_config.db* /data/local/tmp/<work>/'"
$ adb -s <serial> pull /data/local/tmp/<work> ...
```
Schema and contents, **observed** (SQLite):
```
CREATE TABLE modules (mid integer PRIMARY KEY AUTOINCREMENT, module_pkg_name text NOT NULL UNIQUE,
apk_path text NOT NULL, enabled BOOLEAN DEFAULT 0 CHECK (enabled IN (0,1)))
CREATE TABLE scope (mid integer, app_pkg_name text NOT NULL, user_id integer NOT NULL,
PRIMARY KEY (mid, app_pkg_name, user_id), ...)
CREATE TABLE configs (module_pkg_name text NOT NULL, user_id integer NOT NULL, `group` text NOT NULL,
`key` text NOT NULL, data blob NOT NULL, ...)
== table modules ==
(1, 'lspd', '/data/adb/modules/zygisk_lsposed/manager.apk', 0)
(2, 'com.t1.lsphook', '/data/app/~~<hash>==/com.t1.lsphook--<hash>==/base.apk', 0)
== table scope ==
(no rows)
== table configs ==
('lspd', 0, 'config', 'misc_path', <blob>)
```
Three facts worth keeping from this, because each maps to a documented failure mode:
- `enabled` in the LSPosed database is a **separate** bit from the package manager's `enabled` state.
Both were 0 for the probe module when read, and only the package-manager one was changed here.
- The `scope` table was **empty**: an installed module that is not scoped is injected nowhere, which is
why "installed" is not evidence of injection.
- `sqlite_sequence.modules` was 3 while only ids 1 and 2 exist — a module had been registered and
removed in an earlier pass. The ids are not a count of what is loaded.
## 5. lspd lifecycle — observed from its own startup scripts
Read from the installed module directory, **observed**:
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/service.sh'"
...
unshare -m sh -c "$MODDIR/daemon --from-service $@&"
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk_lsposed/daemon'"
...
exec /system/bin/app_process $java_options /system/bin --nice-name=lspd org.lsposed.lspd.Main "$@"
```
`service.sh` is Magisk's late-start service script, which runs once per boot. **Inferred** (not
executed, deliberately): an `lspd` killed from a root shell is not restarted by anything reachable from
that shell, and the cheap-looking "just restart the daemon to pick up my database edit" move would take
the framework's scope decisions away from every subsequently forked process. This pass therefore did
**not** kill `lspd`, and the reference says so explicitly.
Related **inferred** consequence, stated in the reference: hand-editing `modules_config.db` is not a
supported way to change scope, because the daemon serves decisions from its live copy and the file is
its persisted state. The manager path updates both.
## 6. Injection: the framework had to be repaired first, then it worked — observed
With the module installed and PM-enabled, the next step was LSPosed Manager: enable the module there
and add the sample to its scope. That is where the pass stopped — and the reason is a property of the
device's LSPosed state at the time, not of the module.
Manager opened normally but reported the framework as **not installed**. Observed both on screen and in
the dumped node tree:
```
node: "未安装" / "LSPosed 未安装" (overview status card)
node: "API 版本" -> "未安装"
node: "Xposed API 调用保护" -> "未安装"
node: "Dex 优化器包装" -> "未安装"
bottom navigation: 仓库 / 概览 / 设置 # there is no module-list tab in this state
```
The daemon was still alive (`ps -A` → `744 lspd`), so this is not "LSPosed is absent". Two further
observations pin down what it actually is:
- **`lspd` stopped updating its own configuration.** The database still listed the **uninstalled**
module under its old, no-longer-existing path, and never gained an entry for the freshly installed
one. Its size and WAL set were unchanged across the whole pass (156,376 bytes in three files, the
same as before the uninstall):
```
== table modules ==
(1, 'lspd', '/data/adb/modules/zygisk_lsposed/manager.apk', 0)
(2, 'com.t1.lsphook', '/data/app/~~wiGaHYIZn5j3Am2JrJvA5g==/com.t1.lsphook-<hash>/base.apk', 0)
^ that codePath no longer exists: the package was uninstalled this pass
== table scope ==
(still empty)
```
A daemon that had re-scanned packages would have dropped id 2 and registered the new install path.
- **Its log records the framework bridge dying and never recovering.** The verbose capture contains
`system server died`, `am is dead`, then `no response from bridge, retry in 1s` on a loop, and
`Magisk: zygote crashed too many times, rolling-back`.
`[inferred]` The consistent explanation is that the half of the framework that lives in
`system_server`/`zygote` was lost (the same capture shows Magisk rolling a zygote restart back),
leaving `lspd` as a live process with nothing to talk to. Manager asks the framework for its state,
gets nothing, and falls back to its "not installed" onboarding UI — which has no module list, so
**scope cannot be configured through the UI at all** in that state.
Recorded consequences, deliberately not worked around:
- Scope was **not** configured, so no module was injected into any process, so no `T1HOOK` line could
appear. The injection claim therefore remains **unverified on this device**, stated plainly rather
than replaced by a weaker proxy (such as "the module is installed" or "`lspd` is running", both of
which were true the whole time and neither of which means anything about injection).
- The pass did not try to "fix" it by killing `lspd`, hand-editing the database, or restarting
`zygote`: the first two are explained in the reference's §Deploy and the third would have destroyed
concurrent instrumentation work another agent was running on the same device.
- The recovery action is a **device reboot**, which re-initialises Zygisk and `lspd`. It was not taken
because the device was shared and under heavy load. It is the concrete next step, and it is cheap:
after a reboot, open Manager, expect the module list to be present, enable the module, tick the
sample in its scope, then watch `logcat -T <ts> -s T1HOOK` while the sample restarts itself. Per the
reference, the scope change applies to the target's **next** process start, so a self-restarting
target is the ideal case — no spawn, no force-stop, no attach needed.
- Manager was returned to the background afterwards (`mCurrentFocus` back to the launcher). The sample
was never spawned, force-stopped or attached to during this pass.
### 6.1 The repair — observed
A reboot was approved and taken. It is the whole fix, and it is cheap:
```
$ adb -s <serial> reboot
[ 0 s] boot_completed='' ... polled with getprop, no fixed sleep
[ 31 s] boot_completed=1
--- post-boot ---
load average 17.40 4.37 1.46 (was 29.58 31.87 33.61 before)
746 lspd (new pid; was 744)
MoveCertificate zygisk-assistant zygisk_lsposed (all three modules present)
```
Manager then reported the framework normally, and the **module tab appeared** — the recovery
criterion. Observed on the overview page: `已激活 / 1.9.2 (7024) - Zygisk`, `API 版本 100`,
`Xposed API 调用保护 已启用`, `Dex 优化器包装 支持`; bottom navigation went from three tabs to five
(仓库 / 模块 / 概览 / 日志 / 设置). The module package itself survived the reboot
(`pm path` intact, `enabled=1`), so no reinstall was needed — the ROM install workaround in §3.2 only
matters on a fresh install.
### 6.2 Configuring scope through the UI — observed
Done strictly by reading coordinates out of a `uiautomator dump`, never by tapping a guess:
| Step | What was read from the dump | Coordinate tapped | Result |
|---|---|---|---|
| module tab | `[216,2160][432,2293]` | (324, 2260) | module list opened |
| module row | `T1 LSPosed probe`, row `[48,611][1008,793]` | (528, 702) | detail page |
| enable switch | switch node `[804,651][972,795]` | (888, 723) | `checked=true` |
| scope row | `<PKG>`, row `[252,1855][591,1912]` | (528, 1878) | `checked=true` |
The scope list had to be scrolled five screens to reach the target (the list is alphabetical by
display name, not by package). The database then confirmed the change, and — for the first time in
this whole exercise — **`lspd` wrote to it**:
```
== table modules ==
(2, 'com.t1.lsphook', '/data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/com.t1.lsphook-<hash>/base.apk', 1)
enabled ^ = 1
== table scope ==
(2, '<PKG>', 0)
```
The database file grew from 156,376 to 201,696 bytes across the pass. Both facts together are the
proof that the framework, not just its daemon process, came back.
### 6.3 The injection line, and where it actually appears — observed
The target was launched once with `am start`, and the module fired on **every** subsequent process
start. Nine consecutive target starts produced nine full injection blocks:
```
Loading legacy module com.t1.lsphook from /data/app/~~c7aQQNK-GiP3BKd1g1iEvw==/.../base.apk
Loading class com.t1.lsphook.MainHook
T1HOOK injected: <PKG> / <PKG> cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK loaders at-handleLoadPackage:
0: dalvik.system.PathClassLoader c226f2f
1: java.lang.BootClassLoader 8454a72
T1HOOK hook installed: Application.attach
T1HOOK hook installed: Activity.onCreate
T1HOOK hook installed: Application.onCreate
T1HOOK Application.attach #1 this=<PKG>.BaseApplication cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK Application.attach #2 this=com.stub.StubApp cl=dalvik.system.PathClassLoader@c226f2f
T1HOOK Application.onCreate this=com.stub.StubApp
T1HOOK Application.onCreate this=<PKG>.BaseApplication
T1HOOK Activity.onCreate -> <PKG>.MainActivity
T1HOOK injected: com.google.android.webview / <PKG> cl=dalvik.system.PathClassLoader@141f448
T1HOOK Activity.onCreate -> com.qq.e.ads.PortraitADActivity
```
**The verification criterion had to be corrected, and this is the most transferable finding of the
pass.** The reference (and this record's earlier draft, and the scaffold's generated README) said to
watch `logcat -s <TAG>`. On this device that is **empty** — as is `logcat -s LSPosed-Bridge`, and as
is a full-buffer `grep -i T1HOOK`. The module's output exists only in
```
/data/adb/lspd/log/modules_<timestamp>.log
```
which is the same file that earlier appeared to contain nothing but `Logd maybe crashed (err=Socket
operation on non-socket), retrying in 1s...`. The two facts are related: the ROM's `logd` route is
broken, so LSPosed's logcat channel delivers nothing while its file channel works normally. Anyone
debugging a module on such a device with `logcat` alone would conclude "the module never ran" while
looking at nine successful injections. Read **both**: `logcat -s <TAG>` where the platform is healthy,
`/data/adb/lspd/log/modules_*.log` always. The evidence file for this pass is 201 lines, of which 126
are `T1HOOK`.
Three further observations from the same log, none of which needed any extra instrumentation:
- **The packer's double Application is visible in the hook order.** `Application.attach` fires twice —
first for the app's real `BaseApplication`, then for `com.stub.StubApp` — and `onCreate` then runs
for the stub *before* the real Application. Hooks installed at `handleLoadPackage` time survive
this swap, and by `Activity.onCreate` the classes reported are the **unpacked** ones
(`<PKG>.MainActivity`), not the stub's. That is the concrete value of this route on a
packed target: the Java layer is reachable without touching the APK.
- **Scope is per app, not per process.** The module also injected into the app's WebView process
(`com.google.android.webview / <PKG>`) and reported the ad SDK's activity
(`com.qq.e.ads.PortraitADActivity`) — worth knowing before scoping a module widely.
- **The target's self-restart cycle is unaffected by injection.** Nine pids in ~3 minutes, one every
22–23 s (8111 → 9392 → 10557 → 11453 → 12345 → 13236 → 14155 → 15061 → 15934), each run reaching
`MainActivity.onCreate`, the WebView process and an ad activity before ending. The module only
observes, so this says nothing about cause; and because a reboot changes many variables at once, any
comparison with the pre-reboot cycle (a different observer measured 10–20 s) is **inferred at best
and not a controlled result**.
## 7. Log surfaces — observed, and one trap
```
$ adb -s <serial> shell "su -c 'ls -la /data/adb/lspd/log/'"
kmsg.log 993690
modules_2026-09-21T18:44:31.523.log 401
props.txt 46937
verbose_2026-09-21T18:44:31.518.log 178482
```
The `modules_*.log` file is **not** module output. Its entire content, **observed**:
```
----part 1 start----
Logd maybe crashed (err=Socket operation on non-socket), retrying in 1s...
(repeated)
```
`verbose_*.log` is a full logcat snapshot that includes unrelated system events (it contained, for
example, Magisk zygote-restart lines and OEM process deaths from an earlier incident). Useful for
correlating a run with system state; useless as a module log. The injection proof must come from
`logcat -s <TAG>`, which is what the reference and the scaffold's README recommend.
## 8. Root-hiding layer — observed
```
$ adb -s <serial> shell "su -c 'cat /data/adb/modules/zygisk-assistant/module.prop'"
id=zygisk-assistant
name=Zygisk Assistant
version=v2.1.4 (1013f8a-release)
description=A Zygisk module to hide root.
$ adb -s <serial> shell "su -c 'magisk --denylist status'"
Denylist is enforced
$ adb -s <serial> shell "su -c 'magisk --denylist ls'"
(no output)
$ adb -s <serial> shell "su -c 'ls /data/adb/shamiko'"
ls: /data/adb/shamiko: No such file or directory
```
So at pass time the DenyList was **enforced but empty**: no process was being hidden from anything,
while LSPosed and `lspd` were running normally, and Shamiko was not installed. Worth recording because
"the target knows it is on a rooted device" is frequently an environment fact of exactly this shape,
and it is cheaper to check than to escalate (`references/detection-and-anti-analysis.md`).
The Shamiko/Zygisk-Assistant comparison in the reference is **inferred** from their own documentation
plus the on-device module metadata above; the hiding behaviour itself was not exercised against any
detector in this pass.
## 9. Not run / open items
- **Injection is not verified, and the blocker is now identified rather than unknown.** §6 records the
state that stopped it: Manager reporting the framework as not installed, `lspd` alive but no longer
updating its own configuration, and its log showing the framework bridge die and never recover. The
action known to clear that state is a **device reboot**; after it the remaining sequence is short
(enable the module in Manager, tick the sample in its scope, read `logcat -T <ts> -s T1HOOK` on the
target's next self-restart). Until someone does that, every statement in the reference about a module
*actually being injected* rests on framework documentation rather than on this bench.
- Editing `modules_config.db` directly was **not** attempted, on purpose: §5 explains why it would have
been an unreliable experiment, and the unsupported-ness of that path is recorded as inferred rather
than tested by breaking a working setup.
- Killing/restarting `lspd` was **not** attempted, for the same reason.
- The module's effect on a hardened, detection-aware target was not measured, because injection was not
reached. Consequently nothing here validates or contradicts the ClassLoader-count detection reported
externally (see the reference's *What detects you anyway*); that source is cited, not reproduced.
- The vendor install interception (§3.2) was worked around by installing as root; whether it can be
disabled from the ROM's own settings was **not** investigated, and whether `Failure [-99]`
reproduces on an idle device or on other vendor builds is **unverified**.
- `scaffold.py` was exercised on Windows only; the POSIX path in the generated README's command block
is **unverified** (the commands are standard, but not run here).
## 10. Reproduction one-liners
```
python skills/apk-reverse/scripts/lsposed_scaffold.py --help # argparse surface
python skills/apk-reverse/scripts/lsposed_scaffold.py --package <pkg> --name "<name>" \
--hook-target <target.pkg> --out <dir> # writes 4 files
# build (any ABI-neutral host with JDK + build-tools + android.jar + api-82.jar)
javac -source 8 -target 8 -cp "<android.jar>:<api-82.jar>" -d build/classes src/**/*.java
jar cf build/classes.jar -C build/classes . # d8 needs a jar, not a dir
d8 --min-api 24 --lib <android.jar> --output build build/classes.jar
aapt2 link -o build/base.apk -I <android.jar> --manifest AndroidManifest.xml \
--min-sdk-version 24 --target-sdk-version 28 -A assets
(cd build && aapt add unsigned.apk classes.dex) # dex must be in the cwd
zipalign -f -p 4 build/unsigned.apk build/aligned.apk
apksigner sign --ks build/test.keystore --ks-pass pass:android --key-pass pass:android \
--v2-signing-enabled true --out build/module.apk build/aligned.apk
apksigner verify --print-certs build/module.apk
adb -s <serial> shell "pm path <module.pkg>" # installed?
adb -s <serial> shell "dumpsys package <module.pkg> | grep 'User 0'" # pm enabled state
adb -s <serial> shell "su -c 'ps -A -o PID,NAME | grep lspd'" # daemon alive
adb -s <serial> shell "su -c 'magisk --denylist status'" # hiding layer state
```
@@ -0,0 +1,270 @@
# Extension verification — native DBI/deobfuscation and protocol reverse engineering
Covers the 2026-09 extension pass that added `references/native-dbi-and-deobfuscation.md` and
`references/protocol-reverse.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 for this pass: Windows 11 host, Python 3.14.0, host frida 16.7.19
(frida-tools 13.7.1); rooted physical device, Android 11 (API 30), arm64-v8a, 8-core mid-range SoC,
with an on-device frida-server 16.7.19 that was already running when the pass started and was never
killed or restarted (pid 23405 at pass time). All Stalker work ran against system processes; the
hardened Flutter sample under analysis by a different task on the same device was never attached to,
spawned, or stopped by this pass.
**The headline result is a negative one, and it is the reason this record matters: on this
combination, Stalker follow installs successfully and produces no events.** The record separates
that finding from a crash observed later in the same session; they are different observations with
different causes and must not be merged.
---
## 1. Scripts under test — observed
`skills/apk-reverse/scripts/stalker_trace.js` (456 lines) and
`skills/apk-reverse/scripts/stalker_report.py` (236 lines) were already on disk from an earlier
attempt of this task; this pass read them, changed neither, and exercised both.
```
$ node --check skills/apk-reverse/scripts/stalker_trace.js
syntax check exit = 0
$ python skills/apk-reverse/scripts/stalker_report.py --help
usage: stalker_report.py [-h] [--top TOP] [--skeleton SKELETON]
[--edges EDGES] [--seq SEQ] [--json PATH] [--quiet]
log
Summarize a stalker_trace.js log into a block histogram, a first-visit CFG
skeleton, and call-edge tables.
```
Two workbench drivers were written for this pass and live in `tools/_work/t4-dbi/` (not part of the
skill): `drive_usb.py`, which attaches over USB and calls
`rpc.exports.config(...)` / `start(tid)` explicitly, and `pb_selftest.py` (§7).
Why a second driver was needed at all: `scripts/run_probe.py` injects the probe faithfully (its
`--via usb` path attached and logged `READY`/`TRIG`/`DONE` correctly), but it never calls the
script's rpc exports, so `stalker_trace.js` runs with its built-in `autoStart` and follows whichever
thread `Process.getMainThreadId()` reports. That thread is not the Android main thread on this
device (§3), and there is no way to override it from that driver.
## 2. Report parser validated against a hand-written fixture — observed
`tools/_work/t4-dbi/synthetic.log` is a **hand-written** fixture (16 lines, 1,008 B) in the script's
documented output grammar, including lines wrapped the way `run_probe.py` wraps them. It is not a
device run and proves nothing about Stalker; it proves the parser reads the grammar.
```
$ python skills/apk-reverse/scripts/stalker_report.py tools/_work/t4-dbi/synthetic.log \
--top 4 --skeleton 6 --edges 4 --seq 8
module libdemo.so base=0x7abc000000 size=1048576 (target)
unique blocks first-seen (BB) : 3
block executions (BLK) : 7
call edges (CALL) : 2
DONE reason=timeout truncated=0 (device-side counts: bb=3 blk=7 call=2)
== execution histogram: top 4 (dispatcher candidates) ==
rank count pct block firstSeen
1 4 57.1% libdemo.so+0x9f6c0 #1
2 2 28.6% libdemo.so+0x9f710 #2
3 1 14.3% libdemo.so+0x9f800 #5
== call edges (top 4 of 2) ==
1 0x7ab3001020 -> libdemo.so+0x9f6c0
1 libdemo.so+0x9f800 -> libdemo.so+0x9f710
== collapsed execution sequence (first 6 runs of 6) ==
libdemo.so+0x9f6c0 -> libdemo.so+0x9f710 -> libdemo.so+0x9f6c0 -> libdemo.so+0x9f800 ->
libdemo.so+0x9f6c0*2 -> libdemo.so+0x9f710
```
The four reductions the reference promises — histogram, first-visit skeleton, call edges, collapsed
sequence — all behave as documented, including the `*N` repeat collapse that makes a
dispatcher/real-block rhythm legible. `--quiet` and `--json` paths were exercised too (§3, §4).
## 3. Device attempt A — follow a chosen thread, zero events (observed)
Two system processes, each attached for 8 s with `followMs=4000`, target module `libc.so`,
`autoStart` disabled by the driver, and the followed tid passed explicitly.
```
$ python tools/_work/t4-dbi/drive_usb.py --js skills/apk-reverse/scripts/stalker_trace.js \
--pid <settings pid> --seconds 8 --log tools/_work/t4-dbi/run2_settings_main.log
READY stalker_trace loaded; target=libc.so trigger={"kind":"main"} followMs=4000 maxBlocks=100000 moduleLoaded=true
TRIG following tid=26261 (main-thread follow (pid=19938)) module=libc.so base=0x721be88000
TRACE MOD libc.so base=0x721be88000 size=999424 path=/apex/com.android.runtime/lib64/bionic/libc.so
DONE reason=reconfig blocks=0 blk=0 calls=0 truncated=0
TRIG main trigger armed (autoStart=false); call start()
[rpc] start(19938) -> {"following": true, "followingTid": 19938}
DONE reason=timeout blocks=0 blk=0 calls=0 truncated=0
```
Facts worth keeping separate from the interpretation:
- `Process.getMainThreadId()` returned **26261** for a process whose pid is **19938**. On Android the
main thread's tid equals the pid, so the script's default thread selection followed a different
thread. This is observed on this device/version and is the reason the reference tells the reader to
pass `start(tid)` explicitly.
- Re-running with `start(19938)` — the real main thread — produced **the same zero**:
`DONE reason=timeout blocks=0 blk=0 calls=0`.
- A third run against a system UI process (`run3_systemui_libc.log`, same `followMs=4000`)
reproduced it identically. `stalker_report.py --quiet` on all three logs prints the same summary line
(`unique blocks first-seen (BB): 0`, `block executions (BLK): 0`, `call edges (CALL): 0`) and the
script's own "no target-module blocks at all" diagnosis.
**Interpretation: unverified as to cause.** The pipeline was healthy up to `TRIG following`, the
module was loaded and in `maps`, and the Interceptor trigger demonstrably fired (§4) — so the
narrowest supportable statement is "follow installs, events do not arrive in `onReceive`". Whether
that is a frida-server build property, a ROM restriction, or something about the followed threads was
not established and is not claimed. No trace was successfully recorded on this device during this
pass.
## 4. Device attempt B — hot-function export trigger, zero events (observed)
The `export` trigger kind was exercised with `malloc` in `libc.so` as the trigger target, so that
each call would start a follow and each return would end it. The trigger fired repeatedly for the
whole window.
```
$ python tools/_work/t4-dbi/drive_usb.py --js skills/apk-reverse/scripts/stalker_trace.js \
--pid <systemui pid> --seconds 6 --log tools/_work/t4-dbi/run4_systemui_malloc.log \
--cfg '{"targetModule":"libc.so","trigger":{"kind":"export","module":"libc.so","export":"malloc"},"followMs":1000,"maxBlocks":20000}'
$ lines = 609 follows = 201 done_lines = 201 nonzero_blk = 0
# per-firing shape:
TRIG following tid=18915 (trigger 0x721becbbf0) module=libc.so base=0x721be88000
DONE reason=trigger-leave blocks=0 blk=0 calls=0 truncated=0
```
This is the strongest evidence for the §3 statement: **201 fully-formed follow cycles, each with a
clean `DONE`, and not one reported block in any of them.** A trigger that never fires would show
`TRIG-FAIL` or a single cycle; a filter that dropped everything would still be consistent with this,
which is why §3's claim is deliberately limited to "events do not arrive".
## 5. Device attempt C — a system UI process died during the hot-trigger run (observed)
Same run as §4, minutes later, on the same pid. The process died and was restarted by the system.
```
$ adb shell pidof com.android.systemui
18030 # before
26932 # after
$ adb shell "su -c 'logcat -b crash -d -t 80'"
09-21 20:22:25.327 ... F DEBUG : pc 00000070cf8ae464 pst 0000000040001000
09-21 20:22:25.719 ... F DEBUG : backtrace:
09-21 20:22:25.719 ... F DEBUG : #00 pc 000000000001f464 <anonymous:70cf88f000>
09-21 20:22:25.719 ... F DEBUG : #01 pc 00000000004d0b64 /apex/com.android.art/lib64/libart.so
(art::Reference_getReferent(_JNIEnv*, _jobject*)+44)
09-21 20:22:25.719 ... F DEBUG : #03 pc 000000000008220c boot.oat (art_jni_trampoline+124)
09-21 20:22:25.719 ... F DEBUG : #08 pc 0000000000714240 boot-framework.oat (android.os.Looper.loop+2176)
```
Reading of the record, stated as an attribution hypothesis rather than a proven mechanism: frame
`#00` sits in an **anonymous executable region** (`<anonymous:70cf88f000>`), which is the shape of a
DBI's translated-code cache rather than of a file-backed library, and the return path leads into
`libart`. The condition that distinguishes this run from §3/§4 is **hundreds of follow/unfollow
cycles on a hot function**, so the attributed cause is that cycle — a follow/unfollow race against
ART's own execution state — not "Stalker is unusable". The engine-level question (would a single
long follow of the same thread also crash it?) was not tested and remains **unverified**.
The consequence written into the reference is therefore operational, not diagnostic: *do not use a
hot function as a follow trigger, and do not build a per-call follow/unfollow cycle.* Note also that
this crash is **not** the explanation for the zero-event results in §3/§4: those runs produced their
zeros before any death, on two other processes, and one of them never touched an export trigger.
## 6. Cross-task contamination on a shared device — observed (reported by the Lead)
While §3–§5 were running, the device's `load average` was **34.11** (`adb shell uptime`, 1:38 after
boot) on an 8-core mid-range phone, and a dynamic job belonging to **another task on the same
device** — a dex-dumping attach against the hardened Flutter sample — was destroyed mid-flight with
`script has been destroyed` and a terminating process. Both facts are the Lead's observation of its
own job, quoted here because they are part of this pass's outcome; no claim is made that the sample
was affected for any reason other than device load, and the sample's own pid changes observed
independently during that window are **not** attributed to this pass.
The rule this produced, added to the reference's failure modes: on a shared device, a heavy DBI run
is not an isolated experiment. One job pushing the device to load 30+ makes every *other* dynamic
result on that device unattributable, which is `references/long-task-discipline.md` §single-variable
discipline applied to hardware rather than to a patch. Practically: announce the window, keep
`followMs` in the low seconds, cap `maxBlocks`, attach rather than spawn, and treat a
`script has been destroyed` in a *different* job as a signal that your own load is the variable.
## 7. Protobuf wire format self-test — observed
`tools/_work/t4-dbi/pb_selftest.py` (workbench) encodes a message by hand and decodes it by walking
the wire format, with no `protoc`, no schema, and no third-party protobuf runtime. It exists to give
`references/protocol-reverse.md` §1 an output behind its claims.
```
$ python tools/_work/t4-dbi/pb_selftest.py
== canonical varint check ==
varint(0 ) = 00 ok
varint(150 ) = 9601 ok
varint(300 ) = ac02 ok
varint(86942 ) = 9ea705 ok
== fixture bytes ==
nested payload (5 B): 0801120178
packed payload (6 B): 038e029ea705
message (29 B): 089601120774657374696e671a0508011201782206038e029ea7052800
== schema-free decode (the shape protoc --decode_raw produces) ==
field 1 varint 150
field 2 len-delimited(7) str 'testing'
field 3 len-delimited(5) nested message:
field 1 varint 1
field 2 len-delimited(1) str 'x'
field 4 len-delimited(6) hex '038e029ea705'
field 5 varint 0
== what the decode cannot tell you ==
explicit 0 at field 5 : 2800
field 5 never written : b''
-> proto3 cannot distinguish these two on the wire; a decoded "0" is not evidence the sender set the field.
-> field numbers in the walk are local to their message; only a schema says which message field 3 belongs to.
== re-encode round trip ==
rebuild from the walked rows equals original: True
```
Two of the reference's claims are now measured rather than repeated: the packed repeated field
(`3, 270, 86942`) is reported by a schema-free decode as opaque hex because element boundaries need a
schema, and an explicitly written proto3 zero is byte-identical to an absent field. The round-trip
check is also load-bearing as a testing habit: the first version of this script printed
`equals original: False`, and the defect was the decoder's own string/bytes ambiguity — the same
false-confidence trap the reference warns about for third-party decoders.
## 8. Not run, and therefore unverified
Everything below is stated in the references as inferred or unverified, and was **not** exercised in
this pass:
- Any successful Stalker trace. No histogram, no dispatcher identification, and no CFG skeleton was
produced from real device execution; §2's fixture is hand-written.
- Whether Stalker works on this device under a different frida-server build, a different ROM, or with
a single long follow instead of per-call cycles.
- `events.exec` and `events.ret` (the per-instruction and return-edge paths) — never enabled.
- QBDI / frida-qbdi, angr, Triton, unidbg, Unicorn: not installed, not run. §4/§5 of
`native-dbi-and-deobfuscation.md` are method descriptions.
- Trace-driven deflattening end to end, including the state-variable identification and the LIEF
rewrite. The community write-up cited there was read, not reproduced; its performance numbers are
the author's.
- `protoc`, `protobuf-inspector`, `blackboxprotobuf`, `pbtk`/`protodump`, `grpcurl`: not installed,
not run. The schema-recovery routes in `protocol-reverse.md` §2 are inferred from generated-code
markers, not demonstrated on a real APK.
- gRPC and QUIC/HTTP3 capture: no HTTP/2 or HTTP/3 client was exercised; §3/§4 of
`protocol-reverse.md` are unverified.
- `universal-flutter-ssl-pinning` / PyGhidra discovery, and the Frida/Renef output it generates: not
reproduced. The README's own testing table (Google Flutter arm64-v8a, Shorebird builds, Ghidra
12.x, Frida 16.x/17.x) is the only evidence behind that section.
- The `MoveCertificate` module's interaction with a Flutter client: not tested. The reference states
the boundary conditionally (store-reading clients only), not as a measured result.
## 9. What this pass licenses
It licenses the following statements and nothing wider: the trace/report toolchain is syntactically
sound and its parser is verified against its documented grammar; an Android 11 arm64 device with
frida-server 16.7.19 accepted Stalker follows and delivered no events, so *a zero-block trace is not
by itself evidence about the target*; a per-call follow on a hot export killed a system process; and
the protobuf wire-format rules the new reference teaches are measured on a fixture, including the two
traps (packed fields, explicit zeros) that a decode cannot resolve without a schema.
@@ -0,0 +1,256 @@
# Extension verification — root-side memory dump (the route that does not use frida)
Covers the 2026-09 extension pass that added the §Dumping when frida is refused section
to `references/advanced-unpacking.md`. It is the follow-up to
`docs/tool-verification/EXTENSION-device-run.md`, which established that
`frida-dexdump` is refused by this target (attach: `script has been destroyed`, twice,
single-variable repeat) — that file is the background, not repeated here. This pass
asks the next question: **can root read the process's memory directly, with no
instrumentation present at all?**
Strength labels, as defined in this directory's README:
- **observed** — reproduced here, with the exact command and its output.
- **inferred** — follows from observed facts, but the step itself was not executed.
- **unverified** — assumed, or reported by a tool and not independently confirmed.
**Target identity is deliberately absent**; the package appears as `<PKG>`. The
findings are about the route, the device shell and the tooling, and stay valid
whichever hardened APK produced them.
## Environment (observed)
| Item | Value |
|---|---|
| Device | the same physical arm64 device as `EXTENSION-device-run.md`: Android 11 (API 30), kernel 4.14.186, Magisk root |
| Instrumentation | **none** — the device-side `frida-server` was left stopped for the whole pass; `ps -A \| grep -i frida` returned nothing before and after |
| Sample | 360-jiagu hardened, Flutter AOT payload, installed and untouched (no reinstall, no repack) |
| Root access | `adb shell 'su -c "…"` — full root, no `avc: denied` in any read attempt below |
| Device shell | toybox (`grep`, `dd`, `ps`, `stat`, `awk`, `sed`); no `findstr`, no `ls --time-style`, no `map_files` entries |
Two shell facts were measured before anything else, because both silently corrupt a
naive dump and neither announces itself:
```
$ adb shell 'su -c "grep --help"' # → -a -b -o -z -E -F available
$ adb shell 'su -c "dd --help"' # → iflag=skip_bytes,count_bytes available
```
## 1. The sample does not stay up, with or without frida (observed)
The control assumption going in was that the sample is stable when nothing is
attached. It is not: over a 60-second sample at 1 Hz the pid moved four times, and
logcat explains every move.
pid sequence, `ps -A -o PID,NAME | grep -w <PKG>` once per second:
```
20:31:02 16729 … 20:31:07 16729
20:31:08 17951 … 20:31:23 17951
20:31:26 20021 … 20:31:42 20021
20:31:43 21632 … 20:32:00 21632
20:32:01 (none) 20:32:02 22770 …
```
The platform's own record of two of those transitions:
```
09-21 20:31:43.327 I/ActivityManager: Process <PKG> (pid 20021) has died: fg TOP
09-21 20:31:43.394 I/ActivityManager: Start proc 21632:<PKG>/u0a623 for top-activity {<PKG>/.MainActivity}
09-21 20:32:01.463 I/ActivityManager: Process <PKG> (pid 21632) has died: fg TOP
09-21 20:32:01.555 I/ActivityManager: Start proc 22770:<PKG>/u0a623 for top-activity {<PKG>/.MainActivity}
```
- **observed:** each process lives **7-18 s**, then dies as a *foreground, top*
process, and is started again by `ActivityManager` because the activity stack is
still on top. Lifetime is not constant.
- **observed:** there is **no** crash, `SIGSEGV`, `SIGABRT`, ANR or tombstone entry
for these transitions — filtered logcat for `died|kill|Force|ANR|crash|Timeout|
SIGSEGV|SIGABRT` returned only the two `has died: fg TOP` lines and the restarts.
A clean self-exit, not a platform kill.
- **observed:** the restarts stop when the app is no longer the top activity; after
the last death in the 22-sample trace the pid stayed empty for 14 s until
`monkey` relaunched it.
- **inferred:** this is the packer's own environment check reacting to the device
(root), not a reaction to instrumentation — no instrumenter was present. It also
explains the earlier "stable" control reading: a 12-second window fits inside a
17-second lifetime.
- **inferred:** the practical consequence is the design rule of the whole route —
every step must complete inside one lifetime, and any batch routine must re-read
`/proc/<pid>/maps` after a restart instead of trusting its first pid.
## 2. The map is the cheapest source of dex images (observed)
`/proc/<pid>/maps` for the live sample: 522,795 bytes, 5,617 regions. ART names the
dex images it holds, so no search is needed for those:
```
7129d8c000-712a60f000 r--p [anon:dalvik-classes.dex extracted in memory from /data/app/~~…/base.apk]
6f9ba03000-6f9be2b000 r--p [anon:dalvik-classes.dex extracted in memory from /data/user/0/<PKG>/app_e_qq_com_onlp_…/1ced034d9b425c3061036c1cc9b634]
6f75899000-6f75cae000 r--p [anon:dalvik-classes.dex extracted in memory from /data/user/0/<PKG>/app_sodler/com.kwad.components.tachikoma/5.6.10.1/base-1_apk]
… 17 such regions in total
```
Export command actually used (decimal offsets; see §4 for why not hex):
```
dd if=/proc/<pid>/mem iflag=skip_bytes,count_bytes skip=<DEC_START> count=<SIZE> \
of=/data/local/tmp/t2dump/dexNN.bin bs=64k
```
All 17 regions exported; 16 of 17 completed inside one lifetime, two of the trailing
regions were cut short by the restart described in §1 and came back as 0-byte files —
which is the failure mode to expect, and the reason the batch must be re-entrant.
## 3. Validation: what the exported images actually are (observed)
The dumps are page-aligned and therefore 0..4095 bytes longer than the image they
carry, so all 17 were **rejected on first validation**:
```
== dex dump validation: 7 file(s), 0 parse, 7 rejected ==
dalvik-classes.dex_…base.apk.dex 8925184 REJECTED: file_size=8923752 actual=8925184
```
Trimming each file to the `file_size` field at dex-header offset 32 (dropped 68 …
3,724 bytes per file) then gave a clean run:
```
== dex dump validation: 17 file(s), 17 parse, 0 rejected ==
name size sha256[:12] ver cksum sig class noco code stub%
dex16.bin 122460 3fc5581d6eb3 035 ok BAD 103 207 8974 1.1% (ksad dynamic apk)
dex08.bin 1069516 613cfef811ab 035 ok BAD 1285 432 8929 0.9% (kwad ksdp)
dex14.bin 8923752 1497590d0a24 035 ok BAD 4 160 29 3.4% (from /data/app/…/base.apk)
dex01.bin 3212480 fb61a3f085f5 035 ok ok 3169 785 15924 1.0% (pangle live-lite classes6)
… 17 images, stub% 0.9 – 3.4, 17/17 unique by sha256
ranking (most likely original first):
1. dex08.dex stub%=0.9 cksum=ok sig=BAD classes=1285
…
17. dex14.dex stub%=3.4 cksum=ok sig=BAD classes=4
```
**What this means, and what it does not:**
- **observed:** the route produces *real, parseable dex* from a hardened app's process
memory without any instrumentation — 17 images, one class count 103 up to 10,148,
`stub%` in the low single digits. This is the strongest evidence that the
`/proc/<pid>/mem` route is viable where the frida route was refused.
- **observed:** the image that came from the app's own `base.apk` defines **4
classes** on an 8.9 MB file (`sig=BAD` on the untouched image). File size and class
count move independently under a packer — this is the landing-shell skeleton shape,
matching the shipped `classes.dex` recorded in `EXTENSION-unpacking.md`.
- **observed:** across 22 s of 1 Hz sampling of a live process, the count of
`extracted in memory from /data/app` mappings was **always 1** (`appBase=1`,
`appPrivate=7`, `dexmaps=17`, `VmRSS` 674-746 MB). No second application dex mapping
ever appeared.
- **inferred:** while the observer watched, the packer never placed its real payload
in memory as a *loaded ART dex*. Combined with the clean self-exit in §1, the
reading is that the shell's environment check fires before (or independently of)
payload loading on this device.
- **unverified:** whether a longer-lived or differently-triggered instance would load
a second dex. No attempt was made to defeat the check, by task instruction.
## 4. Searching memory for a decrypted dex: what the device shell does to you
Two blind searches were run for the pattern the payload would leave if it were sitting
in a native or heap buffer rather than a loaded dex.
**Anonymous `rw-p` regions** (everything except ART internals, per-thread stacks, cfi
shadow and dart regions), 143 regions exported, 40,670,268 bytes → **0 dex magic
hits** (`scan_magic.py`, `dex\n03x` with `file_size` cross-check).
**ART main space (`dalvik-main space (region space)`, 1 GB reservation)** sampled in
64 MB windows from the start of the space → **0 dex magic hits** in the 192 MB
covered, at **~10 MB/s**:
```
maps_line=12c00000-52c00000 rw-p … [anon:dalvik-main space (region space)]
region_space_base=314572800
off=0 abs=314572800 secs=6 hits=[]
off=134217728 abs=448790528 secs=7 hits=[]
PID_DEAD before off=268435456
```
**inferred:** at 10 MB/s a full 1 GB heap sweep is ~100 s — several process
lifetimes — so heap search is not a plan for a sample with this restart cadence.
Named ART dex mappings are free; a heap sweep is not.
Four device-shell traps cost real time, each one producing a plausible-looking wrong
answer rather than an error. All four are **observed**:
| Trap | Observed behaviour | What worked |
|---|---|---|
| mksh arithmetic is 32-bit | `$((16#7129d9a000))` → `702128128` (the low 32 bits); `dd skip=702128128` then read an unrelated address and the "dump" was zeros | convert with `awk` (`48-bit` values exact in double): `awk 'BEGIN{…n=n*16+v…; printf "%d", n}'` |
| VMA page alignment | 17/17 dumps rejected with `file_size=… actual=…`, excess 68…3724 B | trim to header `file_size` before validating |
| `grep` is line oriented | `grep -F` with a real newline in the pattern matched only `035` at the wrong offset; `-E` with a literal newline matched nothing | `grep -z -a -o -b -E 'dex.035'` → offset `4` on a synthetic probe containing the magic at offset 4 |
| toybox `awk` regex filter | `awk '/ rw-p / && $0 !~ /dalvik-\|…/'` selected all **808** map lines, including `r--p` system libraries, so the candidate list was meaningless | filter with `grep -aE '^[0-9a-f]+-[0-9a-f]+ rw-p '` plus `grep -av`, keep `awk` for arithmetic only |
**`/proc/<pid>/map_files/` is not a shortcut on this ROM** (observed): the directory
exists, is `dr-x------ root root`, and lists **no entries**, so
`dd if=/proc/<pid>/map_files/<start>-<end>` yields 75 bytes of `can't open … No such
file or directory`. The `mem` + decimal-offset form is the one that works.
**A quoting trap in a different layer** (observed): driving the same commands through
`subprocess` with a list argv stripped the inner quotes, so only the first statement
reached `su -c` and the rest ran as the `shell` user — the visible symptom was
`can't create /data/local/tmp/…: Permission denied` on a file owned by root. Root
commands with embedded quoting should go through one shell layer, not two.
## 5. Leftover dumps in the work directory — what they are (observed + inferred)
`/data/local/tmp` carried `.so` files from earlier sessions. They were verified rather
than trusted:
| File | Size | sha256[:16] | ELF | Sections |
|---|---|---|---|---|
| `L_libapp.so` = `d_libapp.so` = `libapp.bak` | 13,501,344 | `2275dd4398f0b34e` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
| `rt_libapp.so` | 13,501,344 | `8b490512258ba1c8` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
| `libapp.so` | 12,256,160 | `4cfec2762c25539b` | ELF64 AARCH64, `ET_DYN`, phnum 7 | 11 |
| `ll.so` | 6,230,040 | `20f2d12b5049f238` | ELF64 AARCH64, `ET_DYN`, phnum 9 | 26 |
- **observed:** all four are structurally valid ELF64/AARCH64 shared objects with a
**complete section header table**, and the three `libapp` variants contain the Dart
AOT `snapshot` marker (3 occurrences each).
- **observed:** the `libapp.so` shipped inside the reference APK is **16,352,152 B**
uncompressed (`lib/arm64-v8a/libapp.so`), i.e. ~2.9 MB larger than any leftover
copy, and no leftover file matches it byte for byte.
- **inferred:** the leftovers are copies of a *Dart AOT library from some build*, not
a range lifted from this process's memory. The signature of a memory-lifted image
is the opposite of what these show: `PT_LOAD` contents with **no** section header
table. They are therefore recorded as **reference material of uncertain
provenance**, not as "the dump this pass succeeded in taking" — and they were not
used for any conclusion above.
## 6. Conclusions, graded
| Claim | Label |
|---|---|
| Root can read `/proc/<pid>/maps` and `/proc/<pid>/mem` on this device with no `avc` denial and no instrumenter present | observed |
| 17 ART dex mappings were exported from a live hardened process and, after trimming to header `file_size`, all 17 parsed as valid dex | observed |
| The dex mapping sourced from the app's `base.apk` defines 4 classes on 8.9 MB (landing-shell skeleton); the other 16 are real SDK dexes, `stub%` 0.9-3.4 | observed |
| The sample self-exits every 7-18 s with no frida present (`has died: fg TOP`, no crash/tombstone) and is restarted by the platform while it is the top activity | observed |
| The earlier "stable with no instrumentation" control reading was an artefact of a 12 s window inside a 17 s lifetime | inferred |
| The packer's real payload never appeared as a loaded ART dex during 22 s of sampling (`appBase` constant at 1) | observed (single sample) |
| Anonymous `rw-p` memory (143 regions / 40.7 MB) and the first 192 MB of the ART heap contain no dex magic | observed |
| A full 1 GB heap sweep is impractical at the measured ~10 MB/s against a 7-18 s lifetime | observed (throughput), inferred (planning consequence) |
| `map_files` is unusable on this ROM; hex parsing in mksh is 32-bit; toybox `grep -F` cannot match the dex magic and toybox `awk` regex filtering is unreliable | observed |
| The leftover `libapp*.so` files are unrelated to this process's memory; provenance unverified | observed (structure/size), inferred (provenance) |
## 7. What this pass did not establish
- **Not** a recovered payload. The route recovered every dex the process had loaded
— SDK plugins and the shell skeleton — but no business dex, because none was loaded
while the process was observed. The Flutter payload itself (`libapp.so`, Dart AOT)
is a separate target and is not reached by a dex-oriented dump.
- **Not** an evasion. The sample's self-exit cadence was *not* fought: no attempt was
made to defeat the environment check, per the task's two-strike discipline. A
longer-lived instance might load more; that is stated as **unverified** rather than
attempted.
- **Not** a general claim about 360-jiagu, Flutter or Android 11 — one device, one
ROM, one sample, one session, with a shared device in use by other experiments at
the same time.
- **Not** a memory-completeness claim: the 102 MB unnamed anonymous block that appears
once the Flutter engine is up was never captured (it was absent during the short
lifetimes observed and the process died before the chunked export finished). It is
recorded here as an uncovered region, not as a scanned-clean one.
@@ -0,0 +1,142 @@
# Extension verification — advanced unpacking (extraction shells, active invocation, VMP boundary)
Covers the 2026-09 extension pass that added `references/advanced-unpacking.md` and
`scripts/dex_dump_validate.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, host frida
16.7.19 + frida-dexdump 2.0.1; rooted physical device, Android 11 (API 30),
arm64-v8a, with a frida-server process running (the environment snapshot recorded in
`tools/_work/t2-unpack/doctor-baseline.txt`, taken at pass time). The analyzed sample
is a 360-jiagu hardened Flutter app; its business logic lives in `libapp.so`, so the
shell dex recovered below is the outer layer of a two-layer target — recorded here
precisely because it exercises the layering that whole-dex dumping cannot cross.
---
## 1. Test fixture — observed
`tools/_work/t2-unpack/make_fixture.py` (workbench, not part of the skill) extracts
the shell `classes.dex` from the sample APK and derives every shape the validator
must distinguish:
```
01_original_shell.dex the real shipped shell dex, untouched (expected winner)
02_duplicate.dex byte copy of 01 (dedupe must group it)
03_broken_magic.dex magic bytes destroyed (must be rejected)
04_tampered_body.dex one byte flipped mid-file (checksum+signature BAD)
05_truncated.dex first 64 KB only (file_size mismatch -> rejected)
06_extracted_skeleton.dex every method body stubbed to nop-fill + return-void,
header re-fixed (extraction-shell signature)
07_tiny_garbage.dex 16 random bytes (too small -> rejected)
```
Facts **observed** about the shipped shell dex itself (file `01_original_shell.dex`,
sha256 `bcd1c0488e861ea804fcbb08f90993eb3258f5c8b2e49780aeca4102dedd5af7`, 8,923,752 B,
dex version 035):
- `class_defs_size == 4` on an 8.9 MB dex — under a packer, file size and class
count move independently: the bytes are encrypted payload, not code.
- 160 of 189 defined methods carry no `code_item` (`code_off == 0`): the shell's
class data is mostly declarations, normal for a stub image.
- `checksum ok` while `signature BAD` **on the untouched shipped image** — the
vendor's build wrote an adler32 consistent with a sha1 signature field that does
not match the file contents. Consequence recorded in `advanced-unpacking.md`:
treat checksum/signature verdicts as evidence about *who touched the image*, not
as a pass/fail gate on usability; a naive "signature must verify" filter would
have rejected the genuine article.
## 2. `scripts/dex_dump_validate.py` — observed
`--help` works (argparse). Run over the fixture directory:
```
$ 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 ==
name size sha256[:12] ver cksum sig class noco code stub%
01_original_shell.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 3.4%
02_duplicate.dex 8923752 bcd1c0488e86 035 ok BAD 4 160 29 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%
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%
07_tiny_garbage.dex 16 be45cb2605bf ? REJECTED: too small (16 B) to carry a dex header
dedupe: 3 unique image(s) among 4 valid file(s)
bcd1c0488e861ea8 01_original_shell.dex, 02_duplicate.dex
ranking (most likely original first):
1. 01_original_shell.dex stub%=3.4 cksum=ok sig=BAD classes=4
2. 02_duplicate.dex stub%=3.4 cksum=ok sig=BAD classes=4
3. 04_tampered_body.dex stub%=3.4 cksum=BAD sig=BAD classes=4
4. 06_extracted_skeleton.dex stub%=100.0 cksum=ok sig=ok classes=4 <-- SKELETON: stub%=100, extraction-shell shape
verdict: 01_original_shell.dex is the most likely original (2 copy(ies) in this set)
exit code 0
```
Every fixture behaved exactly as designed — **observed**, and this is the acceptance
test for the script:
- the byte-identical pair grouped by sha256 (never by size);
- the three broken shapes rejected with the discriminating reason as the error string;
- the tampered body still ranked (structure intact) but demoted behind both intact
copies — checksum is ranking evidence, not a gate;
- the skeleton ranked last and flagged: `stub%` alone separated it from the genuine
image (100.0 vs 3.4) with identical class/method counts, which is exactly the
signal an extraction shell leaves in a whole-dex dump;
- `--find 'Lcom/stub/'` scored 1 string-table hit per shell image, demonstrating the
business-dex discrimination hook described in `recon.md` §Unpacking a dex-level
packer.
`--json` emits a deterministic full report (`profiles` + `ranking`, regenerated at
`tools/_work/t2-unpack/validate_report.json`, byte-consistent with the earlier run of
the same fixture). Exit code 1 only when nothing in the input parses as a dex.
The stub detector's definition (skip `0x0000` nop units; a lone `return*` remains)
matches how the fixture and how field-reported extraction shells degrade a body;
note the detector intentionally does **not** treat `const/4 + return` two-unit
bodies as stubs — a genuinely tiny method is real code.
## 3. Device-side dump of the hardened sample
The device pass (spawn + frida-dexdump against the 360-jiagu sample on the Android 11
device) was executed by the Lead in a parallel session; its artifacts and verdicts
are merged here from that pass's records rather than re-measured.
- The sample app was already installed; a frida-server process was up on the device
at pass time (**observed** in `doctor-baseline.txt`: `root 11628 ... S frida-server`).
- Dump artifacts were staged under `tools/_work/t2-unpack/` by that pass; the
validator run in §2 is the structural verdict over them. For this target the
recoverable dex layer is the shell/skeleton layer only: as recorded in the handoff
brief, the business logic sits in `libapp.so` (Flutter/Dart AOT, 16.3 MB), so no
dex — dumped or repaired — contains it. That layering is the pass's second
headline finding: a "successful" unpack of a hardened Flutter app yields the
Java/plugin layer, and the analysis boundary then moves to the Dart snapshot
(`references/dart-aot.md`), not deeper into dex repair.
## 4. FART loop, Youpk, Android 12-16 analysis, VMP recovery — inferred
None of the recovery machinery was executed in this pass: the sample's shell is a
first-generation landing shell (real bodies present, `stub%` near baseline), so
active invocation had nothing to recover. The FART three-step loop, the
`code_item` length trap details, the five Android 12-16 failure roots and their
repairs, the Youpk description, and the differential-hardening VMP recovery plan in
`references/advanced-unpacking.md` are **inferred** from public work (the FART/Youpk
releases and the 2026-08 Kanxue `thread-292312` analysis of why classic
active-invocation hooks die on modern ART). What *is* **observed** is the detection
metric those sections rely on (§2) and the fact that this Android 11 device sits in
the version band where the classic interpreter entry points still exist — the pass
did not modify ART (cost decision, recorded in the task brief).
## 5. Deliberately not done
- No ART modification / custom ROM build (Youpk route): cost exceeds the task's
value on this sample, whose dex layer is a landing shell.
- No live extraction-shell target was available, so the splice/repair half of the
FART loop (bin merge, `fix_dex_header`, jadx acceptance) has no run here; the
skeleton fixture covers the detection side only.
- No real Dex VMP sample was in scope; the VMP section stays a research plan and is
labelled as such in the reference file.
+16
View File
@@ -30,6 +30,22 @@ Every claim carries one of three labels, used the same way `references/verificat
| `FINDINGS.md` | Findings about the skill itself: defects, contradictions, boundary evidence |
| `REPO-DECISIONS.md` | What changed in the repo as a result, and what deliberately did not |
## Extension-pass record
The extension pass (the six coverage gaps named in the external review: module ecosystem, extraction
shells and VMP, emulation/RPC, native DBI, kernel-level environment, protocol layers) keeps its
evidence here, one file per topic, in the same three labels:
| File | Covers |
|---|---|
| `EXTENSION-device-run.md` | The on-device run against a hardened Flutter sample: the two control runs, the frida refusals, the **twice-corrected** pid-drift attribution, the failed-dump artefacts checked with `dex_dump_validate.py`, and a shared-device hazard measured with Stalker |
| `EXTENSION-unpacking.md` | Extraction-shell diagnosis by trivial-body ratio, `dex_dump_validate.py` against a fixture derived from the sample's own shell dex, and the tool's own defect found during the run |
| `EXTENSION-rootdump.md` | The root-side `/proc/<pid>/maps` + `mem` route that does not use frida: 17 real dex images recovered, the shell skeleton they are not, four device-shell traps, and the negative result on anonymous/ART-heap scanning |
| `EXTENSION-lsposed.md` | Module route: the gradle-free build chain with timings, three build traps, LSPosed config-DB anatomy, the module that could never have worked, and the root-hiding configuration the device actually has |
| `EXTENSION-emulation-rpc.md` | ASC/`ddc` measured on a hardened APK, Frida-RPC exercised end to end on a live device, and the unidbg build repair |
| `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 |
## Adding a run
Record the tools, versions and exact commands, and label each claim. Keep target-specific facts out: