# Detecting whether Android native code is executing through Frida Stalker ## Executive answer There is no single, unforgeable “Stalker is active” flag available to an Android application. Stalker is an in-process dynamic binary translator: it makes a per-thread, instrumented copy of each basic block and executes the copy instead of the original block. Frida’s own documentation explicitly says that the original function is not executed while it is being stalked. That gives a defender several strong detection surfaces, but an attacker in the same process can hook the detector, exclude it with `Stalker.exclude()`, customize Frida, or simply replace its result. A root attacker can also falsify process-level observations. Consequently, “all possible ways” is not a finite list in the mathematical sense; this document covers every practical detection class and the important variants of each. The most reliable answer to the narrow question—“is this function executing through Stalker now?”—is to obtain an **actual kernel-visible PC while an instruction in that function is running**, then classify the mapping containing that PC: 1. Put an inline raw syscall in the protected block and inspect its syscall-origin PC with seccomp, `/proc/self/task//syscall`, ptrace, eBPF, or a privileged monitor. 2. Trigger or asynchronously deliver a signal and read the interrupted PC from the kernel-supplied `ucontext_t`. 3. Sample the thread with perf, ptrace, CoreSight ETM, or another external facility. If the semantic instruction belongs to your function but the actual PC is in a private anonymous executable mapping—especially one that also has Stalker slab structure—then that instruction is executing from a translated code cache. This is much stronger than merely detecting that Frida is loaded. For a production EDR, combine one active-PC test with a Stalker code-cache scan and supporting Frida telemetry. Do not make a block decision from a port, thread name, `TracerPid`, or timing alone. ## Scope and version assumptions This report was checked against Frida 17.25.0 (frida commit [`f74818ada177756bd95c26235cdd26ebe4f07e76`](https://github.com/frida/frida/tree/f74818ada177756bd95c26235cdd26ebe4f07e76), bundling Frida Gum commit [`5ebe3aeccab8dcd4b727378dab67fb15fa8a3cca`](https://github.com/frida/frida-gum/tree/5ebe3aeccab8dcd4b727378dab67fb15fa8a3cca)). Constants and generated instruction patterns are implementation details and must be versioned in an EDR signature pack. Frida’s current Stalker guide describes AArch64, x86-64, and IA-32 support and explains its basic-block recompilation design. The source tree also contains an ARM/Thumb backend; MIPS is a nonfunctional stub. Android production coverage should prioritize arm64-v8a, then test each ABI actually shipped. [Official Stalker guide](https://frida.re/docs/stalker/) The following evidence grades are used: | Grade | Meaning | |---|---| | **A — direct** | Shows that the sampled instruction or protected block is executing from translated memory now. | | **B — Stalker-specific** | Strongly identifies an initialized or recently active Stalker, but not necessarily this function at this instant. | | **C — Frida-correlated** | Shows Frida/Gum presence or injection activity; Stalker may never have been used. | | **D — heuristic** | Useful only as supporting telemetry because legitimate software can produce the same effect. | ## Why Stalker is observable Stalker follows a thread one basic block at a time and JIT-compiles a shadow copy; compiled blocks are cached per thread and ranges may be excluded. This is Frida’s documented design, not an inference. [Gum Stalker API source](https://github.com/frida/frida-gum/blob/17.25.0/gum/gumstalker.c#L92-L112) On current AArch64 Gum: - The followed thread has a thread-local `GumExecCtx`, and `gum_stalker_is_following_me()` tests whether that TLS value is non-null. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L1100-L1151) - Each execution context contains fast-code, slow-code, data, scratch, helper, mapping-table, and block-list state. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L115-L320) - The default trust threshold is 1. Unless the threshold is 0, the cache stores an exact snapshot of each original basic block immediately after its generated code. [`gumstalker.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/gumstalker.c#L188-L204), [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L3901-L3918) - On non-Darwin platforms Gum currently reports full RWX support, allocates Stalker contexts/code slabs as RWX, and implements those allocations with private anonymous `mmap()`. [`gummemory.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/gummemory.c#L392-L409), [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L2025-L2039), [`gummemory-posix.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-posix/gummemory-posix.c#L222-L241) - Regular AArch64 `svc` instructions are copied into the generated block. Therefore the kernel sees the syscall as originating in the Stalker slab, not the original function. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L4488-L4528) - Frida deliberately loads the *real* return address into LR on translated calls, and relocates PC-relative instructions. This is why naive LR, `__builtin_return_address()`, `ADR`, or `dladdr()` checks usually look normal. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L4599-L4645) These properties lead to the detection methods below. ## 1. Direct execution-location methods ### 1.1 Synchronous trap or fault inside the protected block — Grade A Place an architecture-specific trap instruction inline in the native function—`BRK #imm` on AArch64, `BKPT` on ARM/Thumb, or `INT3` on x86—and install an `SA_SIGINFO` handler. Record only the saved PC from `ucontext_t` in the handler; classify it after leaving signal context. - Native execution: the saved PC is at, or immediately after, the trap in the trusted ELF/JIT mapping, depending on the architecture’s trap semantics. - Stalker execution: the trap was copied into the translated block, so the saved PC is in an anonymous code slab. A deliberately invalid instruction (`UDF`/`SIGILL`), controlled execute fault (`SIGSEGV`), arithmetic fault (`SIGFPE`), or seccomp-generated `SIGSYS` is the same general technique. A normal asynchronous signal is safer if intentional traps are unacceptable. Important implementation points: - Keep the trap and validation inline. A separate helper can be excluded and run natively. - Precompute trusted address ranges. A signal handler should not parse `/proc`, allocate, lock, log, or call non-async-signal-safe code. - Preserve and restore the application’s handler/alternate stack, and account for the architecture-specific PC adjustment needed to resume. - Current AArch64 Stalker installs Gum’s exception layer, but its Stalker exception callback only consumes a narrow set of misaligned-stack helper faults; ordinary aligned traps fall through to the application’s saved handler. A custom transformer can of course remove the trap. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L3638-L3703) This test has low false-positive risk after the PC’s mapping is structurally classified, but it is invasive and must be tested on every ABI/OEM kernel. ### 1.2 Seccomp syscall-origin check — Grade A Install an additional seccomp-BPF filter on a dedicated detection thread and execute a raw sentinel syscall inline in the protected block. Filter only a syscall/argument tuple unique to the probe—for example, an otherwise harmless `ioctl` or `prctl` request carrying a random magic value—so normal application calls are unaffected. There are three variants: 1. Return `SECCOMP_RET_TRAP`; the `SIGSYS` handler reads `siginfo_t.si_call_addr`, which the kernel defines as the address of the syscall instruction. 2. Return `SECCOMP_RET_USER_NOTIF`; the listener examines `seccomp_notif.data.instruction_pointer` and then replies. 3. Have classic BPF compare `seccomp_data.instruction_pointer` against the expected original range and trap/reject a sentinel call originating elsewhere. Linux exposes the actual syscall instruction pointer in `struct seccomp_data`, and Android’s UAPI includes the same field and user-notification interface. [Android common-kernel `seccomp.h`](https://android.googlesource.com/kernel/common/+/android-mainline/include/uapi/linux/seccomp.h), [kernel seccomp documentation](https://www.kernel.org/doc/html/latest/userspace-api/seccomp_filter.html) Why this is particularly effective against Stalker: current AArch64 Stalker emits the regular `svc` into its code slab. Thus `si_call_addr`/`instruction_pointer` is in that slab even though the syscall is semantically part of your original function. Constraints and cautions: - Seccomp filters are cumulative and cannot be removed. Scope the probe narrowly, preferably to a dedicated thread, and feature-test the kernel first. - Validate `arch` as well as the syscall number in BPF. - Existing Android app filters still apply; the most restrictive action wins. An OEM may disable user notification even when ordinary filters work. - If resuming after `SECCOMP_RET_TRAP`, set the syscall return register to a defined probe result. - A same-process attacker can hook filter installation, exclude the sentinel block, or transform the syscall. Treat installation failure or an impossible result as tamper evidence, not automatically as “clean.” ### 1.3 Coordinated blocking syscall plus `/proc/.../syscall` — Grade A/B Use an inline raw `futex`, `ppoll`, `read`, or similar syscall to park the suspected thread while a monitor thread reads: ```text /proc/self/task//syscall ``` The final two values are the saved stack pointer and program counter. Linux documents this explicitly. [proc_pid_syscall(5)](https://man7.org/linux/man-pages/man5/proc_pid_syscall.5.html) Map that PC against a prebuilt VMA inventory: - expected ELF/JIT range: native at that sample; - anonymous executable Stalker-like range: translated; - unknown/unreadable: indeterminate. Use an inline syscall rather than a libc wrapper because Frida scripts commonly exclude system libraries. Keep the syscall blocked long enough to avoid the file returning `running`. Access is kernel- and ptrace-policy-dependent; a same-process read normally has the best chance, while an OEM/root collector is stronger. Raw `openat`/`read` reduces ordinary libc-hook exposure but does not defeat a fully compromised process. ### 1.4 Asynchronous per-thread PC sampling — Grade A when it hits the block Have another thread repeatedly send an application-owned signal to the target TID (`tgkill`, a POSIX timer with thread targeting, or an equivalent facility). The handler records the interrupted PC from `ucontext_t`. This is non-destructive and can sample a long-running protected loop, but it is statistical: - Samples may land in excluded libc, ART, a signal trampoline, or unrelated code. - A short function may finish before any signal arrives. - Frida or another library may virtualize signal registration. Increase confidence by placing a bounded, call-free canary loop inline and sampling it several times. One PC in a structurally verified Stalker slab is strong evidence; no hits do not prove absence. ### 1.5 External ptrace/register sampling or crash context — Grade A A trusted companion with the necessary Android privilege can stop the TID and use `PTRACE_GETREGSET`/`NT_PRSTATUS` to read its real PC. The same principle applies to a trusted tombstoned/crash-dump pipeline that preserves raw register state. Advantages: - It is outside the instrumented address space and harder to hook. - It can inspect VMAs, registers, and stack together. Limitations: - Linux permits only one ptracer, Android SELinux and dumpability rules are restrictive, and ordinary third-party apps cannot inspect arbitrary app processes. - Stalker itself does not require a persistent ptracer after setup, so `TracerPid == 0` is expected and is not a negative result. ### 1.6 perf/Simpleperf, eBPF, audit, or kernel tracepoint sampling — Grade A/B Collect user instruction pointers or the userspace PC at syscall entry, then flag samples that fall in untrusted anonymous executable mappings. Options include: - `perf_event_open`/Simpleperf IP samples and raw callchains; - an eBPF program or OEM tracepoint reading userspace registers at syscall entry; - a kernel/LSM/audit extension that records syscall-origin IPs; - a privileged in-kernel VMA classifier that correlates PC with mapping provenance. Android documents Simpleperf as its native app/process profiler, but release-app profiling normally requires root, `debuggable`, or `profileableFromShell`, depending on Android version. [AOSP application profiling](https://android.googlesource.com/platform/system/extras/+/refs/heads/main/simpleperf/doc/android_application_profiling.md) Sampling is statistical; a deliberately invoked sentinel syscall gives deterministic coverage. Resolve PCs using the map snapshot from the same time window because VMAs are racy. ### 1.7 Hardware branch trace — Grade A On devices where an OEM/debug build exposes Arm CoreSight ETM, trace the target TID and decode the actual instruction addresses. A stalked call flows through anonymous slab addresses instead of the original function. Intel PT provides the analogous option on rare x86 Android targets. Linux perf can capture and decode CoreSight address trace when the hardware and kernel configuration support it. [Linux CoreSight-perf documentation](https://www.kernel.org/doc/html/latest/trace/coresight/coresight-perf.html) This is one of the hardest observations for user-space Frida to forge, but production phones usually lock down CoreSight and perf AUX access. ### 1.8 Hardware execute breakpoint on the original block — Grade A with a paired side effect Install an execute breakpoint on an instruction in the original protected function and separately prove that the function’s semantic side effect occurred. - Native execution fetches the original address and hits the breakpoint. - Stalker reads the original bytes as data, then executes a copy elsewhere, so the original execute breakpoint does not fire. Use more than one instruction and a fresh/randomized block if possible. Hardware breakpoint slots are scarce, perf/ptrace access is restricted, and a hostile agent can inspect or consume debug registers. A missing breakpoint alone is never proof—the function may simply not have executed. ## 2. Detecting translation and the code cache ### 2.1 Anonymous executable/RWX VMA inventory — Grade B, or A when matched to a sampled PC Parse `/proc/self/maps` and preferably `/proc/self/smaps` using a raw, bounded reader, or obtain the map list from a trusted external collector. `/proc//maps` reports each mapped range and its permissions. [proc_pid_maps(5)](https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html) Flag—not automatically convict—private executable mappings that are: - anonymous or backed by a deleted/memfd object with no approved provenance; - writable and executable (`rwxp`); - absent from a clean startup baseline; - populated or dirtied while the suspect thread first executes new code; - referenced by a sampled PC; - structurally consistent with Stalker slabs. Legitimate Android sources of anonymous executable memory include ART JIT/zygote JIT caches, WebView/Chromium/V8, games, emulators, packers, and application-owned JITs. Build an allowlist from mapping name, owner, creation event, content, and expected component—not merely pathname text, which an attacker can spoof. #### Current AArch64/x86 slab-size hints The audited source uses 128 KiB initial fast and slow slabs, 4 MiB dynamic fast and slow slabs, a data slab one-fifth the code size, and a 16 KiB scratch slab. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L44-L52) For page size `P`, the initial AArch64/x86 execution-context mapping is approximately: ```text round_up(sizeof(GumExecCtx), P) + P # helper thunks + 128 KiB # fast code + 128 KiB # slow code + round_up(128 KiB / 5, P) # data + round_up(16 KiB, P) # scratch ``` That is typically about **308 KiB on 4 KiB pages** or **336 KiB on 16 KiB pages**, assuming the context header fits one page. When full, current AArch64 allocates a separate **8 MiB RWX** fast+slow mapping and an approximately **820 KiB (4 KiB pages) or 832 KiB (16 KiB pages) RW** data mapping. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L5646-L5726) These are hints only. VMAs can merge, page sizes vary, older releases differ, and a custom build can change one constant. ### 2.2 Scan for exact snapshots of your basic blocks — Grade B/A At the default trust threshold, Stalker places an exact byte snapshot of each original basic block after the translated bytes. Offline-disassemble your protected function into basic blocks, select multiple unique 16–64 byte windows, and search **other** anonymous executable mappings for them. A useful match requires all of the following: - the original address and candidate address are different; - the candidate mapping is not an approved JIT or deliberate application copy; - several windows from distinct blocks match, or one match contains a per-run nonce; - preferably, a sampled PC also lands in the same mapping. False negatives occur when the attacker sets `Stalker.trustThreshold = 0`, rewrites the transformer, encrypts/hides pages, excludes the function, or alters Gum. Even with snapshots disabled, many ordinary non-PC-relative instructions remain byte-identical in the generated block. ### 2.3 Runtime-generated nonce block and duplicate search — Grade A/B This is a stronger form of snapshot scanning that avoids static signatures: 1. Generate a small RX function at a fresh random address. 2. Encode a cryptographic per-run nonce across several semantically used, non-PC-relative instructions. 3. Execute it on the suspected thread. 4. Scan all executable mappings except the original page for the nonce instruction sequence. Stock Stalker must decode the fresh block and normally produces a second representation in its code slab; with the default trust setting, its exact original-byte snapshot provides a guaranteed copy. A long random sequence makes accidental collision negligible. Design the block so an optimizer/transformer cannot remove the nonce without changing its returned result. Rotate its layout and address each run. This remains bypassable by explicit exclusion or a detector-aware transformer, but it is an excellent regular-app technique when combined with VMA structure. ### 2.4 Generated-code opcode and literal signatures — Grade B Versioned signatures can identify Stalker’s generated helpers, fast/slow transfer gates, prolog/epilog sequences, invalidation trampolines, inline-cache loops, literal pools, and backpatch padding. A robust scanner should look for relationships rather than one fixed byte string: - repeated save/restore and indirect-branch templates; - absolute literals pointing into the Frida/Gum module, the original `.text`, or a companion data slab; - many translated blocks separated by exact source snapshots; - fast-code branches into the adjacent slow half of the same allocation; - helper entrypoints repeated once per slab. Signatures must be keyed by Frida version, architecture, CPU features (PAC/BTI), event configuration, transformer, and trust threshold. Exact hashes are brittle and easy to patch. ### 2.5 Parse `GumSlab`, `GumExecBlock`, and pointer topology — Grade B/A For known builds, parse candidate memory as current Gum structures and validate invariants: - slab `data`, `offset`, `size`, `memory_size`, and linked-list pointers stay inside plausible VMAs; - each `GumExecBlock.real_start` falls in executable app/system code; - `code_start` and `slow_start` fall in the candidate fast/slow slabs; - `real_size`, `code_size`, and snapshot position are internally consistent; - the snapshot equals bytes at `real_start`; - inline-cache entries form real-address → code-cache-address pairs; - the context’s `thread_id` names a live TID. Multiple mutually consistent pointers have far lower false-positive probability than VMA size or an opcode signature. This parser is highly version-specific and must never dereference unchecked hostile pointers in the EDR process; copy memory into a bounded parser or analyze it externally. ### 2.6 Inspect thread-local Stalker state — Grade A/B The current backend stores the followed thread’s `GumExecCtx *` in a GLib `GPrivate`. Possible approaches are: - If the *same loaded Gum instance* exports its native API and you have a valid `GumStalker` object, call `gum_stalker_is_following_me()` on the suspected thread. - Resolve the exact loaded implementation, locate its `GPrivate` key, and inspect the target thread’s TLS slot. - From a memory dump, find TLS values pointing to a structurally valid `GumExecCtx` and validate its `thread_id`, slabs, mapping table, and block list. - Enumerate `GumStalker` objects/contexts through symbols in a cooperative or laboratory build. Linking your EDR to a separate copy of libgum does **not** work: it has a different TLS key and state. Injected Frida agents are commonly stripped or statically linked, and calling an attacker-controlled function is itself unsafe. Treat this as a high-confidence laboratory/cooperative method, not a universal production API. ### 2.7 Observe code-cache allocation and mutation — Grade B Monitor `mmap`, `mprotect`, `munmap`, executable-page writes, and instruction-cache maintenance from outside the compromised process where possible. Correlate: - creation of anonymous RWX ranges with the current slab-size families; - writes or soft-dirty changes in executable anonymous pages as new basic blocks run; - growth from the initial per-thread context to dynamic code/data slabs; - caller PCs in Gum/Frida when the allocation occurs; - unmapping shortly after `unfollow()`/garbage collection. Collection options include OEM kernel hooks, eBPF/tracepoints, an LSM, a hypervisor, or an early in-process monitor. Libc-only hooks are weak because Frida can bypass or replace them. A custom Stalker can use W^X dual mappings or different allocators, so monitor executable provenance and write history rather than RWX alone. ### 2.8 Per-thread footprint and lifetime correlation — Grade D/B Each followed thread gets its own execution context/cache. Look for stepwise VMA/RSS/private-dirty growth as additional TIDs are followed, and a short-lived residual mapping after unfollow while Stalker waits to reclaim safely. The current AArch64 collector uses a 20 ms “likely back in original code” delay in one cleanup path. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L1060-L1097) Memory size alone is weak; correlate it with cache content or actual PCs. ## 3. Active semantic probes against dynamic translation These techniques distinguish “execute original bytes” from “execute a cached copy.” They are powerful but more invasive than observing a PC. ### 3.1 Hardware data-read watchpoint on fresh code — Grade A/B Instruction fetch does not normally trigger a data-read watchpoint, but Stalker must read the original bytes with its decoder/relocator before translating them. Set a hardware **read** watchpoint on a newly generated, never-before-executed block, then invoke it: - native execution: instruction fetch only, no data-read hit; - Stalker: decoder reads the bytes and trips the watchpoint. An external ptracer/hypervisor is preferable. Legitimate integrity scanners or another JIT compiler can also read code, so inspect the faulting reader PC and require it to be in Gum/Frida or an untrusted translator. Hardware slots and read-watch granularity vary by SoC. ### 3.2 Execute-only source page — Grade A/B, device-dependent On hardware/kernel combinations that provide true execute-only user pages, put a fresh probe block in X-only memory. Native instruction fetch succeeds; a translator’s data read faults. This directly exposes any DBI, not only Stalker. Many Android devices silently make executable pages readable, disallow application-created X-only mappings, or implement XOM differently. Feature-test it and never infer “clean” when XOM is unavailable. ### 3.3 Self-modifying code / stale-cache challenge — Grade A/B Use a dedicated generated page, never your production `.text`: 1. Execute version A enough times to become cached/trusted. 2. Change an immediate/branch to version B, perform the required D-cache/I-cache synchronization, and execute again. 3. Compare the architectural result. At the current default trust threshold of 1, a block eventually stops comparing its source snapshot and may execute stale translated code. Threshold `-1` rechecks forever, while `0` trusts immediately; a custom transformer may also cope correctly. Therefore, this is a useful challenge, not a universal invariant. A harder variant changes a later instruction during the same basic-block execution; because Stalker translated the whole block before entering it, the current translated copy cannot see the mid-block change. Correct native self-modifying code is architecture-specific and easy to get wrong, so reserve it for a test suite. ### 3.4 Remove execute permission, unmap/remap, or replace source after caching — Grade A/B After warming a dedicated translated block: - remove execute permission from its original page; - set it temporarily to `PROT_NONE`; - unmap and map different code at the same virtual address; or - replace the original block with another implementation. Native execution faults or observes the replacement. A trusted cached translation may continue from its slab or may return stale semantics. Stalker configured to revalidate can instead fault while reading the now-inaccessible source, which is still anomalous if the native probe was designed to execute legally. Invoke the probe from a dispatcher that is itself known to remain in translated execution. A native/excluded caller will branch to the now-non-executable original address and fault before this distinction can be observed. These operations are crash-prone and race-prone. Use isolated pages, a recovery handler, and a lab-tested state machine. Never change a page containing live unrelated code. ### 3.5 Post-cache software breakpoint or hotpatch — Grade A/B Warm the block, then patch an original instruction to a trap or a branch that increments a trusted counter. If the function’s other side effect occurs but the new breakpoint/counter does not, execution used a stale copy. This is the software analogue of an execute breakpoint and shares the trust-threshold limitations. An ordinary one-time integrity hash of the original function is **not** this test: Stalker normally leaves original bytes untouched. ### 3.6 Differential conformance probes — Grade D/B Every DBI has semantic edge cases. A randomized differential harness can compare native and suspected execution for: - unusual direct/indirect call and return patterns, tail calls, nonstandard LR use, `setjmp`/`longjmp`; - nested/alternate-stack signals and synchronous exceptions; - C++ unwinding and forced unwind; - exclusive-load/store sequences and atomics crossing block boundaries; - `clone`, `fork`, thread exit, and races during invalidation; - PC-relative literals and branches at page/range boundaries; - self-modifying, execute-only, or aliased code; - PAC, BTI, MTE, SVE/SME, and newly introduced instructions; - deliberately misaligned SP and non-ABI assembly. Frida fixes such discrepancies over time. Current AArch64 source explicitly discusses and mitigates the historical misaligned-SP anti-Stalker technique, so relying on that single trick is fragile. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L3638-L3660) Treat any difference as a version fingerprint after ruling out CPU/kernel behavior; do not intentionally crash a production process. ## 4. Runtime-control and initialization artifacts ### 4.1 Observe Stalker API calls — Grade B/A If the EDR loads before the agent and has a stronger trust position, watch: - `gum_stalker_new`, `gum_stalker_follow`, `gum_stalker_follow_me`; - `gum_stalker_activate`/`deactivate`, `exclude`, `invalidate`, and `unfollow`; - GumJS bindings that dispatch `Stalker.follow()`; - associated code/data-slab allocation routines. Possible mechanisms include loader notifications, uprobes at known build offsets, hardware breakpoints, an OEM audit hook, or a cooperative interposer. A symbol-name hook alone fails against stripped, static, renamed, or modified builds. Observing `follow(thread_id)` with the protected TID is direct intent evidence; a later `exclude()` or `unfollow()` means it is no longer proof of current execution. ### 4.2 Cross-thread infection transient — Grade B/C `Stalker.follow(otherTid)` must modify another thread’s register state. Current Linux/Android Gum: 1. creates a UNIX `socketpair`; 2. allocates one-page stack and TLS mappings; 3. uses `clone(CLONE_VM | CLONE_SETTLS)` to create a helper outside the original thread group; 4. temporarily changes dumpability/`PR_SET_PTRACER`; 5. `PTRACE_ATTACH`es, stops, reads/writes registers, and detaches the target. This sequence is visible to a kernel/OEM monitor and is highly characteristic when correlated tightly. [`gumprocess-linux.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-linux/gumprocess-linux.c#L630-L750), [`gumprocess-linux.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-linux/gumprocess-linux.c#L874-L905) It does **not** occur for the normal `follow_me()` path, and it is transient. `TracerPid`, a momentary stop state, `PR_GET_DUMPABLE`, an odd clone child, or the socket pair are therefore supporting event telemetry, not persistent detectors. ### 4.3 Gum exception-handler ownership and `sigaction` virtualization — Grade B/C on AArch64 Current AArch64 Stalker obtains Gum’s exception manager during initialization. On POSIX, Gum installs kernel handlers for `SIGABRT`, `SIGSEGV`, `SIGBUS`, `SIGILL`, `SIGFPE`, `SIGTRAP`, and `SIGSYS`, then replaces libc `signal()` and `sigaction()` so applications see virtualized prior handlers. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L815-L874), [`gumexceptor-posix.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-posix/gumexceptor-posix.c#L250-L301) Detection options: - query a signal disposition through libc and through a carefully implemented raw `rt_sigaction` query, then compare them; - classify the kernel-visible handler address; - integrity-check libc’s `signal`/`sigaction` entrypoints and their resolved targets; - look for one common foreign `SA_SIGINFO | SA_NODEFER | SA_ONSTACK` handler across the signal set. Do not overwrite the raw kernel action merely to test it; that can break Gum or another crash handler. Crash reporters, sanitizers, ART, and games legitimately own signals, so this is supporting evidence. The exact footprint differs by architecture and other Gum features also use the exception manager. ### 4.4 Unwind and stack discrepancies — Grade D/B Sample the raw register/stack state externally and compare it with an in-process `backtrace()`/libunwind result. Possible artifacts include anonymous code-cache PCs, transient return addresses into slabs/helpers, added Stalker frames, or disagreement between raw and symbolized callchains. Stalker deliberately registers an unwind-PC translator and adjusts resume contexts, so ordinary unwinding may look completely normal. [`gumstalker-arm64.c`](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c#L768-L874) Use raw-vs-normalized disagreement as support; never rely on a normal backtrace to clear the process. ## 5. Performance and microarchitectural fingerprints ### 5.1 First-hit translation latency — Grade D Execute many freshly generated, semantically identical blocks at random addresses and compare first invocation with later invocations. Stalker’s first hit must decode, transform, emit, flush the I-cache, and update maps; later hits use cached/backpatched code. Look for a repeatable first-hit step beyond normal page-fault/I-cache cost. Measure with several independent clocks where practical: - `CNTVCT_EL0`/architectural counter; - thread CPU time; - a monitor thread or external kernel timestamp; - perf task-clock/cycles. DVFS, thermal throttling, scheduler noise, ART compilation, page faults, and attacker clock hooks create large variance. Use per-device/build baselines and never classify on timing alone. ### 5.2 Retired instructions, branches, and cache events — Grade D/B For a fixed native canary, Stalker executes extra prolog/epilog, dispatch, inline-cache, event, and backpatch instructions. PMU/perf counters may reveal: - substantially more retired instructions and branches; - additional indirect branches/branch misses; - I-cache and iTLB footprint in anonymous pages; - D-cache writes and I-cache maintenance during compilation; - event-dependent overhead when call/ret/exec/block tracing is enabled. Hardware access is restricted on production Android and events vary by CPU. A detector-aware transformer can exclude the benchmark. These counters become much stronger when samples also include IPs in the code slab. ### 5.3 CPU, RSS, and event-delivery overhead — Grade D Observe thread CPU time, private dirty pages, event-drain activity, scheduling, and latency perturbations. Per-instruction Stalker events can be extremely expensive; a default transformer with no events is far quieter. This detects “something expensive” rather than Stalker and should only contribute a small score. ## 6. Generic Frida/Gum evidence (not proof of active Stalker) These checks are still valuable for attribution and for deciding when to run the invasive probes. Frida officially supports injected, embedded/Gadget, and preloaded modes; Gadget can be renamed arbitrarily and can run autonomous scripts without a network listener. [Frida modes](https://frida.re/docs/modes/), [Frida Gadget](https://frida.re/docs/gadget/) ### 6.1 Loaded module and ELF provenance — Grade C - Search `/proc/self/maps`, `/proc/self/map_files`, linker APIs, and linker namespaces for `frida-agent`, `frida-gadget`, `libfrida-gum`, deleted agents, unexpected APK native libraries, or executable memfds. - Reconcile all executable mappings against `dl_iterate_phdr`/linker data; scan manually mapped regions for ELF headers and load segments. - Validate APK/native-library hashes and signing provenance to catch an embedded Gadget. - Compare independent inventories. A module present in maps but hidden from linker enumeration, or vice versa, is suspicious. Names are trivial to change—Frida’s own Gadget documentation calls that out—so provenance and content matter more than substrings. ### 6.2 Memory and symbol fingerprints — Grade C Scan readable regions or offline dumps for versioned fingerprints of Gum, GumJS, QuickJS/V8, Capstone, GLib/GIO, Frida RPC/protocol strings, bundled Java bridge code, Stalker symbol names, generated helper templates, or attacker script text. Search exported and non-exported ELF symbol tables where present. Static dependencies can occur legitimately, strings can be stripped/encoded, and arbitrary scripts need not contain the word “Frida.” Require multiple fingerprints or structural code matches. ### 6.3 Thread inventory — Grade C Inspect `/proc/self/task/*/comm`, status, stacks where allowed, and start routines for characteristic worker threads such as `gum-js-loop`, GLib/GIO loops, or Frida connection workers. The current GumJS scheduler creates a thread named `gum-js-loop`. [`gumscriptscheduler.c`](https://github.com/frida/frida-gum/blob/17.25.0/bindings/gumjs/gumscriptscheduler.c#L107-L123) Thread names can be changed and a native libgum user may have no GumJS thread. Names also collide with legitimate GLib users. ### 6.4 Sockets, ports, file descriptors, and protocol — Grade C - Check TCP listeners/connections, especially the default localhost port 27042, but also non-default ports. - Identify UNIX sockets, pipes, eventfds, memfds, deleted agent files, and connection endpoints that appear with injection. - Actively validate a suspected endpoint with the expected Frida protocol only from an authorized EDR component; a port-number match alone is weak. Gadget can connect outward, use another address/port, or run a script from disk with no communication, so absence of a listener proves nothing. ### 6.5 Processes, files, services, and package artifacts — Grade C From an appropriately privileged EDR component, look for: - `frida-server`, renamed binaries with matching hashes/code, injectors, or helper processes; - common deployment paths such as `/data/local/tmp`, Magisk modules, init services, and startup scripts; - Gadget libraries/configuration embedded in the APK; - SELinux/audit events involving injection-relevant ptrace or executable memory. Paths and process names are trivial to alter. A file on disk also does not prove this process is being stalked. ### 6.6 Injection and hooking artifacts — Grade C - Monitor ptrace, `process_vm_writev`, `/proc//mem`, remote thread creation, linker loads, executable mappings, and code-page writes from a kernel/OEM vantage point. - Verify GOT/PLT entries and inline prologues of security-sensitive libc/linker functions, including `open/read`, `mmap/mprotect`, `ptrace`, `prctl`, `sigaction`, `dl_iterate_phdr`, and property APIs. - Compare API results with raw syscalls or a trusted external view. Frida may be injected before the EDR starts, Gadget needs no injection, and Stalker itself does not patch the protected function. These signals are provenance/tamper evidence, not direct Stalker evidence. ### 6.7 Device integrity, root, and repackaging evidence — Grade C/D Use hardware-backed key/boot attestation where available, Play Integrity as one input, verified package signatures, APK/native-library manifests, SELinux enforcing state, bootloader state, and root/Magisk indicators to estimate whether injection or repackaging is feasible. Frida’s Android documentation notes both the common rooted-device/server workflow and the non-root Gadget/repackaging alternative. [Frida on Android](https://frida.re/docs/android/) A compromised or repackaged device raises prior probability; it neither proves Frida nor answers whether this function is stalked. Conversely, a passing integrity verdict does not rule out a deliberately embedded Gadget, an application vulnerability, or an OEM/debug environment. ## 7. Checks that do **not** reliably detect Stalker Avoid these common mistakes: | Check | Why it is insufficient | |---|---| | Hash only the protected function’s original bytes | Stalker normally leaves them pristine and executes a copy. | | Read PC with `ADR`, take LR, use `__builtin_return_address()`, or call `dladdr()` | Stalker relocates PC-relative operations and deliberately preserves real return addresses. | | Take an ordinary in-process backtrace | Stalker contains unwind translation specifically to normalize it. | | Check only `TracerPid` | A persistent ptracer is not required. Cross-thread setup uses ptrace only transiently; `follow_me()` avoids it. | | Scan only for “frida” in maps | Agent/Gadget may be renamed, stripped, manually mapped, or embedded. | | Scan only port 27042 | The endpoint is configurable and may not exist at all. | | Look only for `gum-js-loop` | Native Gum use has no JS loop and thread names are mutable. | | Treat every anonymous RX/RWX mapping as malicious | ART, WebView, games, and legitimate JITs create them. | | Use timing alone | Device and workload variance is too large, and detector code can be excluded. | | Assume `sigaction()` reports the kernel’s real action | Gum can virtualize the libc call while retaining its own kernel handler. | | Call a separately linked `gum_stalker_is_following_me()` | It observes that libgum instance’s TLS, not the attacker’s. | | Detect Frida and claim the function is stalked | Frida may be present for Interceptor, Java APIs, memory access, or an idle script only. | ## 8. Recommended production design ### Regular app / in-process EDR Use a layered decision: 1. **Build a trusted executable-map inventory.** Record APK/system ELF ranges, known ART/WebView JIT mappings, and application-owned JIT pages. Read maps with raw syscalls and validate them against build IDs/hashes where feasible. 2. **Embed an inline syscall-origin canary** in each high-value function or at several randomized call sites. Prefer the coordinated `/proc/.../syscall` method for low invasiveness; use a narrowly scoped seccomp trap where compatibility testing permits. 3. **Add asynchronous PC sampling** for long-running code so the detector does not rely on one mechanism. 4. **Scan candidate anonymous executable mappings** for per-run nonce blocks, original block snapshots, and Stalker structure/pointer invariants. 5. **Collect supporting Gum/Frida evidence**: raw-vs-libc signal dispositions, module/ELF provenance, hook integrity, threads, FDs, and sockets. 6. **Send raw evidence, not just a Boolean**, to the backend: TID, sampled PC, mapping tuple, map hash, matching snapshot/nonces, and which independent channels agreed. Suggested verdict logic: ```text CONFIRMED_ACTIVE: kernel-visible PC for an inline protected instruction is outside its original trusted range and inside a validated Stalker-like code slab HIGH_CONFIDENCE_STALKER: structurally valid GumExecCtx/GumExecBlock graph OR multiple exact block snapshots in a matching slab OR observed gum_stalker_follow(target_tid) FRIDA_PRESENT_ONLY: agent/Gadget/protocol/runtime evidence without active-PC evidence SUSPICIOUS: anonymous executable memory, timing, PMU, or hook anomaly only INDETERMINATE: detector unavailable, tampered, excluded, raced, or permission denied ``` Never convert “probe unavailable” into “not stalked.” ### OEM, system, root, or kernel-integrated EDR Move trust out of the target process: - record executable VMA creation and modification in the kernel; - sample real PCs with perf/eBPF/ptrace and resolve them against kernel VMA state; - inspect syscall-origin PCs for selected high-value syscalls; - audit the cross-thread `clone` + `PR_SET_PTRACER` + ptrace sequence; - use ETM when hardware policy permits; - collect memory externally and run the versioned slab/metadata parser in an isolated service. This is substantially more robust than any in-process-only anti-instrumentation scheme. Standard Device Owner privileges alone do not grant arbitrary cross-process memory/register inspection; OEM SELinux and kernel support are normally required. ## 9. Evasion and resilience considerations An attacker can evade any single method by: - calling `Stalker.exclude()` on the detector or temporarily deactivating/unfollowing; - installing a transformer that removes traps, redirects sentinel syscalls, or emulates expected results; - setting trust threshold 0 or -1 to change snapshot/self-modifying behavior; - changing slab sizes, permissions, allocator, signatures, thread names, ports, and module names; - hooking `/proc` reads, signal APIs, clocks, map enumeration, or the detector’s report path; - executing only selected blocks natively; - replacing Frida Stalker with another DBI; - compromising the kernel on a rooted device. Improve resilience by: - inlining and scattering several canaries instead of calling one obvious detector function; - generating per-run code/nonces and selecting probe sites after startup; - using two independent kernel-mediated channels, such as seccomp PC plus signal PC; - cross-checking in-process results with a privileged external collector; - authenticating telemetry and reporting contradictions/denials; - versioning signatures while retaining behavior-based checks; - treating a native result for one block only as “this sampled block was native,” not “the thread never uses Stalker.” There is no perfect answer when attacker code and the detector share the same privilege and address space. The defensible goal is high-confidence, multi-source evidence with explicit `indeterminate` handling—not an allegedly unbypassable anti-Frida trick. ## Primary references - [Frida Stalker internals and behavior](https://frida.re/docs/stalker/) - [Frida Gum Stalker public implementation/documentation](https://github.com/frida/frida-gum/blob/17.25.0/gum/gumstalker.c) - [Frida Gum AArch64 Stalker implementation](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-arm64/gumstalker-arm64.c) - [Frida POSIX exception backend](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-posix/gumexceptor-posix.c) - [Frida Linux thread-modification implementation](https://github.com/frida/frida-gum/blob/17.25.0/gum/backend-linux/gumprocess-linux.c) - [Linux seccomp-filter documentation](https://www.kernel.org/doc/html/latest/userspace-api/seccomp_filter.html) - [Android common-kernel seccomp UAPI](https://android.googlesource.com/kernel/common/+/android-mainline/include/uapi/linux/seccomp.h) - [`/proc//syscall` documentation](https://man7.org/linux/man-pages/man5/proc_pid_syscall.5.html) - [`/proc//maps` documentation](https://man7.org/linux/man-pages/man5/proc_pid_maps.5.html) - [AOSP Simpleperf application profiling](https://android.googlesource.com/platform/system/extras/+/refs/heads/main/simpleperf/doc/android_application_profiling.md)