15 KiB
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
ActivityManagerbecause 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 fordied|kill|Force|ANR|crash|Timeout| SIGSEGV|SIGABRTreturned only the twohas died: fg TOPlines 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
monkeyrelaunched 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>/mapsafter 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>/memroute is viable where the frida route was refused. - observed: the image that came from the app's own
base.apkdefines 4 classes on an 8.9 MB file (sig=BADon the untouched image). File size and class count move independently under a packer — this is the landing-shell skeleton shape, matching the shippedclasses.dexrecorded inEXTENSION-unpacking.md. - observed: across 22 s of 1 Hz sampling of a live process, the count of
extracted in memory from /data/appmappings was always 1 (appBase=1,appPrivate=7,dexmaps=17,VmRSS674-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
libappvariants contain the Dart AOTsnapshotmarker (3 occurrences each). - observed: the
libapp.soshipped 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_LOADcontents 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.