# Detecting Frida Stalker from inside an Android process Every way I could find to tell that your code is running, or has just run, under Frida's Stalker. The target is **Android arm64**; there are notes for x86/x86_64 (emulators, ChromeOS) and 32-bit ARM. The facts below come from two sources: * **Source review** of `frida-gum` **17.25.0** (tag `17.25.0`, frida commit `f74818ad`, frida-gum commit `5ebe3aec`, released 2026-10-30). File:line references point into that tree. * **Experiments** against the real Stalker code: * the official `frida-gum-devkit-17.25.0-linux-arm64-musl`, driven under `qemu-aarch64 11.1.2 -cpu max`; * `frida-gum-devkit-17.25.0-linux-x86_64`, run natively on Linux 7.2 for the parts qemu-user cannot emulate: ptrace, rseq, seccomp and timing. The code involved (`gumprocess-linux.c`'s ptrace-based thread hijacking) and the kernel mechanisms are the same on Android. Every probe was run twice, once natively and once after `gum_stalker_follow_me()`. Appendix B has the results. **Status tags used below** | Tag | Meaning | |---|---| | **V-a64** | Verified on Frida 17.25.0's **arm64** Stalker: native and stalked runs give different results | | **V-x64** | Verified natively on x86_64. The mechanism is the same Linux code that Android uses | | **SRC** | Follows from the 17.25.0 source but was not executed. Either it is an Android-only branch, or qemu cannot model it | | **THY** | Architectural reasoning only. Validate it on a device before relying on it | --- ## 0. TL;DR: what I would ship first | # | Check | Why it is hard to get rid of | Cost | § | |---|---|---|---|---| | 1 | **`svc` spill residue**: after a raw syscall, `[sp-144]==x15 && [sp-136]==x17` | Every `svc` is wrapped by `stp x15, x17, [sp, #-0x90]!` on the always-executed fast path. A native syscall never writes to your stack | 1 syscall | B1 | | 2 | **Indirect-branch spill residue**: after a `ret`/`br`/`blr`, `[sp-160..sp-128) == {x0, x1, x16, x17}` | The inline-cache lookup spills these registers below SP on every indirect branch, including in steady state. An arm64 DBI that does not steal a register has to spill somewhere | ~20 insns | B2 | | 3 | **Synchronous signal PC**: send yourself an app-owned RT signal with a raw `tgkill`, then compare `uc_mcontext.pc` with the address just after the `svc` | The kernel reports the real PC, which is in the code cache, and your handler runs natively | 1 signal | A1 | | 4 | **Intra-block self-modifying code** | Stalker translates a block before running it, so the stale instruction runs, even with `trust_threshold = -1` | 1 RWX page | C2 | | 5 | **Execute a RW (non-exec) page** | Stalker never checks `PROT_EXEC` on the code it translates | 1 page | D1 | | 6 | **ExecCtx lookup**: a pthread-key value, or an unnamed `rwxp` mapping, whose header contains *your* TID at `+0x18` | `GumExecCtx` sits at the base of a 0x4D000-byte RWX mapping. Stalker finds it through TLS | key loop or maps scan | E1–E3 | | 7 | **SIGCONT tripwire** | `Stalker.follow(tid)` detaches its ptrace helper with `SIGCONT`. Your handler then sees PC = Stalker's infect thunk | free | F2 | | 8 | **Exceptor footprint**: compare the kernel `SIGSEGV` handler from a raw `rt_sigaction` with what libc/libsigchain reports | Creating an arm64 `GumStalker` always installs Frida's exceptor and hooks libc `sigaction` | 1 syscall | F1 | | 9 | **Watchdog PC sampling**: an unfollowed thread signals the sensitive threads | Gives you the real PC of each thread. Stalker cannot rewrite a thread it does not follow | low | A3 | | 10 | **Hardware traps where present**: `retaa` (PAC), a BTI page, an MTE-tagged stack, SME streaming | Stalker re-implements these instructions without their semantics, or crashes on them | cheap | D7–D10 | If the EDR can afford a permanent seccomp filter, also deploy the **seccomp tripwire** on `mmap/mprotect(PROT_EXEC)` and on `ptrace(PTRACE_ATTACH)` (F3). It saw the single RWX allocation that `Stalker.follow()` makes. --- ## 1. The Stalker internals that create the attack surface (arm64, 17.25.0) * **One code cache per followed thread.** `gum_exec_ctx_new()` (`gum/backend-arm64/gumstalker-arm64.c:2026`) puts the `GumExecCtx` at the **base** of a single anonymous allocation of `ctx_size = page(sizeof ctx) + 1 page (thunks) + 128 KiB (code) + 128 KiB (slow) + page(128 KiB/5) (data) + 16 KiB (scratch)` (`:826-857`). That is **0x4D000 bytes with 4 KiB pages**. The allocation is **RWX** on Linux and Android, because `gum_query_rwx_support()` returns FULL everywhere except Apple (`gum/gummemory.c:402`). As more code gets translated, Stalker adds **8 MiB RWX** code+slow slabs (`:5633`) and **RW** data slabs of 4 MiB/5 (`:5701`). None of these mappings is ever named. * **Blocks.** Every translated block starts with the "restoration prolog" `ldp x16, x17, [sp], #0x90` (`:2662`) and ends with `brk #14` (`:2681`). While `trust_threshold != 0`, a byte-exact **snapshot of the original block** is stored right after the generated code (`:1904`, `:3894`). * **The original code is never modified.** PC-relative instructions (adr, adrp, ldr literal, b, bl, cbz, tbz) are relocated to their real targets, and calls load the **real** return address into LR (`ldr x30, =ret_real_address`, `:4636`, `:4734`). That is why LR checks and `adr` checks see nothing. * **Spills below SP.** Stalker does not steal a register. Its scratch registers are pushed past a 128-byte "red zone" (`GUM_RED_ZONE_SIZE`, `gum/gumdefs.h:497`): * `stp x16, x17, [sp, #-0x90]!` on slow-path transfers, far jumps and every inline-cache (IC) lookup (`:5301`, `:5353`, `:4127`); * `stp x0, x1, [sp, #-16]!` inside the IC (`:5305`); * `stp x15, x17, [sp, #-0x90]!` around **every `svc`** (`:4493`); * `stp x0, x1, [sp, #-16]!` for FP/SIMD literal loads (`gum/arch-arm64/gumarm64relocator.c:1229`); * about 800 B of CPU context for prologs and callouts (`:3139`). * **Coherency.** Blocks are looked up by real address. The snapshot is `memcmp`'d against the original code only while `recycle_count < trust_threshold` (the default threshold is 1; `:820`, `:2439-2456`). After that, direct branches are backpatched to `b` and indirect ones get inline-cache entries; a trusted block is never checked again. * **Syscalls.** `svc` is virtualized only to special-case `__NR_clone` (`:4479-4522`). The child of a `clone` is sent back to the **original** code with `ldr x17, =; br x17` (`:4566-4570`). `clone3` is not special-cased on arm64; the x86 backend does handle it. * **Exceptions.** Creating a `GumStalker` on arm64 obtains Frida's exceptor (`:869`). The exceptor installs kernel handlers for SIGABRT, SEGV, BUS, ILL, FPE, TRAP and SYS (with `SA_ONSTACK`), and inline-hooks libc `signal`/`sigaction` (`gum/backend-posix/gumexceptor-posix.c:253-300`). Stalker uses it only to emulate a whitelist of SP-relative `stp`/`ldp` when the app runs with a misaligned SP. The source calls that "an anti-Frida technique observed in the wild" (`:3150`, `:3631`, `:3655-3706`). * **No PAC handling on Android.** `GUM_CPU_PTRAUTH` is only set on Darwin arm64e (`gum/gum.c:940-967`), so `xpaci` is never emitted on Linux or Android. * **TLS.** The `GumExecCtx*` is stored in a GPrivate, which is a pthread key, of the followed thread (`:1111`). * **Android ≥ 10 only.** Each block compilation calls `gum_ensure_code_readable(block, page_size)` (`:2641` → `gum/gummemory.c:2161`). For each page it has not seen before, Stalker queries the page protection. On Linux ≥ 6.11 it opens `/proc/self/maps` and uses the PROCMAP_QUERY ioctl; otherwise it parses the file (`gum/backend-linux/gummemory-linux.c:2572`). If the page is not readable it calls `mprotect(prot | PROT_READ)`. * **Following another thread** (`Stalker.follow(tid)`; also `Stalker.invalidate(tid, …)`, call probes, `Process.enumerateThreads()` and `Process.findThreadById()`, which fetch CPU contexts) goes through `gum_process_modify_thread()` (`gum/backend-linux/gumprocess-linux.c:630-760, 850-1080`): 1. a `socketpair`; 2. a raw `clone(CLONE_VM|CLONE_SETTLS)`, which creates a helper **process**; 3. `PR_SET_DUMPABLE` 0→1 (`:2449-2478`); 4. `PR_SET_PTRACER`; 5. `PTRACE_ATTACH`, then GETREGSET/SETREGSET; 6. `PTRACE_DETACH(…, SIGCONT)`. The thread's new PC is an "infect thunk" inside the ExecCtx mapping. The thunk first re-executes the 4 bytes that preceded the old PC, which may be an `svc` (`gumstalker-arm64.c:1213-1288`). * **GumJS.** * `Stalker.follow()` on the current thread, typically from inside `Interceptor.attach`, is deferred until the JS scope exits (`bindings/gumjs/gumquickstalker.c:574`, `:399-415`). * JS `transform` and `putCallout` callbacks run on **your** thread. * The event queue holds 16384 entries and is drained every 250 ms on the JS thread (`:255-256`). --- ## 2. Catalogue at a glance | ID | Technique | Native | Under Stalker | Deterministic | Prerequisites | Status | |---|---|---|---|---|---|---| | A1 | Synchronous self-signal PC | PC = after `svc` | PC in code cache | yes | app-owned RT signal | V-a64 | | A2 | Trap/fault PC (brk, udf, illegal, faults) | PC = original insn | PC in code cache | yes | handler | V-a64 | | A3 | Async PC sampling by a watchdog | PCs in your modules | PCs in unnamed RWX | statistical, ~100% | watchdog thread | V-a64 | | A4 | `/proc/self/task//syscall` PC | PC in libc/your lib | PC in code cache or thunk | yes | process dumpable at read time | V-x64 | | A5 | rseq critical section | kernel aborts it | kernel clears `rseq_cs` | yes | **not for Android apps** (seccomp) | V-x64 | | A6 | Return addresses around **excluded** code | in images | in code cache | yes | attacker uses `Stalker.exclude` | V-a64 | | A7 | x86_64: RCX after `syscall`, x87 FIP | real addresses | code cache | yes | x86 Android | V-x64 | | A8 | AArch32 PC-reading instructions | real PC | cache PC / derail | yes | 32-bit process | THY | | B1 | `svc` spill residue (x15/x17 @ sp-144/-136) | untouched | match | yes | none | V-a64 | | B2 | ret/br/blr spill residue (x0,x1,x16,x17 @ sp-160..-128) | untouched | match | yes | none | V-a64 | | B3 | FP/SIMD literal-load spill (x0/x1 @ sp-16) | untouched | match | yes | none | V-a64 | | B4 | Canary / stack high-water mark | untouched | 51/128 words dirtied by one `b` | yes | none | V-a64 | | B5 | Dead-stack scan for code-cache/agent return addresses | none | many | yes (forensic) | none | V-a64 (callback case) | | B6 | ShadowCallStack (x18) dead-area residue | system libs only | agent addresses | likely | SCS-built libs | THY | | B7 | `clone` child's x17 | preserved | = real next PC | yes | raw clone | V-a64 | | B8 | FPSR cumulative flags across code | unchanged | IXC etc. leak from callouts/JS | when callouts or JS run | none | V-a64 | | B9 | SVE P-regs / upper Z discarded by Stalker's syscalls | kept | zeroed | when Stalker issues syscalls | SVE | THY | | C1 | Cross-invocation SMC | new code runs | stale code (trust ≥ 0) | yes | RWX or dual map | V-a64 | | C2 | Intra-block SMC | new code runs | stale even with trust = −1 | yes | RWX or dual map | V-a64 | | C3 | SMC variants (remap, MADV_DONTNEED, cross-thread patch) | coherent | stale | yes | – | THY | | C4 | Cost of `trust = −1` | – | ~300–400× slowdown | yes | timing | V-x64 | | D1 | Execute a RW (non-exec) page | SIGSEGV | runs | yes | none | V-a64 | | D2 | Read-ahead past a trapping insn into a guard page | SIGTRAP at brk | SIGSEGV in Gum on guard page | yes | guard page | V-a64 | | D3 | Execute-only page gets "softened" to `r-x` | stays `--x` | becomes readable | yes | Android ≥ 10 | SRC | | D4 | Branch to a misaligned target | SIGBUS BUS_ADRALN at target | something else | yes | handler | V-a64 | | D5 | Undecodable bytes end the block with `brk #14` | SIGILL | SIGTRAP | yes | encodings Capstone rejects | SRC | | D6 | Misaligned SP + `svc` / + indirect branch | fine | unhandled SIGBUS / emulated SIGBUS storms | yes | real HW (SA0) | SRC | | D7 | BTI landing pad missing | SIGILL | runs | yes | FEAT_BTI | V-a64 | | D8 | `retaa`/`braa`/`blraa` | works | Stalker faults in Capstone | yes | FEAT_PAuth | V-a64 | | D9 | MTE-tagged free stack below SP | fine | SEGV_MTESERR | yes | FEAT_MTE enabled | V-a64 | | D10 | Branch while in SME streaming mode | fine | SIGILL in Gum | yes | SME without FA64 | V-a64 | | D11 | LDXR/STXR across a block boundary | STXR succeeds | STXR fails on first passes | likely | none | THY | | D12 | Raw `clone3` child | runs normally | resumes inside the cache | yes | – | SRC | | D13 | ART implicit null/suspend checks on a stalked thread | NPE/suspend | unattributed SIGSEGV → crash | likely | ART code stalked | THY | | D14 | GCS / FEAT_PAuth_LR | – | mismatch | – | future HW | THY | | E1 | `/proc/self/maps` fingerprint (unnamed `rwxp` 0x4D000; 8 MiB slabs) | absent | present | yes | none | V-a64/x64 | | E2 | `GumExecCtx` header with your TID at +0x18 | absent | present | yes | none | V-a64 | | E3 | pthread key → ExecCtx | absent | present | yes | none | V-a64/x64 | | E4 | Instruction signatures in RWX (`0xa8c947f0`, `0xa9b747f0`, `brk #14`, …) | 0 | hundreds | yes | none | V-a64 | | E5 | Copies of your own code bytes elsewhere | 0 | ≥ 2 | yes | none | V-a64 | | E6 | Pointers into your `.text` from foreign RWX | ~1 | hundreds | yes | none | V-a64 | | E7 | Memory and page-fault accounting | flat | grows | statistical | – | THY | | F1 | Exceptor footprint (kernel handler ≠ reported; `sigaction` patched) | consistent | inconsistent | yes | none | V-a64 | | F2 | `follow(tid)` artifacts (SIGCONT, TracerPid, dumpable flip, helper child, EINTR) | none | all of them | yes | none | V-x64 | | F3 | seccomp tripwires (exec mappings, ptrace attach) | allowlisted callers | trapped | yes | seccomp + NNP | V-x64 | | F4 | Per-thread kernel counters (`/proc/thread-self/io`, schedstat, minflt) | flat | jump on new pages | yes (Android ≥ 10) | dumpable for `io` | SRC | | F5 | Timing (`CNTVCT_EL0`) | baseline | ~40× fresh code, 3–4× hot | statistical | calibration | V-x64 | | F6 | GumJS side effects (latency spikes, deep stacks, 250 ms drain) | – | present | statistical | JS API used | SRC | | F7 | Entry-vector hooks (Interceptor on your function) | clean | patched / LR = trampoline | yes | typical usage | SRC | | F8 | Generic Frida presence (names, threads, ports…) | – | – | evadable | – | known | | G* | Privileged sensors (perf/eBPF, uprobes, ETM/SPE, SELinux) | – | – | yes | system component | THY | --- ## 3. A: Leaks of the real program counter Stalker keeps the *architectural* PC virtual only for the instructions it rewrites. The hardware PC is always inside the code cache, and the kernel will tell you what it is whenever it builds a `ucontext` or reports thread state. ### A1. Synchronous self-signal **[V-a64]** * **Why.** For a signal raised by your own `svc` (`tgkill`), the kernel saves the **hardware** PC, which is the cache address of the instruction after the copied `svc`. It then enters your handler at its real address, so the handler is *not* stalked. * **How.** 1. Issue `tgkill(getpid(), gettid(), SIG)` with an inline `svc`. 2. In the same block, compute the real address of the next instruction with `adr`. Stalker relocates `adr` to the real value. 3. In the handler, compare `uc_mcontext.pc` with that address, or simply check that the PC lies in one of your own `PF_X` segments. * **Which signal.** Use an **app-owned real-time signal** (`SIGRTMIN+n`). Frida's exceptor intercepts SIGABRT, SEGV, BUS, ILL, FPE, TRAP and SYS; ART claims some of these too. * **Observed.** * native: `pc == expected` (main image) * stalked: `pc` in an unnamed `rwxp` region * Code: `sd_check_signal_pc()` in Appendix A. ### A2. Trap and fault PCs **[V-a64]** * `brk #x`, `udf #x`, `hvc`, `smc`, unallocated encodings, and any faulting load or store executed from the cache report a cache PC to your handler. `si_addr` does too for brk and udf. * **Observed.** `brk #0x77`, `udf #0`, `udf #0xdead`, `hvc #0`, `smc #0` and `0xffffffff` each raised SIGILL or SIGTRAP with `pc` in anonymous RWX under Stalker, and at the original instruction natively. * These signals go through Frida's exceptor first. It chains them to you, because `gum_stalker_on_exception` only claims misaligned-SP spills. On Android they also go through libsigchain/ART. ### A3. Asynchronous PC sampling from a watchdog **[V-a64]** * An unfollowed watchdog thread sends an RT signal to each sensitive thread every few ms. The handler records `uc_mcontext.pc` and the watchdog classifies it. * **Observed.** * native: 20/20 samples in the image * stalked: 0/20 in the image, 20/20 in anonymous RWX * It also tells you **which** thread is being followed. * On Android, legitimate anonymous code is ART's JIT (`/memfd:jit-cache`, file-backed and named), so an unnamed anonymous executable PC is a strong signal. The exception is an app that embeds its own JIT (Mono, LuaJIT, a JS engine): allowlist its regions. Interceptor trampolines would also show up here. ### A4. `/proc/self/task//syscall` **[V-x64; Linux code path shared with Android]** * For a thread blocked in a syscall, the last two fields are the user **SP** and **PC**. Syscalls issued from stalked code execute from the cache. * **Observed.** After `Stalker.follow(tid)` on a thread blocked in `read()`, its PC moved from `libc.so` into the 0x4D000 RWX mapping. The infect thunk re-issues the interrupted syscall. * **Caveat.** The file is mode 0400, and its owner becomes root while the process is non-dumpable, which release Android apps are. To read it you must briefly set `prctl(PR_SET_DUMPABLE, 1)`. Decide whether that trade-off is acceptable; it also interferes with F2's dumpable watch. ### A5. rseq: kernel-enforced instruction-pointer range **[V-x64]** * **Why.** On preemption or signal delivery, if `rseq->rseq_cs` is set, the kernel checks the **real** IP against `[start_ip, start_ip + post_commit_offset)`. Inside the range it redirects to `abort_ip`; outside the range it just **clears `rseq_cs`**. * **How.** Write a critical section that spins while checking `rseq_cs != 0`, while a watchdog pings the thread with signals. * **Observed.** * native: 1 (kernel aborted the section) * stalked: 2 (`rseq_cs` was cleared while the code still believed it was inside the section) * **Android:** not usable by zygote-spawned apps. `rseq` is in neither bionic's `SYSCALLS.TXT` nor its seccomp allowlists, so calling it raises SIGSYS; checked against bionic `main`. Keep it for native daemons, privileged components and plain Linux. ### A6. Return addresses Stalker cannot fake (excluded ranges) **[V-a64]** Attackers routinely call `Stalker.exclude()` on libc, libart and other system libraries for speed. Calls into excluded ranges are executed as **real** `bl`/`blr` instructions **from the cache** (`:4389-4405`), so: * **(a)** the excluded callee's LR is a cache address. Observed: native LR in the image; stalked LR in anonymous RWX. * **(b)** callbacks invoked by excluded code (qsort comparators, `dl_iterate_phdr`, JNI up-calls) run *natively*, but the excluded caller's saved LR in its stack frame is a cache address. Observed: scanning 2 KiB of caller frames from a `qsort` comparator found **190** pointers into anonymous executable memory under Stalker, and 0 natively. * **(c)** consequently, `_Unwind_Backtrace` or frame-pointer walks from such callbacks hit unattributable frames. Frida's unwind broker only patches Frida's *own* statically linked unwinder (`gum/backend-linux/gumunwindbroker-linux.c:192-201`), not yours. ### A7. x86 / x86_64 Android (emulators, ChromeOS/ARC, WSA) **[V-x64]** * **RCX after `syscall`.** The CPU writes the return RIP into RCX, so under Stalker RCX is a cache address. Observed. * **x87 FIP.** `fxsave64` (or `fnstenv`) after an x87 instruction stores the last FPU instruction pointer, which is a cache address under Stalker. Observed. * B-style spill residue also exists on x86: the x86 backend uses the same 128-byte red zone. ### A8. 32-bit ARM / Thumb processes **[THY]** * AArch32 lets code read PC as a general-purpose register: * `mov rX, pc`, `add rX, pc, rY`; * `str pc, […]`, `stm/push {…, pc}`; * `ldr`/`ldrd`/`vldr [pc, rX]`; * `tbb`/`tbh [pc, rX]`; * `add pc, pc, rX, lsl #2` jump tables. * Any form that `gumarmrelocator.c`/`gumthumbrelocator.c` does not rewrite either leaks the cache address or derails. Audit this against the Frida version you care about. --- ## 4. B: Stack and register residue An AArch64 DBI that does not steal a register must spill scratch registers to memory relative to SP (there is no PC-relative store). Stalker spills below SP. AAPCS64 has no red zone, so natively nothing writes below SP except signal frames. ### B1. `svc` residue **[V-a64]** * Every `svc` becomes `stp x15, x17, [sp, #-0x90]!; mrs x15, nzcv; sub x17, x8, #220; cbz …; msr nzcv, x15; ldp x15, x17, [sp], #0x90; svc #0` (`:4479-4522`). * So after any syscall, **`[sp-144] == x15` and `[sp-136] == x17`**. * This is the always-executed fast path: no warm-up is needed and it fires every time. * **Observed.** native `0`, stalked `3` (both slots match). Code: `sd_svc_residue`. ### B2. Indirect branch and return residue **[V-a64]** * The inline cache (`:5281-5345`) does `stp x16, x17, [sp, #-0x90]!; stp x0, x1, [sp, #-16]!` before the lookup. The target block's restoration prolog pops x16/x17, but the memory keeps the values. * So after any `ret`, `br` or `blr`: **`[sp-160]=x0, [sp-152]=x1, [sp-144]=x16, [sp-136]=x17`**. * Slow-path transfers (`:5353`) and far jumps (`:4127`) also leave x16/x17 there. * **Observed.** `0xf` (all four slots match) on the first execution **and** on three consecutive steady-state runs; 0 natively. Code: `sd_ret_residue`. ### B3. FP/SIMD literal loads **[V-a64]** * `ldr d0/q0/s0, ` is rewritten to `stp x0, x1, [sp, #-16]!; ldr x0, =addr; ldr d0, [x0]; ldp x0, x1, [sp], #16` (`gumarm64relocator.c:1227-1236`). * So x0/x1 end up **directly below SP**, with no red zone. * **Observed.** native 0, stalked 3. Code: `sd_fp_literal_residue`. ### B4. Canary and stack high-water mark **[V-a64]** * **How.** Fill a window below SP with a pattern, using straight-line code with no branch in the fill or the check. Execute **one** direct `b`, then count the dirty words. * **Observed.** natively 0; under Stalker, the first transit of the slow path dirtied **51–52 of 128 words** in 1 KiB. The prolog saves a GumCpuContext of about 800 B plus spills. * Callouts and JS transforms push the JS engine's frames onto **your** stack, which means KBs to tens of KBs. * B4 generalizes B1–B3. It still works against a custom Frida build with a different red-zone size. * **False positives.** Signal frames also land below SP. Bionic gives every thread a sigaltstack, so where possible use `SA_ONSTACK`, and prefer exact-offset nonces over "anything dirty". ### B5. Dead-stack forensics **[V-a64 for the callback case]** * Scan the dead part of the thread stack, from the guard page up to SP, for words that look like return addresses into unnamed anonymous executable memory or into the agent. * These survive `unfollow` until they are overwritten, which gives a forensic signal that this thread was stalked or ran Frida code (Interceptor, JS). ### B6. ShadowCallStack residue (x18) **[THY]** * Android platform libraries are built with SCS. If Gum's C code calls SCS-instrumented libc on your thread, return addresses into the agent are pushed past `x18`. They remain readable in the dead part of the shadow stack. ### B7. `clone` child x17 **[V-a64]** * In the child of a raw `clone` issued from stalked code, Stalker uses `ldr x17, =; br x17` to send the child back to native code (`:4566-4570`). The kernel otherwise copies every register. * **Observed.** * native: x15/x17 intact * stalked: **x17 clobbered**, x15 intact ### B8. FPSR (and FPCR) are not preserved **[V-a64]** * Stalker's prologs save x0–x28, fp, lr, nzcv and q0–q31, but **never FPSR/FPCR** (`:3139-3290`). * Any FP work done by instrumentation on your thread leaks its cumulative exception flags into *your* FPSR. That includes C callouts, JS transforms and JS callouts (JS numbers are doubles). * **Observed.** With a callout doing `1.0/3.0`, FPSR.IXC (0x10) appeared between two integer-only instructions. Without callouts (and natively) FPSR stayed 0. ### B9. SVE/SME architectural state discarded by Stalker's syscalls **[THY]** * On Linux, a syscall discards SVE state beyond the V registers: P registers, FFR and the upper Z bits. * Pure compute code with no syscalls keeps P registers across branches. Under Stalker, the syscalls it issues on your thread wipe them: Android page-softening (open/ioctl/read of `/proc/self/maps`, `mprotect`) and `mmap` for new slabs. * **Probe.** `ptrue p0.b`, branch into never-executed code on a fresh page, then `cntp`. --- ## 5. C: Code-cache coherency ### C1. Cross-invocation SMC **[V-a64]** * **How.** Run a stub at least twice, patch it, do proper cache maintenance, run it again. * **Observed.** * native: the new code runs * Stalker with default `trust_threshold=1`: the **stale** translation runs * Stalker with `trust_threshold=-1`: correct, because the snapshot is `memcmp`'d on every lookup ### C2. Intra-block SMC **[V-a64]** * A block is translated **before** it executes. Code that patches a *later* instruction of the *same* block (`str; dc cvau; dsb; ic ivau; dsb; isb`, all within one block) is never seen. * **Observed.** * native: 2 (patched instruction ran) * stalked: 1 (stale), **both with trust = 1 and with trust = −1** * Code: `sd_smc_template` and `sd_check_intra_block_smc`. If SELinux denies RWX for your app, use a memfd mapped twice (RW and RX). Note that Frida itself relies on RWX on Android. ### C3. Variants **[THY]** * `munmap` and then `mmap` new code at the same VA. * `MADV_DONTNEED` on anonymous code: the page is zero-filled, which natively executes as `udf #0`. * Writes through a RW alias of a memfd double mapping. Stalker never observes them. * **Cross-thread.** An unfollowed watchdog patches a spinning loop (`nop` → `b exit`, both CMODX-safe). The stalked thread keeps spinning on the stale copy until the watchdog times out. ### C4. Hardening has a price **[V-x64]** * `trust_threshold = −1` disables backpatching and the inline caches. * **Measured.** * a straight loop costs about **400 cycles per iteration** (natively about 1) * an indirect call costs about **1 200 cycles** (natively 3–4) * That is 2–3 orders of magnitude, so F5 fires trivially. --- ## 6. D: Semantic divergences and hardware traps ### D1. Executing a non-executable page **[V-a64]** * Stalker reads guest code as data and never checks `PROT_EXEC`. A function on a `PROT_READ|PROT_WRITE` page: * natively: SIGSEGV (SEGV_ACCERR, PC = the page) * stalked: returns 42 * Code: `sd_check_nx_exec`. ### D2. Read-ahead fault **[V-a64]** * The relocator decodes the **whole basic block** before running it. Its end-of-block rules (`gumarm64relocator.c:252+`) never stop at `brk`, `udf` or `svc`. * **How.** Put a `brk` as the last instruction of an `R-X` page that is followed by a `PROT_NONE` page. * **Observed.** * native: SIGTRAP at the `brk` * Stalker: **SIGSEGV on the guard page with PC inside Gum** (Capstone/relocator), before the `brk` ever runs * On Android ≥ 10, `gum_ensure_code_readable(block, page_size)` checks the *next* page too and `mprotect`s it to `prot|PROT_READ`. The guard page then silently becomes `r--`, which is detectable through `/proc/self/maps` (see D3). ### D3. Execute-only pages get "softened" (Android API ≥ 29) **[SRC]** * **How.** Map a page `PROT_EXEC` only (or link your library with `-Wl,--execute-only`) and execute it from the protected thread. * Stalker queries its protection and `mprotect(+R)`s it (`gummemory.c:2161-2207`). Afterwards: * `/proc/self/maps` shows `r-xp` instead of `--xp`, with a VMA split if the page sat inside a bigger mapping; * `process_vm_readv()` on your own process succeeds where it used to fail with EFAULT. This holds whether or not the CPU enforces XOM, because the kernel checks `VM_READ`. ### D4. Branching to a misaligned target **[V-a64]** * `blr` to `page+2`: * natively: **SIGBUS BUS_ADRALN with PC = target** * under Stalker: Capstone decodes garbage from the misaligned address. One run gave SIGTRAP from the cache; another gave SIGSEGV elsewhere. It is never the native fault. ### D5. Undecodable bytes **[SRC]** * When Capstone cannot decode an instruction, `read_one` returns 0 and the block ends. The next generated instruction is `brk #14` (`:2681`), so you get **SIGTRAP (BRK 14)** instead of SIGILL at the original address. * The reserved encodings I tried were all decoded by Frida's Capstone and executed in place (SIGILL from a cache PC, which falls under A2). Encodings Capstone rejects behave as described here. ### D6. Misaligned SP **[SRC; qemu does not enforce SP alignment]** Linux sets `SCTLR_EL1.SA0`, so an SP-relative access while `SP % 16 != 0` faults. Native code that keeps SP misaligned but never accesses memory through SP runs fine. Under Stalker: * **`svc` with a misaligned SP.** The wrapper's `stp x15, x17, [sp, #-0x90]!` (`0xa9b747ef`) is **not** in the emulation whitelist (`:3669-3700`). The result is an unhandled SIGBUS BUS_ADRALN at a cache PC, delivered to your handler or crashing the process. * **Indirect branches with a misaligned SP.** Each one costs 4 emulated SIGBUS: `stp x16/x17`, `stp x0/x1`, `ldp x0/x1`, `ldp x16/x17`. That means microseconds per branch, plus fresh signal frames on the thread's sigaltstack (Frida's handler is `SA_ONSTACK`). Look for the `ESR_MAGIC 0x45535201` / `FPSIMD_MAGIC 0x46508001` records with EC 0x26. ### D7. BTI landing pads are not enforced **[V-a64]** * **How.** `mprotect(PROT_READ|PROT_EXEC|PROT_BTI)` a page whose first instruction is not `bti c`, then call it indirectly. * **Observed.** * native: SIGILL (BTI exception) * Stalker: returns, because the cache is not a guarded page * Needs `HWCAP2_BTI` (Armv8.5+, e.g. Cortex-A710/X2 and newer). Code: `sd_check_bti`. ### D8. PAC combined instructions **[V-a64]** * `retaa`, `retab`, `braa`, `brab`, `blraa`, `blrab` and their `z` forms: Stalker uses the **signed** register as the branch target. There is no `xpaci` off Darwin, so it compiles a block at a non-canonical address and gets **SIGSEGV inside Capstone's `_getInstruction`** while holding the ExecCtx code lock. * **Observed.** * native: `paciasp … retaa` returns normally * stalked: crash in Gum * Needs `HWCAP_PACA`. * The hint-space `paciasp`/`autiasp` + `ret` that the NDK emits run fine under Stalker, so you have to use the combined instructions deliberately. Code: `sd_check_retaa`. ### D9. MTE-tagged free stack **[V-a64]** * **How.** Switch to a `PROT_MTE` stack, `stg` a non-zero tag onto the free area below SP (SP keeps tag 0), and take one indirect branch. * **Observed.** * native: nothing touches the tagged area * Stalker: its spill raises **SEGV_MTESERR (si_code 9)** with PC in the cache * Needs `HWCAP2_MTE` and `PR_SET_TAGGED_ADDR_CTRL` with sync mode (Pixel 8+ and similar, once MTE is enabled). ### D10. SME streaming mode **[V-a64]** * **How.** `smstart sm; br ; smstop sm`. * **Observed.** * native: fine * Stalker: its slow path runs Gum's C code, which is compiled with Advanced SIMD, and gets **SIGILL** in streaming mode. Observed with `-cpu max,sme_fa64=off`; with FEAT_SME_FA64 enabled nothing happens. * Needs `HWCAP2_SME` (recent Armv9.2+ cores). ### D11. Exclusive monitors (LDXR/STXR) **[THY]** * An LL/SC loop that spans a block boundary goes through Stalker's slow path on its first passes: C code with spinlocks and atomics, sometimes syscalls, runs between the LDXR and the STXR. The monitor is lost and the STXR fails. * **How.** Count failures over freshly generated sequences. Natively the count is about 0. * Stalker only suppresses *events* inside exclusive regions (`GUM_EXEC_BLOCK_USES_EXCLUSIVE_ACCESS`); it cannot remove the transfer code. * qemu cannot model this, so validate it on a device. ### D12. `clone3` children run inside the cache **[SRC; qemu-user lacks clone3]** * Only `__NR_clone` is special-cased on arm64 (`:4498`) and arm32 (`gumstalker-arm.c:4921`). The x86 backend handles `clone3`. * A raw `clone3` child therefore resumes in the cache, sharing the parent's ExecCtx. Expect the residue probes to fire in the child, and probably corruption or a crash. * Android's app seccomp policy **allows** `clone3`. ### D13. ART implicit checks **[THY]** * ART turns SIGSEGV at known PCs into NullPointerException, suspension or stack-overflow handling, after checking that the PC belongs to compiled managed code. * If ART-compiled code runs stalked (libart or the boot/app oat not excluded), the faulting PC is a cache address. ART cannot attribute it and treats it as a real native crash. * A Java call that deliberately triggers a caught implicit NPE on the protected thread therefore becomes a tripwire. ### D14. Future hardware **[THY]** * **GCS** (Armv9.4 Guarded Control Stack; user GCS since Linux 6.13). Calls that Stalker emulates as `ldr x30, =…; b` never push onto the GCS, and its returns are `br`. GCS contents then disagree with the frame-chain return addresses. * **FEAT_PAuth_LR** (`paciasppc`/`autiasppc`) signs using the PC, which differs in the cache. --- ## 7. E: Memory forensics (code-cache and engine artifacts) ### E1. `/proc/self/maps` fingerprint **[V-a64 / V-x64]** * **One unnamed anonymous `rwxp` mapping of exactly `0x4D000` bytes per followed thread** (4 KiB pages; I computed `0x54000` for 16 KiB pages). It appears when following starts. * As more code is translated: more **8 MiB `rwxp`** code+slow slabs and **`rw-p` 0xCD000** data slabs. * All of these are freed on unfollow or garbage collection, so the churn is itself a signal. * **Observed.** `0x4d000` on x86_64. On arm64, qemu split the region into sub-ranges that add up to `0x4d000`. * On Android, almost every legitimate anonymous mapping is named `[anon:…]`, and ART's JIT is `/memfd:jit-cache`. An unnamed RWX anonymous region is already suspicious, unless your app embeds its own JIT; Interceptor trampolines live in one too. The exact size and the E2 header make the match specific to Stalker. ### E2. `GumExecCtx` header **[V-a64]** * Layout at the mapping base: * `+0x00` state (0 active, 1 unfollow pending, 2 destroy pending) * `+0x08` destroy_pending_since * `+0x10` `GumStalker*` * `+0x18` **thread id** * If the thread id equals your `gettid()`, the thread you are on is being followed. Observed. * Code: `sd_check_exec_ctx_maps`. ### E3. TLS **[V-a64 / V-x64]** * Iterate the pthread keys (bionic encodes them as `i | 1u<<31`) and look for a value that is the page-aligned base of a mapping carrying an E2 header. * **Observed.** Key 4 on both musl and glibc while stalked; nothing natively. * This is the cheapest of the three: no `/proc` parsing. Validate that the pointer is mapped before dereferencing it. Code: `sd_check_exec_ctx_tls`. ### E4. Instruction signatures in anonymous executable memory **[V-a64]** * **Observed** after a short run (native counts were all 0): | Encoding | Instruction | Count | |---|---|---| | `0xa8c947f0` | `ldp x16, x17, [sp], #0x90` | 1 399 | | `0xa9b747f0` | `stp x16, x17, [sp, #-0x90]!` | 899 | | `0xd42001c0` | `brk #14` | 500 | * Also characteristic: | Encoding | Meaning | |---|---| | `0xa9b747ef` / `0xa8c947ef` | `svc` wrapper | | `0xa9b77bf3` / `0xa8c953f3` | prolog / epilog | | `0xd42001e0` / `0xd4200200` | `brk #15` / `brk #16` padding around clone syscalls | | `0xbaadd00ddeadface` | empty inline-cache entries in the data slabs | ### E5. Copies of your own code **[V-a64]** * After a function ran once under Stalker, 16 bytes of it were found **twice** in anonymous RWX: once as the translated block (non-PC-relative instructions are copied verbatim) and once as the trust-threshold snapshot. Natively there were 0 copies. * This works against any DBI. ### E6. Pointers into your `.text` held by foreign memory **[V-a64]** * Count the 8-byte values inside anonymous RWX that point into your `.text`. They come from literal pools holding real targets and return addresses, from IC entries and from block metadata. * **Observed.** **587** under Stalker versus 1 natively. * Extend the scan to RW data slabs and to Gum's heap. ### E7. Resource accounting **[THY]** * Each followed thread adds at least 308 KiB of VSZ, then 8 MiB per extra code slab. * `getrusage(RUSAGE_THREAD).ru_minflt` rises on the stalked thread while it executes new code. * RSS grows with the amount of code translated. --- ## 8. F: OS and runtime footprint ### F1. Exceptor footprint **[V-a64]** * After `gum_stalker_new()`: * libc `sigaction()`'s prologue is patched (inline hook); * a raw `rt_sigaction(SIGSEGV, NULL, &old)` returns Gum's handler with flags `0x4c000004` (`SA_SIGINFO|SA_ONSTACK|SA_NODEFER|SA_RESTORER`); * libc `sigaction()` still reports *your* handler. * On Android the kernel-level handler for SEGV, BUS and the rest should belong to **libsigchain/ART**. Anything else (memfd, anonymous memory, an unknown library) means someone bypassed sigchain. * The exceptor is present whenever Stalker (arm64) or GumJS runs. Mode FULL is the default; an embedder can switch it with `gum_exceptor_set_mode()`. * Code: `sd_kernel_sigsegv_handler`. ### F2. `Stalker.follow()` artifacts **[V-x64; same `gumprocess-linux.c` on Android]** 1. **SIGCONT** is delivered to the victim thread on `PTRACE_DETACH` (`si_code` SI_USER). Your handler sees **`uc PC` = the infect thunk inside the 0x4D000 RWX mapping**. Observed both for a spinning thread and for one blocked in `read()`. Code: `sd_install_sigcont_tripwire`. 2. `TracerPid ≠ 0` and state `t` appear transiently in `/proc/self/task//status`. 3. `prctl(PR_GET_DUMPABLE)` flips **0 → 1 → 0**. Release apps are non-dumpable. 4. A **helper process** (CLONE_VM, no SIGCHLD) appears in `/proc/self/task//children` of the thread that called follow (needs `CONFIG_PROC_CHILDREN`; otherwise look for a `/proc/` whose `PPid` is your own PID). 5. Two `socketpair` fds exist transiently. 6. `epoll_wait`, `sigtimedwait`, `semtimedop` and `SO_RCVTIMEO` reads return **EINTR** without any handler running. Observed for `epoll_wait`. 7. The victim's `/proc/…/syscall` PC jumps into the thunk. * **Observed by a polling watcher during one follow:** dumpable==1 98 times, TracerPid≠0 92 times, `task/children` non-empty 103 times. * The same artifacts are triggered by `Stalker.invalidate(tid, …)` and `addCallProbe` (which invalidate other threads), `Process.enumerateThreads()`/`findThreadById()` (which fetch CPU contexts through the same path), and hardware breakpoints/watchpoints. ### F3. seccomp tripwires **[V-x64]** * **How.** A filter traps `mmap`, `mprotect` and `pkey_mprotect` when `PROT_EXEC` is requested, unless the call comes from your trusted `svc` gadget. The SIGSYS handler logs the caller and emulates the syscall, so nothing breaks. * **Observed.** `follow_me()` produced **exactly one** trapped `mmap(…, PROT_READ|PROT_WRITE|PROT_EXEC, …)`: the ExecCtx allocation. * **Same idea for follow(tid).** * Trap `ptrace(PTRACE_ATTACH)`: Frida's helper uses ATTACH, while Android's `crash_dump` uses SEIZE. * Trap `clone` without `CLONE_THREAD`: the helper uses `CLONE_VM|CLONE_SETTLS`. * Both `seccomp` and `PR_SET_NO_NEW_PRIVS` are allowed for Android apps. * **Caveats.** * Filters are permanent and inherited by children. * The linker and ART's JIT legitimately create executable mappings. * The handler must be async-signal-safe. * The helper process shares your memory but not your TLS. * Code: `sd_install_exec_mapping_tripwire`. ### F4. Per-thread kernel counters **[SRC]** * On Android ≥ 10, the first compilation on each new page makes **your** thread open `/proc/self/maps`. * On kernels without PROCMAP_QUERY (< 6.11), Stalker then parses the file with `read()`, so `rchar` and `syscr` in `/proc/thread-self/io` jump. That file is mode 0400, so it is readable only while the process is dumpable. * Also expect extra `stime`, more `/proc/thread-self/schedstat` runtime, and more minor faults. ### F5. Timing **[V-x64 magnitudes]** * **Measured on x86_64:** | Workload | Native | Stalked | |---|---|---| | Hot steady-state loop | 1× | **3–4×** (noisy) | | First execution of fresh code | ≈200 cycles | **≈6–8 k cycles (~40×)** | | Anything with `trust=-1` | 1× | **~300–400×** | | Branches with a misaligned SP | – | microseconds per branch | * **On arm64**, use `CNTVCT_EL0`: it is readable at EL0 on Android, and the vDSO uses it. * **What to compare.** Ratios rather than absolute times: * fresh code vs. warm code; * a polymorphic indirect call site whose targets exceed the 2-entry IC vs. a monomorphic one; * your thread vs. a calibration run on an unfollowed thread. * **Where to measure from.** An unfollowed watchdog, where possible: a transformer can rewrite `mrs cntvct_el0` inside a stalked thread. ### F6. GumJS side effects **[SRC]** * A `transform` runs on your thread for every newly compiled block. That puts the JS engine on your stack, sets FP flags (B8), and makes your thread wait on the JS lock whenever the JS thread is busy, which shows up as latency spikes. * A JS `putCallout` does the same on every execution. * `onReceive`/`onCallSummary` make the JS thread wake up every 250 ms. * `Stalker.follow()` called from `onEnter` starts stalking inside Frida's Interceptor epilogue, so your function is entered through the Interceptor trampoline (see F7). ### F7. Entry vector **[SRC]** Stalking is usually started from an Interceptor hook on *your* function. Detect the hook: * the prologue differs from the bytes on disk; * `/proc/self/smaps` shows `Private_Dirty` text pages; * with `onLeave`, `__builtin_return_address(0)` is the `on_leave_trampoline`, which lives in anonymous RWX (`gum/guminterceptor.c:2368`). Also check for hooks on `JNI_OnLoad`, `RegisterNatives` targets and your exported symbols. ### F8. Generic Frida presence (a prerequisite, but easily renamed) * Agent or gadget mappings (`memfd:frida-agent…`, renamed `.so` files). * Thread names: `gum-js-loop`, `gmain`, `gdbus`, `pool-…`. * Ports and sockets (27042), frida-server files, SELinux policy additions made by frida-server. * Inline hooks in libc and libart. Custom builds remove all of these. **None of the checks in A–F rely on names.** --- ## 9. G: Privileged or out-of-process sensors (if the EDR has a system component) * **PC sampling.** perf_event, eBPF or ETM/ETE/TRBE/SPE samples of the app's PCs that land in unnamed anonymous executable memory. * **uprobes** on your code. The kernel's `BRK` gets copied into the code cache and executed there. No uprobe is registered at that address, so the uprobe never fires and the kernel sends the app a SIGTRAP **[THY]**. * **External polling** of `/proc//maps`, `/proc//task/*/{syscall,status}` and the dumpable state, which avoids the self-read restrictions mentioned in A4 and F4. * **SELinux policy integrity.** frida-server patches the policy. Also watch audit logs for execmem and ptrace denials. --- ## 10. What does *not* detect Stalker (don't waste checks on these) * **LR checks** (`__builtin_return_address(0)`, `adr`/`adrp`/`ldr literal`/`bl`-based "where am I"): Stalker relocates these to real addresses and loads real return addresses into LR. * **Integrity checks of your own code bytes.** Stalker never patches original code. These checks still catch the Interceptor entry hook (F7). * **Normal unwinding and C++ exceptions** on non-excluded paths. Return addresses are real, and the unwind broker translates excluded call sites inside Frida's own unwinder. * **NZCV, x18 and callee-saved registers** across branches. Stalker saves and restores them. * **Hot-loop timing alone.** It is only 3–4× and noisy. * **Name or string searches** for "frida" or "gum". Custom builds remove them. * **Isolating probes in `fork()` or `pthread_create()` children. They always run natively** **[V-a64].** Measured: svc-residue probe = 3 in the stalked thread, 0 in a new pthread, 0 in a fork child. The cause is that arm64 Stalker sends a `clone` child back to the original code. Keep destructive probes on the protected thread behind a `sigsetjmp` guard, as Appendix A does. --- ## 11. An adaptive attacker: what survives what Stalker is the ultimate tool against in-thread checks, because the attacker sees and can rewrite **every instruction of your check** with a transformer. Assume the attacker adapts. | Attacker move | Still detects | |---|---| | Custom Frida build with a different red zone, renamed or named mappings | B4 (any distance), A1–A3, C2, D1–D10, E2–E6 (content-based), F1–F3 | | Transformer that rewrites your probes (e.g. emits a raw `svc` with no wrapper, fakes `mrs cntvct_el0`, skips your SMC stub) | Anything observed **outside** the stalked thread: A3, A4, F2, F3, external G; kernel-mediated A1 (the handler runs natively); hardware traps D7–D10; forensic E*. Also inline many *diverse* checks and **derive keys or values from probe outputs** instead of branching on a boolean | | `trust_threshold = -1` | C2 and F5 (~300–400× slowdown) | | Exclude your module, or start following only after your checks | Repeat checks at points of use; tripwires F2 and F3 fire at any time; A6 catches its callers | | Exceptor mode HANDLER_ONLY or OFF | HANDLER_ONLY only drops the libc hook: the raw kernel-handler check (F1) still sees Gum. OFF silences F1, **but misaligned-SP branches then crash Stalker** (D6), because nothing emulates the spills | | Follow your watchdog too | Heartbeat or liveness cross-checks between several threads; F2 artifacts appear when the watchdog gets followed | | Hook `sigaction` to block your handlers | Raw `rt_sigaction` (F1 also exposes the hook) | Base your decisions on the most fundamental properties of an arm64 DBI. These only go away if the attacker rewrites the engine (for example by stealing a register): * (1) it must spill below SP: B1, B2, B4; * (2) it executes from a cache: A1–A4, E1; * (3) translation runs ahead of execution: C2, D2; * (4) it re-implements instruction semantics: D7–D10. --- ## 12. Responding: making Stalker lose * **Escape.** Your synchronous signal handler (A1) runs natively. Before returning it can set `uc_mcontext.pc` to a native continuation, abort, or report. * **Move sensitive work to fresh threads.** They run natively unless the attacker explicitly follows them. Verified for pthread and fork children. * **Wedge or kill the tracer.** `retaa` faults Gum while it holds the ExecCtx code lock (D8). Misaligned SP + `svc` gives an unhandled SIGBUS (D6). BTI, MTE and SME traps work too (D7, D9, D10). * **Prevent `follow(tid)`.** Return `SECCOMP_RET_ERRNO(EPERM)` for `ptrace(PTRACE_ATTACH)` inside your process tree. The helper process inherits the filter, `gum_process_modify_thread()` fails with "Unable to attach to thread", and the thread is never infected. Debuggers that use ATTACH are blocked too; Android's `crash_dump` uses SEIZE. Alternatively, kill the process from the SIGCONT tripwire (F2). --- ## Appendix A: Reference implementation (Android arm64) * Compile-checked with **NDK r29** (`clang --target=aarch64-linux-android29 -Wall -Wextra`). * Validated against Frida 17.25.0's arm64 Stalker under `qemu-aarch64 -cpu max`. Every check returns 0 natively and non-zero while the calling thread is followed: ``` [native ] svc=0 ret=0 fplit=0 signal_pc=0 smc=0 nx=0 ctx_maps=0 ctx_tls=0 bti=0 retaa=0 [stalked] svc=3 ret=0xf fplit=3 signal_pc=1 smc=1 nx=1 ctx_maps=1 ctx_tls=1 bti=1 retaa=1 ``` * Call the probes **from the code path you want to protect**, many times and inlined, rather than from one central "is_frida()" function. ```c /* * Android arm64 reference snippets for detecting Frida Stalker. * Compile-checked with NDK r29 clang (aarch64-linux-android29) and validated * against Frida 17.25.0's arm64 Stalker (qemu-aarch64 -cpu max): every check * returns 0 natively and non-zero while the calling thread is followed. */ #define _GNU_SOURCE #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include /* ------------------------------------------------------------------ */ /* B1/B2/B3: below-SP spill residue (single basic block per probe) */ /* ------------------------------------------------------------------ */ __asm__ ( ".text\n" ".macro LOAD64 reg, v0, v1, v2, v3\n" " movz \\reg, #\\v0\n" " movk \\reg, #\\v1, lsl #16\n" " movk \\reg, #\\v2, lsl #32\n" " movk \\reg, #\\v3, lsl #48\n" ".endm\n" /* uint64_t sd_svc_residue(void): bit0 = [sp-144]==x15, bit1 = [sp-136]==x17 */ ".globl sd_svc_residue\n" ".type sd_svc_residue, %function\n" ".p2align 4\n" "sd_svc_residue:\n" " mov x9, sp\n" " stp xzr, xzr, [x9, #-144]\n" /* wipe the two slots */ " LOAD64 x15, 0x1515, 0xdead, 0xbeef, 0x1515\n" " LOAD64 x17, 0x1717, 0xcafe, 0xf00d, 0x1717\n" " mov x8, #172\n" /* __NR_getpid */ " svc #0\n" " ldp x2, x3, [x9, #-144]\n" " cmp x2, x15\n" " cset w0, eq\n" " cmp x3, x17\n" " cset w1, eq\n" " orr w0, w0, w1, lsl #1\n" " ret\n" /* uint64_t sd_ret_residue(void): bits = x16@-144, x17@-136, x0@-160, x1@-152 */ ".globl sd_ret_residue\n" ".type sd_ret_residue, %function\n" ".p2align 4\n" "sd_ret_residue:\n" " mov x9, sp\n" " stp xzr, xzr, [x9, #-160]\n" " stp xzr, xzr, [x9, #-144]\n" " LOAD64 x0, 0x0000, 0x1111, 0x2222, 0x0a0a\n" " LOAD64 x1, 0x0101, 0x3333, 0x4444, 0x0b0b\n" " LOAD64 x16, 0x1616, 0x5555, 0x6666, 0x0c0c\n" " LOAD64 x17, 0x1717, 0x7777, 0x8888, 0x0d0d\n" " mov x12, x30\n" " bl 1f\n" /* callee is a bare `ret` */ " mov x30, x12\n" " ldp x2, x3, [x9, #-144]\n" " ldp x4, x5, [x9, #-160]\n" " cmp x2, x16\n" " cset w6, eq\n" " cmp x3, x17\n" " cset w7, eq\n" " orr w6, w6, w7, lsl #1\n" " cmp x4, x0\n" " cset w7, eq\n" " orr w6, w6, w7, lsl #2\n" " cmp x5, x1\n" " cset w7, eq\n" " orr w0, w6, w7, lsl #3\n" " ret\n" "1: ret\n" /* uint64_t sd_fp_literal_residue(void): bit0/1 = x0/x1 at [sp-16, sp) */ ".globl sd_fp_literal_residue\n" ".type sd_fp_literal_residue, %function\n" ".p2align 4\n" "sd_fp_literal_residue:\n" " mov x9, sp\n" " stp xzr, xzr, [x9, #-16]\n" " LOAD64 x0, 0x1234, 0x0000, 0x0000, 0xaaaa\n" " LOAD64 x1, 0x5678, 0x0000, 0x0000, 0xbbbb\n" " ldr d0, 2f\n" " ldp x2, x3, [x9, #-16]\n" " cmp x2, x0\n" " cset w4, eq\n" " cmp x3, x1\n" " cset w5, eq\n" " orr w0, w4, w5, lsl #1\n" " ret\n" ".p2align 3\n" "2: .quad 0x3ff0000000000000\n" /* void *sd_tgkill(long tgid, long tid, long sig): returns the real address after `svc` */ ".globl sd_tgkill\n" ".type sd_tgkill, %function\n" ".p2align 4\n" "sd_tgkill:\n" " mov x8, #131\n" /* __NR_tgkill */ " svc #0\n" "3: adr x0, 3b\n" " ret\n" /* Intra-block SMC template: patches its own `movz w0, #1` into `movz w0, #2`. */ ".globl sd_smc_template\n" ".globl sd_smc_template_end\n" ".p2align 4\n" "sd_smc_template:\n" " adr x1, 4f\n" " movz w2, #0x0040\n" " movk w2, #0x5280, lsl #16\n" /* 0x52800040 = movz w0, #2 */ " str w2, [x1]\n" " dc cvau, x1\n" " dsb ish\n" " ic ivau, x1\n" " dsb ish\n" " isb\n" "4: movz w0, #1\n" " ret\n" "sd_smc_template_end:\n"); extern uint64_t sd_svc_residue (void); extern uint64_t sd_ret_residue (void); extern uint64_t sd_fp_literal_residue (void); extern void * sd_tgkill (long tgid, long tid, long sig); extern char sd_smc_template[], sd_smc_template_end[]; int sd_check_spill_residue (void) { return sd_svc_residue () == 3 || sd_ret_residue () == 0xf || sd_fp_literal_residue () == 3; } /* ------------------------------------------------------------------ */ /* In-thread fault guard. Do NOT isolate probes with fork()/pthread_create(): */ /* arm64 Stalker sends the child of a clone() back to the original code, so */ /* a child of a stalked thread always runs natively. */ /* ------------------------------------------------------------------ */ static sigjmp_buf sd_jb; static volatile int sd_fault_sig; static void sd_on_fault (int sig, siginfo_t * si, void * ctx) { sd_fault_sig = sig; siglongjmp (sd_jb, 1); } /* Returns 0 and stores the result if fn() returned, or the signal number. */ static int sd_guarded_call (uint64_t (* fn) (void), uint64_t * result) { static const int sigs[] = { SIGSEGV, SIGBUS, SIGILL, SIGTRAP }; struct sigaction sa, old[4]; int i, sig; memset (&sa, 0, sizeof (sa)); sa.sa_sigaction = sd_on_fault; sa.sa_flags = SA_SIGINFO | SA_NODEFER | SA_ONSTACK; for (i = 0; i != 4; i++) sigaction (sigs[i], &sa, &old[i]); sd_fault_sig = 0; if (sigsetjmp (sd_jb, 1) == 0) *result = fn (); sig = sd_fault_sig; for (i = 0; i != 4; i++) sigaction (sigs[i], &old[i], NULL); return sig; } /* ------------------------------------------------------------------ */ /* A1: real PC seen by a signal handler (signal not claimed by Frida/ART) */ /* ------------------------------------------------------------------ */ static volatile uintptr_t sd_seen_pc; static void sd_on_probe_signal (int sig, siginfo_t * si, void * ctx) { sd_seen_pc = ((ucontext_t *) ctx)->uc_mcontext.pc; } int sd_check_signal_pc (void) { const int sig = SIGRTMIN + 7; /* pick an RT signal your app owns */ struct sigaction sa, old; void * expected; memset (&sa, 0, sizeof (sa)); sa.sa_sigaction = sd_on_probe_signal; sa.sa_flags = SA_SIGINFO; sigaction (sig, &sa, &old); sd_seen_pc = 0; expected = sd_tgkill (getpid (), gettid (), sig); sigaction (sig, &old, NULL); return sd_seen_pc != (uintptr_t) expected; } /* ------------------------------------------------------------------ */ /* C2: intra-block self-modifying code */ /* ------------------------------------------------------------------ */ int sd_check_intra_block_smc (void) { size_t n = sd_smc_template_end - sd_smc_template; void * page = mmap (NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); int r; if (page == MAP_FAILED) return -1; /* fall back to a memfd RW/RX double mapping if RWX is denied */ memcpy (page, sd_smc_template, n); __builtin___clear_cache ((char *) page, (char *) page + n); r = ((int (*) (void)) page) (); munmap (page, 4096); return r != 2; /* 1 => the pre-translated (stale) instruction ran */ } /* ------------------------------------------------------------------ */ /* D1: execute a non-executable (RW) page */ /* ------------------------------------------------------------------ */ static void * sd_nx_page; static uint64_t sd_call_nx (void) { return ((uint64_t (*) (void)) sd_nx_page) (); } int sd_check_nx_exec (void) { uint32_t * page = mmap (NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); uint64_t r = 0; int sig; page[0] = 0xd2800540; /* movz x0, #42 */ page[1] = 0xd65f03c0; /* ret */ sd_nx_page = page; sig = sd_guarded_call (sd_call_nx, &r); /* natively: SIGSEGV (SEGV_ACCERR) */ munmap (page, 4096); return sig == 0 && r == 42; } /* ------------------------------------------------------------------ */ /* E1/E2/E3: ExecCtx in /proc/self/maps and in the thread's TLS */ /* ------------------------------------------------------------------ */ static int sd_is_exec_ctx (uintptr_t base, pid_t tid) { const uint64_t * q = (const uint64_t *) base; return q[0] <= 2 && q[1] < (1ull << 62) && q[2] > 0x10000 && q[3] == (uint64_t) tid; } int sd_check_exec_ctx_maps (void) { FILE * f = fopen ("/proc/self/maps", "re"); char line[512]; pid_t tid = gettid (); int hits = 0; while (f != NULL && fgets (line, sizeof (line), f) != NULL) { unsigned long s, e, ino; char perms[5]; int name_off = 0; if (sscanf (line, "%lx-%lx %4s %*s %*s %lu %n", &s, &e, perms, &ino, &name_off) < 4) continue; if (strcmp (perms, "rwxp") != 0 || ino != 0 || line[name_off] == '[') continue; /* unnamed anonymous RWX only */ if (sd_is_exec_ctx (s, tid)) hits++; } if (f != NULL) fclose (f); return hits; } int sd_check_exec_ctx_tls (void) { pid_t tid = gettid (); unsigned i; for (i = 0; i != 256; i++) { #ifdef __BIONIC__ /* bionic encodes keys as index | (1 << 31); out-of-range keys return NULL */ uintptr_t v = (uintptr_t) pthread_getspecific ((pthread_key_t) (i | 0x80000000u)); #else uintptr_t v = (i < PTHREAD_KEYS_MAX) ? (uintptr_t) pthread_getspecific ((pthread_key_t) i) : 0; #endif if (v != 0 && (v & 0xfff) == 0 && sd_is_exec_ctx (v, tid)) return 1; /* NB: validate that v is mapped (e.g. via the maps scan) first */ } return 0; } /* ------------------------------------------------------------------ */ /* F1: kernel-level SIGSEGV handler vs. what libc/libsigchain reports */ /* ------------------------------------------------------------------ */ struct sd_kernel_sigaction { void * handler; unsigned long flags; void * restorer; uint64_t mask; }; void * sd_kernel_sigsegv_handler (unsigned long * flags) { struct sd_kernel_sigaction ks; memset (&ks, 0, sizeof (ks)); syscall (__NR_rt_sigaction, SIGSEGV, NULL, &ks, sizeof (uint64_t)); *flags = ks.flags; return ks.handler; /* expect libsigchain/ART; Frida's exceptor lives elsewhere */ } /* ------------------------------------------------------------------ */ /* F2: SIGCONT tripwire for Stalker.follow() & friends */ /* ------------------------------------------------------------------ */ static volatile int sd_sigcont_hits; static volatile uintptr_t sd_sigcont_pc; static void sd_on_sigcont (int sig, siginfo_t * si, void * ctx) { sd_sigcont_hits++; sd_sigcont_pc = ((ucontext_t *) ctx)->uc_mcontext.pc; /* the interrupted PC is Stalker's infect thunk (anonymous RWX) */ } void sd_install_sigcont_tripwire (void) { struct sigaction sa; memset (&sa, 0, sizeof (sa)); sa.sa_sigaction = sd_on_sigcont; sa.sa_flags = SA_SIGINFO | SA_RESTART; sigaction (SIGCONT, &sa, NULL); } /* ------------------------------------------------------------------ */ /* D7: BTI landing pads are not enforced inside the code cache */ /* ------------------------------------------------------------------ */ #ifndef PROT_BTI # define PROT_BTI 0x10 #endif #ifndef HWCAP2_BTI # define HWCAP2_BTI (1 << 17) #endif static void * sd_bti_page; static uint64_t sd_call_bti (void) { return ((uint64_t (*) (void)) sd_bti_page) (); } int sd_check_bti (void) { uint32_t * page; uint64_t r = 0; int sig; if ((getauxval (AT_HWCAP2) & HWCAP2_BTI) == 0) return -1; page = mmap (NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); page[0] = 0xd28000e0; /* movz x0, #7 -- not a BTI landing pad */ page[1] = 0xd65f03c0; __builtin___clear_cache ((char *) page, (char *) page + 8); if (mprotect (page, 4096, PROT_READ | PROT_EXEC | PROT_BTI) != 0) return -1; sd_bti_page = page; sig = sd_guarded_call (sd_call_bti, &r); /* natively: SIGILL (BTI exception) */ munmap (page, 4096); return sig == 0 && r == 7; } /* ------------------------------------------------------------------ */ /* D8: RETAA makes Stalker decode a PAC-signed address (no stripping) */ /* ------------------------------------------------------------------ */ __asm__ ( ".text\n" ".p2align 4\n" "sd_retaa_leaf:\n" " .inst 0xd503233f\n" /* paciasp */ " .inst 0xd65f0bff\n" /* retaa */ ".globl sd_retaa\n" ".p2align 4\n" "sd_retaa:\n" " stp x29, x30, [sp, #-16]!\n" " mov x29, sp\n" " bl sd_retaa_leaf\n" " mov x0, #42\n" " ldp x29, x30, [sp], #16\n" " ret\n"); extern uint64_t sd_retaa (void); int sd_check_retaa (void) { uint64_t r = 0; int sig; if ((getauxval (AT_HWCAP) & HWCAP_PACA) == 0) return -1; /* Native: returns 42. Under Stalker: SIGSEGV inside Capstone while Stalker * holds its code lock -- a tripwire that also leaves the tracer wedged. */ sig = sd_guarded_call (sd_retaa, &r); return !(sig == 0 && r == 42); } /* ------------------------------------------------------------------ */ /* F3: seccomp tripwire on new executable memory (arm64 numbers) */ /* ------------------------------------------------------------------ */ #ifndef AUDIT_ARCH_AARCH64 # define AUDIT_ARCH_AARCH64 0xc00000b7 #endif /* Trusted gadget: the only svc allowed to create executable memory. */ __asm__ ( ".text\n" ".p2align 4\n" ".globl sd_trusted_svc\n" "sd_trusted_svc:\n" /* (nr, a0..a5) */ " mov x8, x0\n" " mov x0, x1\n" " mov x1, x2\n" " mov x2, x3\n" " mov x3, x4\n" " mov x4, x5\n" " mov x5, x6\n" " svc #0\n" ".globl sd_trusted_svc_ret\n" "sd_trusted_svc_ret:\n" " ret\n"); extern long sd_trusted_svc (long, long, long, long, long, long, long); extern char sd_trusted_svc_ret[]; static volatile int sd_exec_map_events; static volatile uintptr_t sd_exec_map_callers[16]; static void sd_on_sigsys (int sig, siginfo_t * si, void * ctx) { mcontext_t * mc = &((ucontext_t *) ctx)->uc_mcontext; int i = sd_exec_map_events++; if (i < 16) sd_exec_map_callers[i] = mc->regs[30]; /* LR at the svc; walk the x29 chain for the real caller if needed */ /* Attribute the caller (linker64, libart JIT, your libs are expected; an * unnamed/memfd mapping is not), report it, then emulate the syscall: */ mc->regs[0] = sd_trusted_svc (si->si_syscall, mc->regs[0], mc->regs[1], mc->regs[2], mc->regs[3], mc->regs[4], mc->regs[5]); } int sd_install_exec_mapping_tripwire (void) { const uint64_t ip = (uint64_t) sd_trusted_svc_ret; struct sock_filter f[] = { BPF_STMT (BPF_LD | BPF_W | BPF_ABS, offsetof (struct seccomp_data, arch)), BPF_JUMP (BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_AARCH64, 1, 0), BPF_STMT (BPF_RET | BPF_K, SECCOMP_RET_ALLOW), BPF_STMT (BPF_LD | BPF_W | BPF_ABS, offsetof (struct seccomp_data, nr)), BPF_JUMP (BPF_JMP | BPF_JEQ | BPF_K, __NR_mmap, 3, 0), BPF_JUMP (BPF_JMP | BPF_JEQ | BPF_K, __NR_mprotect, 2, 0), BPF_JUMP (BPF_JMP | BPF_JEQ | BPF_K, __NR_pkey_mprotect, 1, 0), BPF_STMT (BPF_RET | BPF_K, SECCOMP_RET_ALLOW), BPF_STMT (BPF_LD | BPF_W | BPF_ABS, offsetof (struct seccomp_data, args[2])), BPF_JUMP (BPF_JMP | BPF_JSET | BPF_K, PROT_EXEC, 0, 5), BPF_STMT (BPF_LD | BPF_W | BPF_ABS, offsetof (struct seccomp_data, instruction_pointer)), BPF_JUMP (BPF_JMP | BPF_JEQ | BPF_K, (uint32_t) ip, 0, 2), BPF_STMT (BPF_LD | BPF_W | BPF_ABS, offsetof (struct seccomp_data, instruction_pointer) + 4), BPF_JUMP (BPF_JMP | BPF_JEQ | BPF_K, (uint32_t) (ip >> 32), 1, 0), BPF_STMT (BPF_RET | BPF_K, SECCOMP_RET_TRAP), BPF_STMT (BPF_RET | BPF_K, SECCOMP_RET_ALLOW), }; struct sock_fprog prog = { sizeof (f) / sizeof (f[0]), f }; struct sigaction sa; memset (&sa, 0, sizeof (sa)); sa.sa_sigaction = sd_on_sigsys; sa.sa_flags = SA_SIGINFO | SA_NODEFER; sigaction (SIGSYS, &sa, NULL); if (prctl (PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) return -1; return (int) syscall (__NR_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog); } ``` ### Notes for production * **Isolate the probes.** Put them in a non-inlined, `optnone`-like `.S` file, or obfuscate them. The compiler must not hoist, merge or CSE them; it CSE'd one of my own test calls. * **Don't branch on a single boolean.** Mix probe outputs into keys or values you need later, so that patching one compare is not enough. * **Order matters.** `sd_check_retaa` intentionally wedges the tracer, so run it last or only on suspicion. --- ## Appendix B: Bench results (abridged; some addresses elided) ### arm64 (Frida 17.25.0 Stalker, qemu-aarch64 11.1.2) **Spill residue (B1–B4):** ``` == svc residue == [native ] svc residue (x15@sp-144,x17@sp-136): match-bits=0x0 dirty-words=0 [stalked] svc residue (x15@sp-144,x17@sp-136): match-bits=0x3 dirty-words=2 == ret residue (first execution) == [native ] ret residue (x16,x17,x0,x1): match-bits=0x0 dirty-words=0 [stalked] ret residue (x16,x17,x0,x1): match-bits=0xf dirty-words=52 == ret residue (steady state) == [native ] ret residue x3 (steady state): 0x0 0x0 0x0 [stalked] ret residue x3 (steady state): 0xf 0xf 0xf == br residue == [native ] br residue (x16,x17): match-bits=0x0 dirty-words=0 [stalked] br residue (x16,x17): match-bits=0x3 dirty-words=52 == direct b == [native ] direct b: dirty-words=0 [stalked] direct b: dirty-words=51 ldr d0, literal: native bits=0 stalked bits=3 (3 = x0/x1 spilled at [sp-16, sp)) ``` **Real PC leaks (A1–A3):** ``` == self-signal PC (raw tgkill) == [native ] tgkill: handler pc=0x1592b88 expected=0x1592b88 -> MATCH (main image) [stalked] tgkill: handler pc=0x7ff636a2b014 expected=0x1592b88 -> MISMATCH (rwxp [anonymous]) == brk PC == [native ] brk: handler pc=0x1592b94 expected=0x1592b94 -> MATCH (main image) [stalked] brk: handler pc=0x7ff636a252ac expected=0x1592b94 -> MISMATCH (rwxp [anonymous]) == async PC sampling (SIGUSR1 from watchdog) == [native ] PC sampling: 20 samples, 20 in main image, 0 elsewhere [stalked] PC sampling: 20 samples, 0 in main image, 20 elsewhere (rwxp [anonymous]) ``` **Execution and coherency (D1, C1, C2):** ``` == execute non-executable memory == [native ] exec from RW (non-exec) page: signal 11 code 2 pc=0x7f0731abc000 [stalked] exec from RW (non-exec) page: returned 0x2a, no fault == cross-call SMC (trust=1) == [native ] cross-call SMC: before=1,1,1 after patch=2 -> fresh code [stalked] cross-call SMC: before=1,1,1 after patch=1 -> STALE translation == cross-call SMC (trust=-1) == [stalked] cross-call SMC: before=1,1,1 after patch=2 -> fresh code == intra-block SMC (trust=-1) == [native ] intra-block SMC: returned 2 -> patched insn executed [stalked] intra-block SMC: returned 1 -> STALE (pre-translated) insn executed == intra-block SMC (trust=1) == [stalked] intra-block SMC: returned 1 -> STALE (pre-translated) insn executed ``` **Child threads and processes (B7, section 10):** ``` == raw clone: child registers == [native ] clone child: result=0x103 -> x15 intact, x17 intact [stalked] clone child: result=0x101 -> x15 intact, x17 CLOBBERED stalked thread: svc residue=3 | new pthread: 0 | fork child: 0 (3 = stalked, 0 = native) ``` **Faults, exclusions and memory forensics (D2, D4, A6, E1–E6):** ``` == read-ahead past a trapping insn == [native ] signal 5 code 1 addr= pc=at brk (original) [stalked] signal 11 code 2 addr= (= guard page) pc=main image (Gum code) == return address seen by an EXCLUDED callee == [native ] callee return address: 0x14ae818 (main image) [stalked] callee return address: 0x7fc8748d52dc (rwxp [anonymous]) == callback invoked by an EXCLUDED libc qsort(): scan caller frames == [native ] pointers into anonymous executable memory found in caller frames: 0 [stalked] pointers into anonymous executable memory found in caller frames: 190 == blr to a misaligned target (page+2) == [native ] signal 7 (SIGBUS) code 1 (BUS_ADRALN) pc= [stalked] SIGTRAP from the code cache (run 1) / SIGSEGV elsewhere (run 2) == memory forensics (while following) == [stalked] -> GumExecCtx header: state=0 stalker=0x7f7e498bc8d0 thread_id=3331 [native ] scan: rwx-anon=4 ExecCtx(tid)=0 ldp=0 stp=0 brk14=0 copies-of-marker_fn=0 pointers-into-main-text=1 [stalked] scan: rwx-anon=12 ExecCtx(tid)=1 ldp=1399 stp=899 brk14=500 copies-of-marker_fn=2 pointers-into-main-text=587 ``` **Exceptor footprint and TLS (F1, E3):** ``` == after gum_stalker_new(): libc sigaction() first bytes PATCHED (inline hook) sigaction(SIGSEGV) via libc reports handler=0x14ac0e4 (app) raw rt_sigaction(SIGSEGV) says kernel handler=0x15824e8 (Gum) flags=0x4c000004 [SA_ONSTACK] [stalked] pthread key 4 -> 0x7f3895ee3000 = GumExecCtx for this thread ``` **Hardware features (D7–D10, B8):** ``` == RETAA == [native] returned 0x600d | [stalked] SIGSEGV in Capstone _getInstruction (AArch64Disassembler.c:253) == BTI == [native] SIGILL | [stalked] returned 0x7 == MTE == [native] returned 0x1 | [stalked] SIGSEGV code 9 (SEGV_MTESERR), pc outside the image (code cache) == SME (sme_fa64=off) == [native] returned 0x1 | [stalked] SIGILL in Gum C code == FPSR == [native] 0x0 | [stalked] 0x0 | [stalked+callout] 0x10 (IXC) ``` ### x86_64 (Frida 17.25.0, native Linux 7.2) **`Stalker.follow()` (F2, A4):** ``` == Stalker.follow() -- victim spinning, SIGCONT handler installed == SIGCONT handler runs=1 on tid=3518 (victim=3518) si_code=0 SIGCONT interrupted context RIP=0x7f51042fc002 -> rwxp [anonymous] (region size 0x4d000) watcher: dumpable==1 seen 98 times, TracerPid!=0 seen 92 times, task/children non-empty 103 times == victim blocked in read() == before follow: /proc/.../syscall pc in r-xp /usr/lib/libc.so.6 after follow: /proc/.../syscall pc in rwxp [anonymous] (region size 0x4d000) == victim in epoll_wait(), no SIGCONT handler == epoll_wait returned -1 errno=4 (Interrupted system call) ``` **rseq, seccomp, x86-only leaks (A5, F3, A7):** ``` == rseq critical section == [native] 1 (aborted by kernel) | [stalked] 2 (rseq_cs cleared while inside) == seccomp == after follow_me/unfollow_me: 1 PROT_EXEC mapping request trapped: prot=0x7 from __mmap == x86-only == [stalked] rcx after syscall -> LEAK | x87 FIP -> LEAK ``` **Timing (F5, C4), cycles per iteration:** ``` [native ] indirect-call loop=3-4 straight loop=1 ; fresh code 1st exec ~175-200 [stalked] indirect-call loop=10-14 straight loop=1-3; fresh code 1st exec ~5700-7900 [stalked, trust=-1] indirect-call loop ~1200, straight loop ~400 ``` --- ## Appendix C: Reproducing * **Source.** `git clone --depth 1 --branch 17.25.0 https://github.com/frida/frida-gum`. All file:line references above are relative to that tree. * **arm64 bench.** * Inputs: `frida-gum-devkit-17.25.0-linux-arm64-musl` + `zig cc -target aarch64-linux-musl -dynamic` + Alpine's `ld-musl-aarch64.so.1`, run with `qemu-aarch64 -cpu max`. Use `-cpu max,sme_fa64=off` for D10. * The devkit needs `__aarch64_swp4_sync` (a libgcc outline atomic) and LLVM `libunwind`. * Gum's libc lookup on musl needs the program interpreter path to match `/proc/self/maps`. I pointed PT_INTERP at a relative symlink. * **What qemu-user cannot show, and how it was handled:** * SP-alignment faults (D6); * `clone3` (D12); * exclusive-monitor loss (D11); * ptrace on guest threads. The ptrace path (F2), rseq (A5), seccomp (F3) and timing (F5) were therefore measured natively on x86_64, where the same `gumprocess-linux.c` code runs. The exceptor checks (F1) were done on arm64: the x86 Stalker only obtains the exceptor on Windows. * **Android-only branches** (`HAVE_ANDROID`: page "softening" D3/F4, bionic TLS keys, libsigchain interplay) are source-derived. Confirm them on a device before relying on them.