Repository navigation
Description
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); // <-- faultstack_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()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:
- Fiber A (low-address stack) enters hooked function
F→ entryE_Fpushed withstack_address = SP_A. - The scheduler suspends A and resumes fiber B (higher-address stack). B enters hooked function
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.- Later A resumes and
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_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() { ... } })(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.- 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: 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). - Don't reap purely by stack-pointer comparison. Since fiber stacks are not monotonic across coroutines,
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). - Restoring the classic non-reaping behavior fixes stackful-coroutine workloads; a targeted guard is preferable so exception/
longjmpcleanup 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).
Activity
wertyutreethgfd commentedon Aug 13, 2026
Temporary workaround we are using
Until there is an upstream fix, we restored the pre-17.x “no-reap” behavior by turning both reap helpers into no-ops in
gum/guminterceptor.c:Why this works for us
end_invocation→peek_top()on an empty invocation stack, then unconditional deref ofcaller_ret_addr.reap_unwound(on enter) andreap_unwound_above(on leave) stops live fiber frames from being misclassified and popped.peek_topalways sees a non-empty stack (even if slightly mismatched), instead of SIGSEGV.Trade-offs (known)
longjmp/ C++ exception skip of an on-leave trampoline are no longer reaped promptly, so the per-thread invocation stack can grow in those cases._gum_function_context_end_invocationso peek’s NULL check is not DCE’d, and/or (2) stop treatingstack_address < live_spalone as proof of unwinding across fiber stacks.Happy to turn a guarded / corrected-reap patch into a PR if maintainers indicate a preferred direction.