mirror of
https://codeberg.org/reverse-skill-lab/apk-reverse
synced 2026-11-15 20:24:24 +08:00
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:
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user