# 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 ``. 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 ` 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 (pid 20021) has died: fg TOP 09-21 20:31:43.394 I/ActivityManager: Start proc 21632:/u0a623 for top-activity {/.MainActivity} 09-21 20:32:01.463 I/ActivityManager: Process (pid 21632) has died: fg TOP 09-21 20:32:01.555 I/ActivityManager: Start proc 22770:/u0a623 for top-activity {/.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//maps` after a restart instead of trusting its first pid. ## 2. The map is the cheapest source of dex images (observed) `/proc//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//app_e_qq_com_onlp_…/1ced034d9b425c3061036c1cc9b634] 6f75899000-6f75cae000 r--p [anon:dalvik-classes.dex extracted in memory from /data/user/0//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//mem iflag=skip_bytes,count_bytes skip= count= \ 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//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//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//map_files/-` 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//maps` and `/proc//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.