A Deep Dive into Frida Stalker's Source Code
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes.
A Deep Dive into Frida Stalker’s Source Code
Purpose
This records some earlier study and research. My original goal was to get familiar with the overall shape of Stalker, so that if I ever need to modify or customize it, I can get up to speed quickly.
Principle
Stalker executes code by translation, working at the granularity of a basic block. It copies the original binary code into new memory one basic block at a time, then runs a translation pass on that copied block — this is exactly what the transform function in the API does. The picture below illustrates it nicely. Also, Stalker is thread-level: it only traces a specific thread.
Fig. 1
Core data structures
_GumExecCtx is Stalker’s execution context. The pointer itself is stored in TLS, so it’s thread-specific, while the memory it points to lives in the normal heap. This is how each thread’s execution context stays independent.
struct _GumExecCtx
{
volatile gint state;
gint64 destroy_pending_since;
GumStalker * stalker;
GumThreadId thread_id;
GumArm64Writer code_writer;
GumArm64Writer slow_writer;
GumArm64Relocator relocator;
GumStalkerTransformer * transformer;
void(* transform_block_impl) (GumStalkerTransformer * self,
GumStalkerIterator * iterator, GumStalkerOutput * output);
GumEventSink * sink;
gboolean sink_started;
GumEventType sink_mask;
void(* sink_process_impl) (GumEventSink * self, const GumEvent * event,
GumCpuContext * cpu_context);
GumStalkerObserver * observer;
gboolean unfollow_called_while_still_following;
GumExecBlock * current_block;
gpointer pending_return_location;
gsize pending_calls;
gpointer resume_at;
gpointer return_at;
gconstpointer activation_target;
gpointer thunks;
gpointer infect_thunk;
GumAddress infect_body;
GumSpinlock code_lock;
GumCodeSlab * code_slab;
GumSlowSlab * slow_slab;
GumDataSlab * data_slab;
GumCodeSlab * scratch_slab;
GumMetalHashTable * mappings;
gpointer last_prolog_minimal;
gpointer last_epilog_minimal;
gpointer last_prolog_full;
gpointer last_epilog_full;
gpointer last_invalidator;
/*
* GumExecBlocks are attached to a singly linked list when they are generated,
* this allows us to store other data in the data slab (rather than relying on
* them being found in there in sequential order).
*/
GumExecBlock * block_list;
/*
* Stalker for AArch64 no longer makes use of a shadow stack for handling
* CALL/RET instructions, so we instead keep a count of the depth of the stack
* here when GUM_CALL or GUM_RET events are enabled.
*/
gsize depth;
#ifdef HAVE_LINUX
GumMetalHashTable * excluded_calls;
#endif
};
Next is GumExecBlock — this is the basic block that Stalker has copied and instrumented. Its memory layout is:
+----------------------+ <-- block->code_start
| instrumented code |
| (transformed/rewritten code) |
| (size: block->code_size) |
+----------------------+ <-- block->code_start + block->code_size
| original code snapshot | (this is what gum_exec_block_get_snapshot_start returns)
| (size: block->real_size) |
+----------------------+
As you can see, besides the instrumented basic block, it also keeps a snapshot of the original code. Why? For later optimization: Stalker runs transform on a basic block once; when that basic block is executed again, it compares the original code against this snapshot — if nothing changed, it won’t re-translate, otherwise it re-translates. Here are the definitions of _GumExecBlock and Stalker itself:
struct _GumExecBlock
{
/*
* GumExecBlock instances are held in a singly linked list to allow them to be
* disposed. This is necessary since other data may also be stored in the data
* slab (e.g. inline caches) and hence we cannot simply rely on them being
* contiguous.
*/
GumExecBlock * next;
GumExecCtx * ctx;
GumCodeSlab * code_slab;
GumSlowSlab * slow_slab;
GumExecBlock * storage_block;
guint8 * real_start;
guint8 * code_start;
guint8 * slow_start;
guint real_size;
guint code_size;
guint slow_size;
guint capacity;
guint last_callout_offset;
GumExecBlockFlags flags;
gint recycle_count;
GumIcEntry * ic_entries;
};
struct _GumStalker
{
GObject parent;
guint ic_entries;
gsize ctx_size;
gsize ctx_header_size;
goffset thunks_offset;
gsize thunks_size;
goffset code_slab_offset;
gsize code_slab_size_initial;
gsize code_slab_size_dynamic;
/*
* The instrumented code which Stalker generates is split into two parts.
* There is the part which is always run (the fast path) and the part which
* is run only when attempting to find the next block and call the backpatcher
* (the slow path). Backpatching is applied to the fast path so that
* subsequent executions no longer need to transit the slow path.
*
* By separating the code in this way, we can improve the locality of the code
* executing in the fast path. This has a performance benefit as well as
* making the backpatched code much easier to read when working in the
* debugger.
*
* The slow path makes use of its own slab and its own code writer.
*/
goffset slow_slab_offset;
gsize slow_slab_size_initial;
gsize slow_slab_size_dynamic;
goffset data_slab_offset;
gsize data_slab_size_initial;
gsize data_slab_size_dynamic;
goffset scratch_slab_offset;
gsize scratch_slab_size;
gsize page_size;
GumCpuFeatures cpu_features;
gboolean is_rwx_supported;
GMutex mutex;
GSList * contexts;
GArray * exclusions;
gint trust_threshold;
volatile gboolean any_probes_attached;
volatile gint last_probe_id;
GumSpinlock probe_lock;
GHashTable * probe_target_by_id;
GHashTable * probe_array_by_address;
GumExceptor * exceptor;
};
Stalker itself is a global object. Although execution happens independently per thread, all threads share some common state, so Stalker is designed to be global. One variable is worth noting:
gint trust_threshold;
It pairs with _GumExecBlock’s:
gint recycle_count;
Each time a basic block is about to execute, Stalker first compares the original code against the snapshot (mentioned above). If unchanged, it increments recycle_count — until it reaches trust_threshold. At that point the block is considered stable and won’t change anymore; afterwards, executing that block does nothing and jumps straight to the already-instrumented block. This trust threshold is user-specified.
Next is _GumSlab, used for Stalker’s memory allocation. To avoid the performance hit of calling malloc/mmap every time, it implements its own allocator: pre-allocate a big chunk up front, then carve pieces out of it as needed. One glance tells you the idea. Stalker keeps many of these:
_GumSlab
They’re held as a linked list and store Stalker’s instrumented basic blocks. Slabs also come in fast and slow varieties; high-frequency code is packed into the fast path to improve cache hit rate. But these are trivial optimization details — not worth dwelling on.
struct _GumSlab
{
guint8 * data; // start of the data region
guint offset; // currently used offset
guint size; // usable data region size
guint memory_size; // total memory block size (including the header)
GumSlab * next; // pointer to the next slab in the list
};
Then _GumStalkerIterator, mainly responsible for the actual translation in cooperation with the transform function (more later).
struct _GumStalkerIterator
{
GumExecCtx * exec_context;
GumExecBlock * exec_block;
GumGeneratorContext * generator_context;
GumInstruction instruction;
GumVirtualizationRequirements requirements;
};
Finally _GumGeneratorContext — the main data structure for generating instructions during translation. More concretely, it leverages what frida-gum provides: GumArm64Relocator copies instructions to new memory (automatically handling PC-relative instructions), and GumArm64Writer generates instructions.
struct _GumGeneratorContext
{
GumInstruction * instruction;
GumArm64Relocator * relocator;
GumArm64Writer * code_writer;
GumArm64Writer * slow_writer;
gpointer continuation_real_address;
GumPrologType opened_prolog;
};
The flow
Stalker has two entry APIs: gum_stalker_follow_me and gum_stalker_follow. Let me use gum_stalker_follow as the example. The code below shows only the important parts, omitting the mundane ones.
/* 1. */
gum_stalker_follow (GumStalker * self,
GumThreadId thread_id,
GumStalkerTransformer * transformer,
GumEventSink * sink)
GumInfectContext ctx;
ctx.stalker = self;
ctx.transformer = transformer;
ctx.sink = sink;
gum_process_modify_thread (thread_id, gum_stalker_infect, &ctx,
GUM_MODIFY_THREAD_FLAGS_NONE);
/* 2. */
gum_process_modify_thread (GumThreadId thread_id,
GumModifyThreadFunc func,
gpointer user_data,
GumModifyThreadFlags flags)
GumModifyThreadContext ctx;
ctx.func = func; // gum_stalker_infect
ctx.user_data = user_data; // the ctx from gum_stalker_follow, GumInfectContext
return gum_linux_modify_thread (thread_id, GUM_REGS_GENERAL_PURPOSE,
gum_do_modify_thread, &ctx, NULL);
/* 3. */
gum_linux_modify_thread (GumThreadId thread_id,
GumLinuxRegsType regs_type,
GumLinuxModifyThreadFunc func,
gpointer user_data,
GError ** error)
ctx.thread_id = thread_id;
ctx.regs_type = regs_type;
ctx.func = func; // gum_do_modify_thread
ctx.user_data = user_data; // the ctx from gum_process_modify_thread, GumModifyThreadContext
child = gum_libc_clone (
gum_linux_do_modify_thread,
stack + gum_query_page_size (),
CLONE_VM | CLONE_SETTLS,
&ctx,
NULL,
desc,
NULL);
prctl (PR_SET_PTRACER, child);
if(thread_id == gum_process_get_current_thread_id ())
{
success = GPOINTER_TO_UINT (g_thread_join (g_thread_new (
"gum-modify-thread-worker",
gum_linux_handle_modify_thread_comms,
&ctx)));
}
else
{
success = GPOINTER_TO_UINT (gum_linux_handle_modify_thread_comms (&ctx));
}
/* 4. */
gum_linux_handle_modify_thread_comms (gpointer data)
GumLinuxModifyThreadContext * ctx = data;
// func is gum_do_modify_thread
ctx->func (ctx->thread_id, &ctx->regs_data, ctx->user_data);
/* 5. */
gum_do_modify_thread (GumThreadId thread_id,
GumRegs * regs,
gpointer user_data)
GumModifyThreadContext * ctx = user_data;
// func is gum_stalker_infect
// user_data is the ctx of these three:
// GumInfectContext ctx;
// ctx.stalker = self;
// ctx.transformer = transformer;
// ctx.sink = sink;
ctx->func (thread_id, &cpu_context, ctx->user_data);
/* 6. */
gum_stalker_infect (GumThreadId thread_id,
GumCpuContext * cpu_context,
gpointer user_data)
The last parameter is event-related. Stalker supports events and callbacks — basic-block-executed, instruction-executed, function-call, etc. — letting the user run their own code at these important points.
gum_stalker_follow does a lot, split into two phases. Phase one prepares everything for phase two’s call to gum_stalker_infect — the code above. I’ll skip the fine details and cover only the important things and the overall shape. Stalker works by repeatedly fetching a basic block, translating it, executing it. So how does it fetch a basic block — especially the first one? That’s what gum_stalker_follow does. If entered via gum_stalker_follow_me, fetching the first block is simple: we’re inside the gum_stalker_follow_me call, so just grab the lr register as the first block’s entry. gum_stalker_follow, on the other hand, traces another thread, not the current one, so there’s some technique involved. Stalker mainly uses the clone syscall to create a new thread, which then ptrace-attaches to the target thread to read its register context — and the pc register gives the first block’s entry address. It uses clone instead of fork for two reasons: first, Stalker wants to share memory with the main process (a forked thread is a new process with independent memory); second, ptrace doesn’t allow attaching to a thread in the same thread group, and clone lets you precisely configure the new thread to belong to a different thread group.
Note that three threads now appear: the thread calling gum_stalker_follow (the main thread), the cloned thread, and the target thread to be traced. The main thread and the clone thread communicate via a socket. The main thread, through the clone thread, ptraces the target thread, gets its register context, runs some logic that modifies that context, and finally has the clone thread write the modified context back. The main modification: grab the target’s pc, fetch the basic block at it, translate it, then point pc at the translated block. That completes the trace setup.
As said, gum_stalker_follow has two phases — phase one configures and clones a new thread; phase two runs gum_stalker_infect inside the cloned thread.
/* 1. */
gum_stalker_infect (GumThreadId thread_id,
GumCpuContext * cpu_context,
gpointer user_data)
GumInfectContext * infect_context = user_data;
GumStalker * self = infect_context->stalker;
GumExecCtx * ctx;
guint8 * pc;
gpointer code_address;
GumArm64Writer * cw;
const guint potential_svc_size = 4;
ctx = gum_stalker_create_exec_ctx (self, thread_id,
infect_context->transformer, infect_context->sink);
pc = GSIZE_TO_POINTER (gum_strip_code_address (cpu_context->pc));
ctx->current_block = gum_exec_ctx_obtain_block_for (ctx, pc, &code_address);
/* 2. */
gum_exec_ctx_obtain_block_for (GumExecCtx * ctx,
gpointer real_address,
gpointer * code_address)
GumExecBlock * block;
gum_spinlock_acquire (&ctx->code_lock);
block = gum_metal_hash_table_lookup (ctx->mappings, real_address);
if(block != NULL)
{
const gint trust_threshold = ctx->stalker->trust_threshold;
gboolean still_up_to_date;
still_up_to_date =
(trust_threshold >= 0 && block->recycle_count >= trust_threshold) ||
memcmp(block->real_start, gum_exec_block_get_snapshot_start (block),
block->real_size) == 0;
gum_spinlock_release (&ctx->code_lock);
if(still_up_to_date)
{
if(trust_threshold > 0)
block->recycle_count++;
}
else
{
gum_exec_ctx_recompile_block (ctx, block);
}
}
else
{
block = gum_exec_block_new (ctx);
block->real_start = real_address;
gum_exec_block_maybe_inherit_exclusive_access_state (block, block->next);
gum_exec_ctx_compile_block (ctx, block, real_address, block->code_start,
GUM_ADDRESS (block->code_start), &block->real_size, &block->code_size,
&block->slow_size);
gum_exec_block_commit (block);
gum_exec_block_propagate_exclusive_access_state (block);
gum_metal_hash_table_insert (ctx->mappings, real_address, block);
gum_spinlock_release (&ctx->code_lock);
gum_exec_ctx_maybe_emit_compile_event (ctx, block);
}
*code_address = block->code_start;
return block;
/* 3. */
gum_exec_ctx_compile_block (GumExecCtx * ctx,
GumExecBlock * block,
gconstpointer input_code,
gpointer output_code,
GumAddress output_pc,
guint * input_size,
guint * output_size,
guint * slow_size)
GumArm64Writer * cw = &ctx->code_writer;
GumArm64Writer * cws = &ctx->slow_writer;
GumArm64Relocator * rl = &ctx->relocator;
GumGeneratorContext gc;
GumStalkerIterator iterator;
GumStalkerOutput output;
gboolean all_labels_resolved;
gboolean all_slow_labels_resolved;
gum_arm64_writer_reset (cw, output_code);
cw->pc = output_pc;
gum_arm64_writer_reset (cws, block->slow_start);
cws->pc = GUM_ADDRESS (block->slow_start);
gum_arm64_relocator_reset (rl, input_code, cw);
gum_ensure_code_readable (input_code, ctx->stalker->page_size);
gc.instruction = NULL;
gc.relocator = rl;
gc.code_writer = cw;
gc.slow_writer = cws;
gc.continuation_real_address = NULL;
gc.opened_prolog = GUM_PROLOG_NONE;
iterator.exec_context = ctx;
iterator.exec_block = block;
iterator.generator_context = &gc;
iterator.instruction.ci = NULL;
iterator.instruction.start = NULL;
iterator.instruction.end = NULL;
iterator.requirements = GUM_REQUIRE_NOTHING;
output.writer.arm64 = cw;
output.encoding = GUM_INSTRUCTION_DEFAULT;
// My guess: these two registers are saved because frida uses them frequently and
// has its own protocol for them, while the other registers may already be saved when used.
gum_arm64_writer_put_ldp_reg_reg_reg_offset (cw, ARM64_REG_X16, ARM64_REG_X17,
ARM64_REG_SP, 16 + GUM_RED_ZONE_SIZE, GUM_INDEX_POST_ADJUST);
gum_exec_block_maybe_write_call_probe_code (block, &gc);
ctx->pending_calls++;
ctx->transform_block_impl (ctx->transformer, &iterator, &output);
ctx->pending_calls--;
if(gc.continuation_real_address != NULL)
{
GumBranchTarget continue_target = { 0, };
continue_target.absolute_address = gc.continuation_real_address;
continue_target.reg = ARM64_REG_INVALID;
gum_exec_block_write_jmp_transfer_code (block, &continue_target,
GUM_ENTRYGATE (jmp_continuation), &gc);
}
First it creates the thread-independent execution context GumExecCtx via gum_stalker_create_exec_ctx, then fetches the basic block GumExecBlock with gum_exec_ctx_obtain_block_for using the block entry (the target thread’s pc value). The block-fetching logic: look up the entry address in a hash table to see if it’s already been translated. If yes, compare the block’s recycle_count against the trust threshold — if reached, jump straight to the translated block; if not, compare for changes: no change increments recycle_count and jumps to the translated block, change triggers gum_exec_ctx_recompile_block to re-translate, then jump to the translated block. On first execution, it triggers transform to translate, mainly via gum_exec_ctx_compile_block. Notice that this function calls ctx->transform_block_impl (ctx->transformer, &iterator, &output) — using the user-provided transform callback for the actual translation. Another notable point: at function entry it runs gum_arm64_writer_put_ldp_reg_reg_reg_offset to restore x16 and x17. These two registers, per the ARM64 ABI, are not required to be saved by either caller or callee; frida chose them to implement all-memory indirect jumps (br/blr to a register). The reason they must be restored on every gum_exec_ctx_compile_block call: Stalker executes block by block. After executing translated block A, it moves on to the next translated block; if that next block isn’t translated yet, it must call gum_exec_ctx_compile_block to translate it. But Stalker’s jumps go through x16/x17, polluting them — so they’re saved before the jump and restored after, keeping the register context consistent. In particular, if it reaches gum_exec_ctx_compile_block via bl rather than br, Stalker skips the first instruction (doesn’t restore x16/x17).
There’s logic around gc.continuation_real_address in gum_exec_ctx_compile_block, mostly for running out of memory: when translating a block, if the destination for the translated block runs out mid-instruction, gc.continuation_real_address records the instruction after the current one in the original block. After Stalker allocates a new memory block, it resumes translation there, and handles the jump from the out-of-space block to the newly allocated one.
Finally, gum_stalker_iterator_next — used by the transform function to iterate all instructions of the original block, completing the user’s custom instrumentation.
gboolean
gum_stalker_iterator_next (GumStalkerIterator * self,
const cs_insn ** insn)
GumGeneratorContext * gc = self->generator_context;
GumArm64Relocator * rl = gc->relocator;
GumInstruction * instruction;
gboolean is_first_instruction;
guint n_read;
instruction = self->generator_context->instruction;
is_first_instruction = instruction == NULL;
// check if slab space is sufficient
if(gum_stalker_iterator_is_out_of_space (self))
{
// If out of space, set continuation_real_address to the instruction after
// the current one as the resume point.
gc->continuation_real_address = instruction->end;
return FALSE;
}
// check if this is the last one; the relocator sets the last one as eob
if(!skip_implicitly_requested && gum_arm64_relocator_eob (rl))
return FALSE;
instruction = &self->instruction;
n_read = gum_arm64_relocator_read_one (rl, &instruction->ci);
if(n_read == 0)
return FALSE;
instruction->start = GSIZE_TO_POINTER (instruction->ci->address);
instruction->end = instruction->start + instruction->ci->size;
// Store the current instruction into generator_context, so that on the next
// round generator_context->instruction is the current instruction — matching
// the earlier check of whether it is null to detect the first instruction.
self->generator_context->instruction = instruction;
void
gum_stalker_iterator_keep (GumStalkerIterator * self)
{
// write the instruction-executed event
if((self->exec_context->sink_mask & GUM_EXEC) != 0 &&
(block->flags & GUM_EXEC_BLOCK_USES_EXCLUSIVE_ACCESS) == 0)
{
gum_exec_block_write_exec_event_code (block, gc, GUM_CODE_INTERRUPTIBLE);
}
switch(insn->id)
{
// all basic-block-splitting instructions are virtualized to regain control
requirements = gum_exec_block_virtualize_branch_insn (block, gc);
break;
requirements = gum_exec_block_virtualize_ret_insn (block, gc);
// handle svc
case ARM64_INS_SVC:
requirements = gum_exec_block_virtualize_sysenter_insn (block, gc);
break;
requirements = GUM_REQUIRE_RELOCATION;
}
gum_exec_block_close_prolog (block, gc, gc->code_writer);
if((requirements & GUM_REQUIRE_RELOCATION) != 0)
// write the instruction
gum_arm64_relocator_write_one (rl);
self->requirements = requirements;
}
First it checks the slab memory; if insufficient, it records the instruction after the breakpoint position as the resume point via continuation_real_address.
The second thing: GumGeneratorContext’s instruction holds the instruction currently being iterated in the block under translation, initialized to null — so checking whether it’s null tells you whether you’re translating the first instruction. Continuing: gum_arm64_relocator_read_one reads one instruction from the original block and stores it in self->generator_context->instruction. gum_arm64_relocator_read_one is a GumArm64Relocator member function. Because translation often keeps the original instructions, it’s equivalent to moving an instruction from the original block to the new block — which is what GumArm64Relocator is for. And the translation process is really just iterating every instruction (of some block) in GumArm64Relocator one by one; during iteration the user may choose to copy an instruction or not, and also do other things like insert callbacks or instructions.
After understanding the principle, I had two questions about Stalker. First: how does it hijack control flow the first time and start its translate-and-execute process? Answered above. Second: after executing a translated block, how does it regain control? Let’s answer that now, starting with gum_stalker_iterator_keep:
void
gum_stalker_iterator_keep (GumStalkerIterator * self)
{
// write the instruction-executed event
if((self->exec_context->sink_mask & GUM_EXEC) != 0 &&
(block->flags & GUM_EXEC_BLOCK_USES_EXCLUSIVE_ACCESS) == 0)
{
gum_exec_block_write_exec_event_code (block, gc, GUM_CODE_INTERRUPTIBLE);
}
switch(insn->id)
{
// all basic-block-splitting instructions are virtualized to regain control
requirements = gum_exec_block_virtualize_branch_insn (block, gc);
break;
requirements = gum_exec_block_virtualize_ret_insn (block, gc);
// handle svc
case ARM64_INS_SVC:
requirements = gum_exec_block_virtualize_sysenter_insn (block, gc);
break;
requirements = GUM_REQUIRE_RELOCATION;
}
gum_exec_block_close_prolog (block, gc, gc->code_writer);
if((requirements & GUM_REQUIRE_RELOCATION) != 0)
// write the instruction
gum_arm64_relocator_write_one (rl);
self->requirements = requirements;
}
This function mainly places the original block’s instructions into the new block, using GumArm64Relocator. The key thing to note: Stalker checks whether an instruction splits the basic block — various jumps, function calls, etc. For block-splitting instructions, Stalker special-cases them via the gum_exec_block_virtualize_* family — which it calls “virtualization”. Take gum_exec_block_virtualize_branch_insn as an example:
// Take an unconditional branch as an example
static GumVirtualizationRequirements
gum_exec_block_virtualize_branch_insn (GumExecBlock * block,
GumGeneratorContext * gc)
{
...
else
{
if(target.reg != ARM64_REG_INVALID)
regular_entry_func = GUM_ENTRYGATE (jmp_reg);
else
regular_entry_func = GUM_ENTRYGATE (jmp_imm);
cond_entry_func = NULL;
}
gum_exec_block_write_jmp_transfer_code (block, &target,
is_conditional ? cond_entry_func : regular_entry_func, gc);
}
This function checks which kind of jump instruction it is and runs different logic, though it’s broadly the same, so I picked one to look at. First it uses the GUM_ENTRYGATE macro to generate a function name — different jump instructions call different functions — then calls gum_exec_block_write_jmp_transfer_code to write that function into the new block, replacing the block-splitting instruction.
gum_exec_block_write_jmp_transfer_code is:
static void
gum_exec_block_write_jmp_transfer_code (GumExecBlock * block,
const GumBranchTarget * target,
GumExecCtxReplaceCurrentBlockFunc func,
GumGeneratorContext * gc)
// This part handles the case where the trust threshold is effective but cannot
// be statically backpatched (register-indirect jumps):
// 1. close any opened prolog code
// 2. generate inline-cache lookup code, stored to the fast path
// 3. put the result into a register and branch with a no-auth branch instruction
// The inline cache is an important optimization: it caches the previous jump
// target and its instrumented code, speeding up later jumps to the same target.
if(trust_threshold >= 0 && !can_backpatch_statically)
{
arm64_reg result_reg;
gum_exec_block_close_prolog (block, gc, cw);
/* inline cache handling */
result_reg = gum_exec_block_write_inline_cache_code (block, target->reg,
cw, cws);
gum_arm64_writer_put_br_reg_no_auth (cw, result_reg);
}
// direct branch handling
else
{
guint i;
/* jump directly to the slow path */
gum_exec_block_write_slab_transfer_code (cw, cws);
/* add NOP padding to leave room for potential later backpatching */
for(i = 0; i != 10; i++)
gum_arm64_writer_put_nop (cw);
}
// slow path implementation
// 1. open a minimal prolog to save register state
// 2. push the jump target address onto the stack, then pop into X0/X1
// 3. call the passed-in replacement function with the current block, target
// address and instruction address
// - this function looks up or compiles the target block, then switches to it
gum_exec_block_open_prolog (block, GUM_PROLOG_MINIMAL, gc, cws);
gum_exec_ctx_write_push_branch_target_address (block->ctx, target, gc, cws);
gum_arm64_writer_put_pop_reg_reg (cws, ARM64_REG_X0, ARM64_REG_X1);
gum_arm64_writer_put_call_address_with_arguments (cws,
GUM_ADDRESS (func), 3,
GUM_ARG_ADDRESS, GUM_ADDRESS (block),
GUM_ARG_REGISTER, ARM64_REG_X1,
GUM_ARG_ADDRESS, GUM_ADDRESS (gc->instruction->start));
As you can see, it mainly calls gum_arm64_writer_put_call_address_with_arguments to write the call to the function generated by the GUM_ENTRYGATE macro. Here’s that macro:
#define GUM_ENTRYGATE(name) \
gum_exec_ctx_replace_current_block_from_##name
#define GUM_DEFINE_ENTRYGATE(name) \
static gpointer \
GUM_ENTRYGATE (name) ( \
GumExecBlock * block, \
gpointer start_address, \
gpointer from_insn) \
{ \
GumExecCtx * ctx = block->ctx; \
\
if(ctx->observer != NULL) \
gum_stalker_observer_increment_##name (ctx->observer); \
\
return gum_exec_ctx_switch_block (ctx, block, start_address, from_insn); \
}
GUM_DEFINE_ENTRYGATE (call_imm)
GUM_DEFINE_ENTRYGATE (call_reg)
GUM_DEFINE_ENTRYGATE (excluded_call_imm)
GUM_DEFINE_ENTRYGATE (excluded_call_reg)
GUM_DEFINE_ENTRYGATE (ret)
GUM_DEFINE_ENTRYGATE (jmp_imm)
GUM_DEFINE_ENTRYGATE (jmp_reg)
GUM_DEFINE_ENTRYGATE (jmp_cond_cc)
GUM_DEFINE_ENTRYGATE (jmp_cond_cbz)
GUM_DEFINE_ENTRYGATE (jmp_cond_cbnz)
GUM_DEFINE_ENTRYGATE (jmp_cond_tbz)
GUM_DEFINE_ENTRYGATE (jmp_cond_tbnz)
GUM_DEFINE_ENTRYGATE (jmp_continuation)
// The core is switching blocks via gum_exec_ctx_switch_block; Stalker always
// retains control. This function is the real core of "look up or compile the
// target block, then switch".
GUM_ENTRYGATE generates the function name; GUM_DEFINE_ENTRYGATE generates each corresponding function. Every function has one core purpose — to call:
gum_exec_ctx_switch_block
This answers our second question: after Stalker finishes any basic block, it returns to gum_exec_ctx_switch_block, regains control, and dispatches the next block to execute, forming a closed loop. That’s how Stalker keeps fetching-translating-executing blocks.
Side note — Stalker’s pain point
When using Stalker in practice, it occasionally reports an error:
Unable to allocate code slab near 0x6fd644d000 with max_distance=2138779647
Finding the corresponding source:
static GumCodeSlab *
gum_code_slab_new (GumExecCtx * ctx)
{
GumStalker * stalker = ctx->stalker;
gsize total_size;
GumCodeSlab * code_slab;
GumSlowSlab * slow_slab;
GumAddressSpec spec;
total_size = stalker->code_slab_size_dynamic +
stalker->slow_slab_size_dynamic;
gum_exec_ctx_compute_code_address_spec (ctx, total_size, &spec);
code_slab = gum_memory_allocate_near (&spec, total_size, stalker->page_size,
stalker->is_rwx_supported ? GUM_PAGE_RWX : GUM_PAGE_RW);
if(code_slab == NULL)
{
g_error ("Unable to allocate code slab near %p with max_distance=%zu",
spec.near_address, spec.max_distance);
}
gum_code_slab_init (code_slab, stalker->code_slab_size_dynamic, total_size,
stalker->page_size);
slow_slab = gum_slab_end (&code_slab->slab);
gum_slow_slab_init (slow_slab, stalker->slow_slab_size_dynamic, 0,
stalker->page_size);
return code_slab;
}
In gum_code_slab_new, Stalker allocates memory near the GumExecCtx via gum_memory_allocate_near, so it can use relative jump instructions to reach it. But once the program has been running, memory is heavily fragmented — there often isn’t adjacent, sizable memory near the GumExecCtx. Several issues have been filed about this, but the maintainer apparently hasn’t addressed them yet.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch