Introduction
Techniques to detect that a function is hooked by Frida (or by any other hook engine) are well documented, and asking an AI about them provides meaningful and relevant answers. Sometimes, a hook is not enough to understand the purpose of a function and we need to take a closer look at what it executes. This is where Frida’s Stalker comes into play: it traces the code at the instruction level by running an instrumented copy of it, while the original instructions stay untouched.
Detecting the Stalker is more challenging, and a well-designed DBI resists most detections. This blog post shows a detection that a function can run on itself to find out whether it is being stalked by Frida.
The Detection
Any DBI needs scratch registers for its rewriting rules.
QBDI picks a register that the instrumented code does not use.
For instance, the rule that rewrites an arm64 RET
instruction is:
# RET rewriting by QBDI
temp := LR
DataBlock[PC] := temp
which could be lowered into:
mov x11, lr
str x11, [x27, #600]
Here, QBDI pays the cost of finding a register that the instruction does not use
(x11) to store the temp variable, and it saves this register in its DataBlock.
QBDI could have used another strategy: saving and restoring registers on the stack. Thanks to the early designers and authors of QBDI (Charles Hubain and Cédric Tessier), using the stack for the needs of the DBI was not an option. This choice keeps QBDI stealthy under strong side-effect and integrity checks.
On the other hand, Frida’s Stalker uses the stack for the same use case as the
temp allocation in the previous snippet. Instead of finding a free register
within the scope of the instrumented code, it uses predefined registers (e.g. x16
and x17) that are saved to and restored from the stack.
Assuming that the non-instrumented code uses the following stack layout:

When the Stalker is running, every rewritten jump or call (e.g. br, blr) goes
to a stub that saves some predefined registers. The stack looks like this:

The Stalker spills x16 and x17 (x15 and x17 for a syscall) right below
the red zone. When it switches to its own code, for instance to compile a new
block, it also saves some or all registers as a GumCpuContext.
The important point is that when Frida restores these registers,
it does not erase their copies.
On Android, compiled code never writes below the stack pointer, so these slots only change under the Stalker. A function can therefore detect the Stalker in three steps:
- write a canary into
[sp - 0x90]and[sp - 0x88] - execute an instruction that triggers Stalker rewrites (like an indirect branch)
- compare the slots with the backup registers
| Rewritten instruction | [sp - 0x90] | [sp - 0x88] |
|---|---|---|
br, blr, ret | x16 | x17 |
svc | x15 | x17 |
The code of this detection is available in this GitHub repo: romainthomas/frida-stalker-detection
</last word>
If you want to go further with the Stalker detection, you can check the SingPass RASP Analysis blog post, which exposes another detection that I found while reverse engineering iXGuard.
Note that the detection explained in this blog post is rooted in some work done on DynaMIR1.
Last Minute Update
I thought about this detection a while ago (i.e. at the beginning of this year). In July, while I was playing with AI, I asked Opus 5 how it would detect the Stalker, and my detection was not part of its answer 😏.
Before publishing this blog post2, and for the sake of completeness and intellectual integrity, I asked Opus 5.5 the same question. The first two detections of its answer are literally the stack-based detection that I describe.
This is just the reality of the pace at which AI models progress.
You can check the answers from OpenAI Daybreak3 and Opus 5.5 here:
\Romain
