8000 Interceptor: live invocation-stack entry reaped under stackful coroutines → SIGSEGV in _gum_function_context_end_invocation · Issue #1142 · frida/frida-gum · GitHub
[go: up one dir, main page]

Skip to content

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

Open

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).

Activity

  1. wertyutreethgfd commented on Aug 13, 2026

    @wertyutreethgfd
    ContributorAuthor

    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:

    static void
    gum_invocation_stack_reap_unwound (GumInvocationStack * stack,
                                       gpointer live_stack_address)
    {
      /* Temporary: stackful coroutines break SP-based reaping (see issue body).
       * Classic Gum did not reap here; leave the stack untouched. */
      return;
    
      /* ... original loop left below for reference / easy revert ... */
      while (stack->len != 0)
      {
        /* ... */
      }
    }
    
    static void
    gum_invocation_stack_reap_unwound_above (GumInvocationStack * stack,
                                             GumFunctionContext * returning_ctx)
    {
      /* Same temporary no-op: do not pop frames based on returning_ctx matching.
       * Prevents emptying a live thread stack when a fiber returns out of order. */
      return;
    
      /* ... original loop ... */
    }

    Why this works for us

    • The crash path is end_invocation → peek_top() on an empty invocation stack, then unconditional deref of caller_ret_addr.
    • Disabling both reap_unwound (on enter) and reap_unwound_above (on leave) stops live fiber frames from being misclassified and popped.
    • Under stackful coroutine workloads this matches classic Gum: peek_top always sees a non-empty stack (even if slightly mismatched), instead of SIGSEGV.

    Trade-offs (known)

    • Entries that would have been cleaned up after 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.
    • That is acceptable for our current workload (same as pre-17.x), but it is not a preferred long-term fix.
    • Preferable upstream directions remain those in the issue description: (1) guard empty/mismatch in _gum_function_context_end_invocation so peek’s NULL check is not DCE’d, and/or (2) stop treating stack_address < live_sp alone as proof of unwinding across fiber stacks.

    Happy to turn a guarded / corrected-reap patch into a PR if maintainers indicate a preferred direction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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

      0