Skip to content

Interceptor: live invocation-stack entry reaped under stackful coroutines → SIGSEGV in _gum_function_context_end_invocation #1142

Description

@wertyutreethgfd

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:

  1. 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").
  2. _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)

  1. In a process using a runtime with stackful coroutines/green threads (fiber scheduler that swaps the C stack on the same OS thread).
  2. 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.
  3. Under load the process crashes probabilistically in _gum_function_context_end_invocation.

Suggested fixes (any one addresses the crash)

  1. 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).
  2. 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).
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions