https://gcc.gnu.org/bugzilla/show_bug.cgi?id=127112

            Bug ID: 127112
           Summary: _Unwind_Backtrace faults on CFI-derived addresses
           Product: gcc
           Version: 16.0
            Status: UNCONFIRMED
          Severity: normal
          Priority: P3
         Component: libgcc
          Assignee: unassigned at gcc dot gnu.org
          Reporter: travis.downs at gmail dot com
  Target Milestone: ---

When a profiling/stall-detection signal handler calls backtrace() and the
signal
happened to land in a function whose CFI is wrong or absent, libgcc's unwinder
dereferences the resulting addresses and segfaults inside libgcc_s.so.1. I
would
expect the *asynchronous* unwind path (_Unwind_Backtrace) to fail safe - stop
the
walk - rather than fault, because a program using it cannot avoid the
situation.

I am not asking for any change to exception unwinding, where a lying CFI is
fatal by definition. Only the backtrace path.

The trigger I hit in practice is glibc's swapcontext(), whose FDE contains
nothing but three DW_CFA_nop on both x86_64 and aarch64, so the CIE defaults
apply through the whole function. I have filed the missing annotations
separately against glibc; this report is about the unwinder's behavior when it
meets such code, which it cannot detect and cannot be fixed for retroactively
in
already-shipped libraries.

  x86_64  CIE says CFA = rsp+8 with the return address at CFA-8. swapcontext()
          reloads rsp from the target context (glibc 2.39: "mov
0xa0(%rdx),%rsp"
          at swapcontext+0xb0) and pushes the saved RIP only near the end, so
in
          between the unwinder reads a "return address" out of an unrelated
          stack.
  aarch64 CIE says the return address is in x30. swapcontext() clobbers x30
          early (glibc 2.41: "adr x30, swapcontext+0xe0" at swapcontext+0x14),
          so the unwinder gets a stale caller PC.

REPRODUCER

Attached: swapcontext-backtrace-repro.c

  cc -O1 -g -rdynamic -o repro swapcontext-backtrace-repro.c
  ./repro 6

Two ucontexts ping-pong while a 50us CLOCK_THREAD_CPUTIME_ID timer fires a
handler that calls backtrace(). The reproducer catches SIGSEGV and siglongjmps
out of the failed unwind so it can count the faults; without that net the
process just dies.

Observed on x86_64 (Ubuntu 24.04, glibc 2.39, libgcc_s from a GCC 16 snapshot,
20260315; also seen with the distro GCC 13 libgcc):

  switches=7733248 samples=5053 unwinder_crashes=14
    survived-unwind, interrupted pc offsets: +0x91:4544 +0x64:172 +0xa9:100
      +0x6b:81 +0xb0:42 +0xb7:10 +0xa7:13 +0x1da:25 +0x97:4 +0x0:9 ...
    CRASHED-unwind, interrupted pc offsets: +0xde:4 +0xb7:6 +0xbe:2 +0x1d2:1
+0xc6:1

Every faulting sample was interrupted at swapcontext+0xb7 or later, i.e. after
the rsp reload at +0xb0, exactly as the analysis above predicts. Without the
SIGSEGV net the process dies within ~5s, 3 runs out of 3, and gdb shows:

  Program received signal SIGSEGV, Segmentation fault.
  0x00007ffff7f96447 in ?? () from /lib/x86_64-linux-gnu/libgcc_s.so.1
  #0  0x00007ffff7f96447 in ?? () from /lib/x86_64-linux-gnu/libgcc_s.so.1
  #1  0x00007ffff7f98446 in _Unwind_Backtrace () from
/lib/x86_64-linux-gnu/libgcc_s.so.1
  #2  0x00007ffff7d35fb3 in __GI___backtrace (array=..., size=64) at
./debug/backtrace.c:78
  #3  0x0000555555555603 in handler (...) at swapcontext-backtrace-repro.c:126
  #4  <signal handler called>
  #5  swapcontext () at ../sysdeps/unix/sysv/linux/x86_64/swapcontext.S:106
  #6  0x0000000000000004 in ?? ()
  #7  0x0000001400000000 in ?? ()

The same reproducer run with a flag set around the switch, so the handler skips
the backtrace while a switch is in progress, gives unwinder_crashes=0.

AARCH64: SAME FAULT, REACHED THROUGH A REGISTER INSTEAD OF MEMORY

On aarch64 the CIE names x30 as the return-address column and the FDE supplies
no rule for it, so the unwinder takes the live x30 out of the interrupted
register set. swapcontext() has already overwritten it (glibc 2.41:
"adr x30, swapcontext+0xe0" at swapcontext+0x14), so the "caller" is an address
inside swapcontext() itself. That address does have an FDE, so the walk does
not
stop: the next frame's rules are applied to a register set that is
half-restored
from the incoming context while sp still refers to the outgoing stack. The
save-slot pointer that comes out of that can be near-null, and the fault then
lands in _Unwind_GetGR, at the line libgcc already flags as dangerous:

  /* This will segfault if the register hasn't been saved.  */
  if (size == sizeof(_Unwind_Ptr))
    return * (_Unwind_Ptr *) (_Unwind_Internal_Ptr) val;

reached from uw_update_context via _Unwind_GetPtr. A crash of exactly this
shape
has been observed on aarch64 in a sanitizer build, with the faulting read at
address 0x80e - a pointer no plausibility test would accept, which is why
suggestion 1 below would have prevented it.

When the stale x30 does not lead to a fault, the aarch64 outcome is worse than
a
truncated backtrace: the reported caller is the resume trampoline inside
swapcontext(), a perfectly valid code address, so the sample is silently wrong
and nothing downstream can tell it apart from a good one.

WHY THIS IS THE UNWINDER'S PROBLEM TOO

Whether a build faults or merely returns wrong frames is pure layout luck, and
the difference is one existing check in libgcc. If the stale stack word the CIE
rule points at happens to be zero, uw_frame_state_for's "context->ra == 0"
test ends the walk cleanly. If it happens to hold a plausible code address,
libgcc finds an FDE for it, applies that function's rules to a CFA taken from
an
unrelated stack, and dereferences whatever comes out. I have measured both
outcomes from the same source file built with slightly different frame layouts.

So libgcc already has the right instinct in one place; the walk simply has no
other guard rails. Concretely, in libgcc/unwind-dw2.c:

  * uw_update_context_1 computes the new CFA (CFA_REG_OFFSET / CFA_EXP) and
    stores it at "context->cfa = cfa" with no plausibility test at all, then
    immediately derives register addresses from it (REG_SAVED_OFFSET etc).
  * uw_update_context then reads the return address through
    _Unwind_GetPtr(context, fs->retaddr_column), which dereferences one of
    those addresses.
  * The only existing safety nets are the "ra == 0" check and
    _Unwind_Find_FDE failing.

Possible solutions:

1. A CFA plausibility check for the backtrace path. After computing the CFA,
   require it to move in the direction given by __LIBGCC_STACK_GROWS_DOWNWARD__
   relative to the previous frame's CFA (skipping frames where
   _Unwind_IsSignalFrame is set), and end the walk if it does not. In the case
   above the CFA jumps from the thread stack to an unrelated stack, which such
a
   test catches immediately. It must not apply to _Unwind_RaiseException, where
   truncating a walk would turn a bad backtrace into std::terminate, and it
will
   also stop legitimate makecontext/split-stack walks early - acceptable for a
   best-effort backtrace, not for EH, hence the gating.

2. Fault-tolerant reads on the backtrace path. All CFI-derived loads go through
   a small number of accessors (_Unwind_GetPtr / _Unwind_GetGRPtr and
   execute_stack_op's DW_OP_deref); routing them through a probing read on that
   path (process_vm_readv on /proc/self/mem is async-signal-safe on Linux)
would
   turn the fault into _URC_END_OF_STACK. More expensive, so presumably only
   acceptable behind an opt-in.

3. If neither can be made the default, an explicit best-effort entry point,
e.g.
   _Unwind_Backtrace2(trace, arg, flags) with a "hardened/asynchronous" flag
   enabling 1 and 2, leaving _Unwind_Backtrace and all EH paths untouched.
   glibc's backtrace() could then pass it; note that glibc's backtrace_helper
   already tries to be defensive (it stops when IP and CFA both stop changing),
   but that check only runs after libgcc has already done the dereference.

ENVIRONMENT

  x86_64, Linux 6.8, Ubuntu 24.04
  glibc 2.39
  libgcc_s.so.1 from GCC 16 snapshot 20260315, and from GCC 13.3
  reproducer compiled with gcc 13.3 -O1 -g -rdynamic

  aarch64: glibc 2.41, where I verified that the FDE is nop-only and that x30
           is clobbered at swapcontext+0x14; the aarch64 crash described above
           was seen in a sanitizer build on that architecture.

Reply via email to