Browse by type
A stealthy Android ARM64 process injector for authorized security testing. No ptrace,
no debugger attach, no persistent fingerprint visible to the injected app.
Validated on Android 16 (API 36), kernel 4.19 with a custom vma_hide module, against
an app protected by jiagu packer plus standard anti-tamper checks. End-to-end the
child process exposes no observable artifact of injection through /proc/self/maps,
/proc/self/smaps, the linker's solist, byte-level content hashes of patched system
libraries, the on-disk staging directory, or open file descriptors.
The adversary runs inside the target app's UID sandbox — the classic in-process anti-tamper / anti-cheat / anti-debug scanner. They can:
/proc/self/maps, /proc/self/smaps, /proc/self/statussolist via dl_iterate_phdr / dlinfo/proc/self/fdThey cannot read other processes' /proc/<pid>/* (SELinux + UID isolation), and
they cannot read kernel ring buffers. The injector binary plus a small kernel hook
operate outside that sandbox.
host device (rooted)
──── ──────────────
xmake b injector ──push──> /data/local/tmp/app_process_XXXX (injector binary, root)
xmake run ──push──> /data/local/tmp/<lib>.so (payload)
┌─────────────────────────────────────────────┐
│ injector (root via su) │
│ │
│ 1. detect API from /system/build.prop │
│ 2. resolve app UID via syscall.Stat │
│ 3. clear /proc/vma_hide entries for UID │
│ 4. force-stop pkg (skip if already dead), │
│ bounded-poll for kill to take effect │
│ 5. stage payload → /data/data/<pkg>/ │
│ .org.chromium.<rand>.tmp │
│ 6. resolve setArgV0 + __loader_dlopen │
│ 7. write 428-byte stub @ setArgV0 │
│ 8. am start <main_activity> │
│ 9. detect new child via /proc walk ──┐ │
│ 10. restore zygote setArgV0 │ │
│ 11. poll stage mailbox value=1 │ ┌──┴─────────────────┐
│ 12. restore child setArgV0 │ │ child app process │
│ 13. write release flag (value=1) ├──┤ • mmap 256K RWX │
│ 14. poll mailbox value>=2 → handle │ │ • read stage │
│ 15. poll mailbox value=3 (stage ret) │ │ • BR to stage │
│ 16. UnlinkSoinfo + vma_hide │ │ • value=1 (ready) │
│ (file segs + guards + bss) │ │ • BLR real │
│ 17. vma_hide stage region │ │ setArgV0 │
│ 18. exit (binary removed by xmake) │ │ • dlopen() │
└────────────────────────────────────────┘ │ • value=2 │
│ • unlinkat .tmp │
│ • madvise CoW │
│ • value=3 │
│ • RET │
└────────────────────┘
The diagram shows the single-payload essence; with several --lib payloads the
stage loads them in order and the injector gates each one (see §3 for the exact
gate/loaded mailbox handshake). No ptrace. No debugger attach. The hook is a
one-shot byte-patch on a single function, restored as soon as the target child is
spotted.
Each layer closes a specific detection surface. They are layered because no single technique is sufficient on its own.
setArgV0We trap _Z27android_os_Process_setArgV0P7_JNIEnvP8_jobjectP8_jstring in
/system/lib64/libandroid_runtime.so. The Java framework calls this exactly once per
forked app — after fork() and before the app's main code runs — to rename
the process from <pre-initialized> to its package name. This is the narrow window
in which the app's anti-tamper has not yet initialized but the process is fully a
child of zygote.
Why not other entry points:
- Trapping a zygote function would corrupt zygote globally on the first trigger.
- Trapping in the linker would be too early (no JNI, no real process identity).
- Trapping in JNI_OnLoad of an app library is too late (anti-tamper already armed).
The trap itself is only 428 bytes (custom_stub.s → custom_stub.bin). Its job:
getpid() matches zygote, then getuid() matches the target app's
UID, so unrelated forks (SystemUI, system_server children) fall straight
through to the real setArgV0.openat/mmap a 256 KB anonymous RWX region, read the 4 KB stage file from
/data/local/tmp/.gzs.<nanos> into it._orig_hook_slot with the address of the real setArgV0.BR to the stage.The stub uses only x16/x17 (caller-saved IP scratch) and x9–x11 — never any
callee-saved register. AArch64 callee-saved state (x19–x28) flows through to
the stage untouched, so the original caller's state is preserved bit-for-bit across
the trap.
This was a real bug we fixed: an earlier revision used x21/x22 as scratch, which
silently corrupted ART's runtime state. The app would keep running and crash later
— "system memory corruption after one launch".
The 4 KB stage (stage_dlopen.s → stage_dlopen.bin) runs from the new RWX
anonymous mapping — never from a patched system library — so the injector can safely
restore the child's setArgV0 page without clobbering still-executing shellcode.
This is the fundamental icache problem that makes "inline" traps unsafe across COW
restores.
The stage loads N payloads (passed via repeated --lib, in order) into the one
trapped child. The mailbox is a 48-byte struct at a fixed offset in the stage
region, laid out as five uint64 slots. gate (injector → stage) and loaded
(stage → injector) are monotonic counters that gate each payload:
| offset | name | written by | meaning |
|---|---|---|---|
0x00 |
handle | stage | dlopen return value for the payload currently loading |
0x08 |
pid | stage | child's getpid (for cross-check / self-identification) |
0x10 |
status | stage | 1 = announced & spinning, 3 = stage done (about to RET) |
0x18 |
gate | injector | 1 releases the single setArgV0; i+2 acks payload i |
0x20 |
loaded | stage | i+1 published once payload i's dlopen has returned |
Sequence inside the stage:
x0..x3, x8, x29, x30.pid + status=1 into the mailbox, then spin until gate >= 1.status=1, restores the child's setArgV0 page, writes
gate=1. Stage breaks the spin.BLR the real setArgV0 once (original bytes are
back now).i in 0..N-1:
a. __loader_dlopen(path[i], RTLD_NOW, NULL) (null caller-addr selects the
default namespace); write the handle.
b. unlinkat(AT_FDCWD, path[i], 0) — delete the staged copy from disk. The
kernel keeps the inode alive through the now-mapped segments.
c. Write loaded=i+1, then spin until gate >= i+2 (the injector's ack).madvise(page_align(setArgV0), 8192, MADV_DONTNEED) — drop the two CoW'd pages
of libandroid_runtime.so .text. The kernel reverts them to file-backed, so
smaps Anonymous and Private_Dirty go back to zero. Works on any kernel since
2.4 (we target 4.19, pre-process_madvise).status=3 ("stage done, about to RET") — the handshake the injector waits
for before hiding the stage region — then restore all registers and RET to the
original setArgV0 call site. The stage page is no longer executing after this.The injector side mirrors the loop: it releases gate=1, then for each payload
waits for loaded >= i+1, records the handle, and acks gate=i+2. The
handle slot stays stable between those two because the stage blocks on the ack
before overwriting it. The soinfo-unlink + VMA-hide teardown (§5) is deferred until
after every payload is loaded — unlinking a soinfo that is the linker's solist
tail would dangle the linker's tail pointer and orphan the next payload's node.
Writing the trap to zygote's setArgV0 triggers Copy-on-Write: the kernel allocates
an anonymous private page, copies the file-backed bytes, applies the trap. Writing
the original bytes back into that page content-restores but not page-restores
— the page is still anonymous, not file-backed.
Two-step fix:
/proc/zygote/smaps is gated by SELinux
(u:r:zygote:s0); apps can never read it.madvise(page, 8192, MADV_DONTNEED) from the stage drops the
anonymous page; subsequent access faults back from the page cache. Net result:
smaps Anonymous=0, Private_Dirty=0, Shared_Clean=full segment size. SHA-256
of the 428-byte trap window matches the disk file byte-for-byte.After dlopen succeeds, the payload is on the linker's __dl__ZL6solist linked
list. dl_iterate_phdr walks this list and would expose the payload to any caller.
We:
__dl__ZL6solist (resolved via prefix match to handle
LLVM hash suffixes like .llvm.3423636157476772985).next pointer (offset is stable at 0x28 across API levels).realpath field (libc++ std::string SSO; the walker probes a
small window of candidate offsets and accepts only /-prefixed decodes, so AOSP
struct drift across API levels is absorbed automatically).next to skip ours, or update the head if we're first.After unlink, dl_iterate_phdr yields no entry for the payload.
/proc/maps hiding via per-UID vma_hideThis needs a custom kernel module exposing /proc/vma_hide with a per-UID hide list.
It is optional: --vma-hide selects auto (the default — use the module iff
/proc/vma_hide exists), always (use it; warn, don't abort, if writes fail), or
never (skip it entirely). When inactive, the payload's VMAs are simply left visible
in /proc/<pid>/maps; everything else — soinfo unlinking, on-disk cleanup, CoW
restoration — is independent of the module and still runs. Write commands:
clear # global wipe (all UIDs)
clear <uid> # wipe only this UID's entries — used at injection start
add <uid> 0xSTART 0xEND # per-UID hide, repeated per VMA
The kernel hook filters listed VMAs out of /proc/<pid>/maps and /proc/<pid>/smaps
only for readers whose current_uid() matches the entry's <uid> — root (uid 0)
always bypasses the filter. The pages stay mapped; only enumeration is filtered.
The injector adds entries for:
r-xp, r--p, rw-p) — file-backed, named by the
staged .tmp path.---p, anonymous) touching a payload boundary. These are
remnants of the linker's initial PROT_NONE reservation and reveal the segment-gap
alignment of a typical .so even when the file-backed regions are gone.[anon:.bss] (rw-p, named) immediately after the last payload segment. Sized
to LOAD#3 (memsz − filesz), this is the zero-init tail of the payload's data
segment and was the most distinctive of the leftover signals.rwxp, anonymous). The stub mmaps exactly 0x40000
bytes for the stage; this exact-size RWX anonymous mapping outside ART's code-cache
pattern was an obvious fingerprint until we hid it.Detection of satellite VMAs is fixed-point: each newly-added region extends the
boundary set, so a chain r-xp ↔ ---p ↔ r--p ↔ ---p ↔ rw-p ↔ [anon:.bss] is fully
absorbed in one pass.
/proc/vma_hide's list is kernel-global state. Stale entries from a previous run
into the same app can linger and (on the older global-list kernel) shadow the new
stage's address. We clear <uid> at the first action in RunInjector. With the
per-UID kernel module this is correctness-optional — the injector reads as root
and is never filtered — but tidy across multiple injections into the same app.
The staged payload is copied to /data/data/<pkg>/.org.chromium.<rand>.tmp. After
dlopen succeeds, the stage calls unlinkat from inside the child process — so
the unlink syscall looks like normal app file activity, not a privileged operation.
The file descriptor is released; the kernel keeps the inode alive via the still-mmap'd
payload segment
$ claude mcp add gozinject \
-- python -m otcore.mcp_server <graph>