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

16 KiB

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.