Files
reverse-skills-max/vendor-new/apk-reverse/docs/tool-verification/EXTENSION-rootdump.md

15 KiB
Raw Blame History

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.