• Android
  • Obfuscation

TECHNICAL ANALYSIS

Detecting Frida Stalker

Detecting Frida Stalker

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:

Native stack layout: the caller frame, the current frame between fp and sp with local variables and data, the 128-byte red zone from sp down to sp - 0x80, and unallocated memory below

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:

Stack layout under the Stalker: the same frames and 128-byte red zone, then, right below sp - 0x80, the saved x16 and x17 followed by a GumCpuContext with the general-purpose, vector, and flag registers, left behind as Stalker artifacts

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:

  1. write a canary into [sp - 0x90] and [sp - 0x88]
  2. execute an instruction that triggers Stalker rewrites (like an indirect branch)
  3. compare the slots with the backup registers
Rewritten instruction[sp - 0x90][sp - 0x88]
br, blr, retx16x17
svcx15x17

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


  1. More details to come soon™ ↩︎

  2. My personal account has also been CVP-approved, so I no longer face the safeguards for this kind of detection-based question. ↩︎

  3. The answer from Astra is not worth it. ↩︎