Summary
On a target whose scripting runtime uses stackful coroutines / green threads (multiple logical call stacks multiplexed onto one OS thread, e.g. swapcontext/makecontext-style fiber schedulers), attaching an onLeave (or Interceptor.replace) to a native function that is entered and returns across a coroutine suspend/resume leads to a probabilistic SIGSEGV inside the Interceptor machinery.
The crashing frame (#0) is _gum_function_context_end_invocation (gum/guminterceptor.c), at:
stack_entry = gum_invocation_stack_peek_top (interceptor_ctx->stack);
*next_hop = gum_sign_code_pointer (stack_entry->caller_ret_addr); // <-- fault
stack_entry is a wild pointer because the thread's invocation stack is empty at this point, yet the value is dereferenced unconditionally.
Environment
- frida-gum 17.17.0 (the relevant code is also present on
main).
- Reproduced on Android arm64, embedded as Gadget. The analysis is platform-independent.
- Signal:
SIGSEGV / SEGV_MAPERR, fault address is a large out-of-bounds pointer (not a small NULL+offset).
Root cause
17.x added two "reap" steps that assume calls nest strictly by descending stack address on a single OS thread:
gum_function_context_begin_invocation() calls gum_invocation_stack_reap_unwound(stack, live_sp) on every on-enter, popping every entry whose stack_address < live_sp (treated as "unwound past").
_gum_function_context_end_invocation() calls gum_invocation_stack_reap_unwound_above(stack, returning_ctx).
With stackful coroutines this assumption breaks. Consider one OS thread running two fibers whose stacks live at unrelated addresses:
- Fiber A (low-address stack) enters hooked function
F → entry E_F pushed with stack_address = SP_A.
- The scheduler suspends A and resumes fiber B (higher-address stack). B enters hooked function
G at SP_B where SP_B > SP_A.
begin_invocation for G runs reap_unwound(stack, SP_B). Since SP_A < SP_B, the still-live entry E_F is misclassified as unwound and popped.
- Later A resumes and
F returns; its on-leave trampoline fires, but E_F is gone. The thread's invocation stack may now be empty, so gum_invocation_stack_peek_top() returns NULL.
Because _gum_function_context_end_invocation dereferences the peek result unconditionally, the compiler treats the if (stack->len == 0) return NULL; inside gum_invocation_stack_peek_top as dead (dereferencing NULL is UB), and eliminates it. The inlined peek then computes &g_array_index(stack, GumInvocationStackEntry, (guint)(len - 1)) with len == 0, i.e. index 0xFFFFFFFF, producing a wild pointer → SIGSEGV on the caller_ret_addr load.
Disassembly (arm64, inlined peek at the fault) confirms: len is loaded, sub len,#1 (no zero check), &data[len-1] computed via umaddl, then ldr x0,[x_entry,#8] faults.
Pre-17.x Gum did not have these reap steps and did not crash in this scenario (it simply peeked the current top).
Reproduction (conceptual)
- In a process using a runtime with stackful coroutines/green threads (fiber scheduler that swaps the C stack on the same OS thread).
Interceptor.attach(fn, { onLeave() { ... } }) (or Interceptor.replace) on a native function that is entered on one fiber and returns after a suspend/resume, so its frame is "crossed" by another hooked call on a higher-address fiber stack.
- Under load the process crashes probabilistically in
_gum_function_context_end_invocation.
Suggested fixes (any one addresses the crash)
- Guard the empty/mismatch case in
_gum_function_context_end_invocation: if gum_invocation_stack_peek_top() returns NULL (or the top's function_ctx != function_ctx), do not dereference; recover gracefully instead of relying on UB. This also makes gum_invocation_stack_peek_top's NULL guard actually effective (currently DCE'd away by the unconditional caller deref).
- Don't reap purely by stack-pointer comparison. Since fiber stacks are not monotonic across coroutines,
reap_unwound should not treat stack_address < live_sp as proof of unwinding. Reap only entries that are provably dead (e.g. require the returning function_ctx to be present, and never empty a non-empty stack that still holds unrelated live frames).
- Restoring the classic non-reaping behavior fixes stackful-coroutine workloads; a targeted guard is preferable so exception/
longjmp cleanup still works.
I'm happy to send a PR — please advise which direction you'd prefer (defensive guard in end_invocation vs. reworking the reap heuristic).
Summary
On a target whose scripting runtime uses stackful coroutines / green threads (multiple logical call stacks multiplexed onto one OS thread, e.g.
swapcontext/makecontext-style fiber schedulers), attaching anonLeave(orInterceptor.replace) to a native function that is entered and returns across a coroutine suspend/resume leads to a probabilisticSIGSEGVinside the Interceptor machinery.The crashing frame (#0) is
_gum_function_context_end_invocation(gum/guminterceptor.c), at:stack_entryis a wild pointer because the thread's invocation stack is empty at this point, yet the value is dereferenced unconditionally.Environment
main).SIGSEGV / SEGV_MAPERR, fault address is a large out-of-bounds pointer (not a small NULL+offset).Root cause
17.x added two "reap" steps that assume calls nest strictly by descending stack address on a single OS thread:
gum_function_context_begin_invocation()callsgum_invocation_stack_reap_unwound(stack, live_sp)on every on-enter, popping every entry whosestack_address < live_sp(treated as "unwound past")._gum_function_context_end_invocation()callsgum_invocation_stack_reap_unwound_above(stack, returning_ctx).With stackful coroutines this assumption breaks. Consider one OS thread running two fibers whose stacks live at unrelated addresses:
F→ entryE_Fpushed withstack_address = SP_A.GatSP_BwhereSP_B > SP_A.begin_invocationforGrunsreap_unwound(stack, SP_B). SinceSP_A < SP_B, the still-live entryE_Fis misclassified as unwound and popped.Freturns; its on-leave trampoline fires, butE_Fis gone. The thread's invocation stack may now be empty, sogum_invocation_stack_peek_top()returnsNULL.Because
_gum_function_context_end_invocationdereferences the peek result unconditionally, the compiler treats theif (stack->len == 0) return NULL;insidegum_invocation_stack_peek_topas dead (dereferencing NULL is UB), and eliminates it. The inlined peek then computes&g_array_index(stack, GumInvocationStackEntry, (guint)(len - 1))withlen == 0, i.e. index0xFFFFFFFF, producing a wild pointer →SIGSEGVon thecaller_ret_addrload.Disassembly (arm64, inlined peek at the fault) confirms:
lenis loaded,sub len,#1(no zero check),&data[len-1]computed viaumaddl, thenldr x0,[x_entry,#8]faults.Pre-17.x Gum did not have these reap steps and did not crash in this scenario (it simply peeked the current top).
Reproduction (conceptual)
Interceptor.attach(fn, { onLeave() { ... } })(orInterceptor.replace) on a native function that is entered on one fiber and returns after a suspend/resume, so its frame is "crossed" by another hooked call on a higher-address fiber stack._gum_function_context_end_invocation.Suggested fixes (any one addresses the crash)
_gum_function_context_end_invocation: ifgum_invocation_stack_peek_top()returnsNULL(or the top'sfunction_ctx != function_ctx), do not dereference; recover gracefully instead of relying on UB. This also makesgum_invocation_stack_peek_top's NULL guard actually effective (currently DCE'd away by the unconditional caller deref).reap_unwoundshould not treatstack_address < live_spas proof of unwinding. Reap only entries that are provably dead (e.g. require the returningfunction_ctxto be present, and never empty a non-empty stack that still holds unrelated live frames).longjmpcleanup still works.I'm happy to send a PR — please advise which direction you'd prefer (defensive guard in
end_invocationvs. reworking the reap heuristic).