FEX-Emu / FEX-Emu/FEX

rt_sigreturn leaves InSyscallInfo stale, causing later signal frames to overwrite the guest stack

Open
#5,901 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8k
Forks
351
Avg merge
12h 31m
Merged PRs (30d)
102

Description

While debugging and getting the Hytale Client / Game working on my Snapdragon X2E laptop (it ended up working!) I had to go through multiple patches to FEX. One of which was related to the following bug.

What I found was after a guest signal handler returns normally (no RIP change), Frame->InSyscallInfo is left holding the marker written by the guest's rt_sigreturn syscall. The next asynchronous signal that lands in JIT code on that thread then skips spilling the masked host registers in SpillSRA, so the guest signal frame is built from Frame->State, which at that point is the state restored at the previous sigreturn, i.e. the thread's state one call level up. The frame is written over live stack data of the thread.

An application can be affected when a later asynchronous signal lands in JIT code on the same thread while the stale InSyscallInfo marker remains active. Signal-heavy runtimes such as .NET NativeAOT GC suspension and Go asynchronous preemption make this condition much easier to trigger. (Hytale is a mix of Java, C#, and Go)

**Mechanism**

The way it is broken in main is as follows:

- The guest handler returns via rt_sigreturn. That is a guest syscall executed through DEF_OP(Syscall), which spills the SRA, stores InSyscallInfo = GPRSpillMask & 0xFFFF and calls the handler.

- HandleSigreturn → RestoreThreadState → RestoreFrame_x64 restores the host context saved at delivery and resumes at the original delivery point in JIT code. The syscall op's epilogue (str zr, [STATE, InSyscallInfo]) never runs.

- RestoreFrame_x64 restores Frame->InSyscallInfo = Context->InSyscallInfo only inside the "guest modified the RIP" branch (GuestFramesManagement.cpp lines 125–134 in 2608; same for the two ia32 variants at 213/292). In the normal branch the stale marker survives.

- The next signal in JIT code: HandleDispatcherGuestSignal sees WasInJIT and Frame->InSyscallInfo != 0, passes IgnoreMask = InSyscallInfo & 0xFFFF to SpillSRA, which skips every SRA register whose host index is < 16 - RSP included. NewGuestSP is derived from the stale State.gregs[REG_RSP].

**Steps To Reproduce**

Added to the details dropdown below is an example for a worker thread that alternates between a shallow busy loop and a 12-deep call chain whose frames hold a known pattern, while the main thread sends it SIGUSR1 every 20 µs; the handler just returns. No sigaltstack, SA_SIGINFO | SA_RESTART.

fex-sigframe-repro.c

```c
/* Reproducer: guest signal frames written from stale state after a normal
* rt_sigreturn (FEX-2608, FEX main as of 2026-09-02).
*
* A worker thread alternates between a shallow busy loop and a deep call
* chain whose frames hold a known pattern. The main thread keeps sending it
* SIGUSR1; the handler does nothing and returns normally.
*
* Correct behaviour (native x86-64, or FEX with the proposed fix): every signal frame
* is placed below the interrupted frame's RSP, the pattern survives, the
* program prints "OK".
*
* Buggy behaviour: after the first signal returns, Frame->InSyscallInfo keeps
* the marker written by the rt_sigreturn syscall op. The next signal that
* lands in JIT code skips spilling the live RSP and builds its frame from the
* RSP of the previous delivery (shallow phase) -- on top of the deep phase's
* live frames. The pattern check fails within a second or two.
*
* Build (x86-64): gcc -O1 -pthread -o fex-sigframe-repro fex-sigframe-repro.c
* Run: FEXInterpreter ./fex-sigframe-repro (or via binfmt)
*/
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
#include

#define DEPTH 12
#define WORDS 96 /* 768 bytes per frame; a signal frame is ~0x500 bytes */
#define PATTERN(d, i) (0x5a5a000000000000ULL ^ ((unsigned long long)(d) << 32) ^ (unsigned long long)(i))

static volatile sig_atomic_t signals_seen;
static volatile int stop;
static pthread_t worker;

static void handler(int sig, siginfo_t* si, void* uc) {
(void)sig; (void)si; (void)uc;
signals_seen++;
}

static void spin(unsigned long iters) {
volatile unsigned long x = 0;
for (unsigned long i = 0; i < iters; i++) x += i;
}

/* Deep phase: each level owns a pattern-filled buffer that it verifies after
* the deeper levels (and a short spin) have run. noinline so every level is a
* real frame. */
static __attribute__((noinline)) void deep(int d) {
volatile unsigned long long buf[WORDS];
for (int i = 0; i < WORDS; i++) buf[i] = PATTERN(d, i);

if (d < DEPTH) {
deep(d + 1);
} else {
spin(20000);
}

for (int i = 0; i < WORDS; i++) {
if (buf[i] != PATTERN(d, i)) {
fprintf(stderr,
"FRAME CORRUPTED: depth %d word %d = %#llx (expected %#llx), %d signals so far\n"
" frame at %p; a guest signal frame was written over live stack data\n",
d, i, buf[i], PATTERN(d, i), (int)signals_seen, (void*)buf);
_exit(1);
}
}
}

static void* worker_main(void* arg) {
(void)arg;
while (!stop) {
spin(20000); /* shallow phase: the "previous delivery" RSP is high */
deep(1); /* deep phase: live frames far below that RSP */
}
return NULL;
}

int main(void) {
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_sigaction = handler;
sa.sa_flags = SA_SIGINFO | SA_RESTART; /* no SA_ONSTACK: frames go on the thread stack */
sigemptyset(&sa.sa_mask);
if (sigaction(SIGUSR1, &sa, NULL) != 0) { perror("sigaction"); return 2; }

if (pthread_create(&worker, NULL, worker_main, NULL) != 0) { perror("pthread_create"); return 2; }

struct timespec ts = {0, 20000}; /* 20 us between signals */
for (int i = 0; i < 300000; i++) {
pthread_kill(worker, SIGUSR1);
nanosleep(&ts, NULL);
}
stop = 1;
pthread_join(worker, NULL);
printf("OK: %d signals delivered, no frame corruption\n", (int)signals_seen);
return 0;
}
```

```
gcc -O1 -pthread -static -o fex-sigframe-repro fex-sigframe-repro.c
FEXInterpreter ./fex-sigframe-repro
```

Expected (native x86-64, and FEX with the proposed fix below):

```
OK: 299914 signals delivered, no frame corruption
```

Actual (FEX-2608, Snapdragon X2 Elite, static musl build of the reproducer):

```
FRAME CORRUPTED: depth 3 word 9 = 0x4019c7 (expected 0x5a5a000300000009), 4 signals so far
frame at 0x7ffff7efd540; a guest signal frame was written over live stack data
```
The value that replaced the pattern, 0x4019c7, is a guest code address. The RIP field of the ucontext in the misplaced signal frame. It fails on the 4th signal, i.e. the first one delivered in the deep phase after a return from the shallow phase.

With the proposed fix below, same binary, same machine:

```
OK: 299995 signals delivered, no frame corruption
```

The original finding was a .NET NativeAOT game (Hytale) that died within a minute of heavy GC activity with several unrelated-looking signatures (stack-protector aborts, SetupFrame_x64 faulting with NewGuestSP = 0xfffffffffffffbb0 because State.gregs[RSP] was still 0 for a running thread, GC heap corruption); the reproducer isolates the mechanism.

**Observations From Original Application**

- Stack-protector aborts in a native function that cannot write its own frame (sentry-native's ELF reader). Dumping the frame at __stack_chk_fail showed the canary slot zeroed and, above it, FEX's xstate magic and the ContextBackup host pointer written by SetupFrame_x64, a guest signal frame, placed ~0x150 bytes above the thread's real RSP (one call level up).

- A core where SetupFrame_x64 itself faulted with NewGuestSP = 0xfffffffffffffbb0: State.gregs[RSP] == 0 for a running thread (state never written since thread creation, never spilled because of the stale marker).

- Heap/GC corruption in the guest (frames written over a thread's locals that hold GC roots).

With FEX_MAXINST=1 the problem is invisible in the game (the state is written back at every instruction boundary), which is how it was narrowed down.

**Proposed Fix**

What I had to do locally in NixOS was patch FEX in the following way to fix it:

Patch

```diff
--- a/Source/Tools/LinuxEmulation/LinuxSyscalls/SignalDelegator/GuestFramesManagement.cpp
+++ b/Source/Tools/LinuxEmulation/LinuxSyscalls/SignalDelegator/GuestFramesManagement.cpp
@@ -127,12 +127,11 @@
auto* guest_uctx = reinterpret_cast(Context->UContextLocation);
[[maybe_unused]] auto* guest_siginfo = reinterpret_cast(Context->SigInfoLocation);

+ Frame->InSyscallInfo = Context->InSyscallInfo;
+
// If the guest modified the RIP then we need to take special precautions here
if (Context->OriginalRIP != guest_uctx->uc_mcontext.gregs[FEXCore::x86_64::FEX_REG_RIP] || Context->FaultToTopAndGeneratedException) {

- // Restore previous `InSyscallInfo` structure.
- Frame->InSyscallInfo = Context->InSyscallInfo;
-
// Hack! Go back to the top of the dispatcher top
// This is only safe inside the JIT rather than anything outside of it
ArchHelpers::Context::SetPc(ucontext, Config.AbsoluteLoopTopAddressFillSRA);
@@ -208,11 +207,10 @@
void SignalDelegator::RestoreFrame_ia32(FEXCore::Core::InternalThreadState* Thread, ArchHelpers::Context::ContextBackup* Context,
FEXCore::Core::CpuStateFrame* Frame, void* ucontext) {
SigFrame_i32* guest_uctx = reinterpret_cast(Context->UContextLocation);
+ Frame->InSyscallInfo = Context->InSyscallInfo;
+
// If the guest modified the RIP then we need to take special precautions here
if (Context->OriginalRIP != guest_uctx->sc.ip || Context->FaultToTopAndGeneratedException) {
- // Restore previous `InSyscallInfo` structure.
- Frame->InSyscallInfo = Context->InSyscallInfo;
-
// Hack! Go back to the top of the dispatcher top
// This is only safe inside the JIT rather than anything outside of it
ArchHelpers::Context::SetPc(ucontext, Config.AbsoluteLoopTopAddressFillSRA);
@@ -286,12 +284,11 @@
void SignalDelegator::RestoreRTFrame_ia32(FEXCore::Core::InternalThreadState* Thread, ArchHelpers::Context::ContextBackup* Context,
FEXCore::Core::CpuStateFrame* Frame, void* ucontext) {
RTSigFrame_i32* guest_uctx = reinterpret_cast(Context->UContextLocation);
+ Frame->InSyscallInfo = Context->InSyscallInfo;
+
// If the guest modified the RIP then we need to take special precautions here
if (Context->OriginalRIP != guest_uctx->uc.uc_mcontext.gregs[FEXCore::x86::FEX_REG_EIP] || Context->FaultToTopAndGeneratedException) {

- // Restore previous `InSyscallInfo` structure.
- Frame->InSyscallInfo = Context->InSyscallInfo;
-
// Hack! Go back to the top of the dispatcher top
// This is only safe inside the JIT rather than anything outside of it
ArchHelpers::Context::SetPc(ucontext, Config.AbsoluteLoopTopAddressFillSRA);
```

With this applied, the NativeAOT game (Hytale) that previously died within a minute of world load (every run, several distinct crash signatures) runs stably (with a few other patches, but this one was a major problem).

**My Environment**
```
Version: FEX-2608 (also present on main as of 2026-09-02)
Host: aarch64, Snapdragon X2 Elite (Oryon-3), NixOS, 4K pages
Guest: x86-64 .NET NativeAOT application (Hytale client); Go binaries show the same issue with async preemption signals
```

Please let me know if there's any other details I can provide. I'm very happy I got Hytale working locally (took two days of debugging) and want to get some of my local patches out of my personal environment, so thought I'd share here to see if it made sense.

Contributor guide

Open the contributing guide

Research direction

Start in Source/Tools/LinuxEmulation/LinuxSyscalls/SignalDelegator/GuestFramesManagement.cpp, focusing on RestoreFrame_x64, RestoreFrame_ia32, and RestoreRTFrame_ia32 and how Context->InSyscallInfo is restored. Build and run the supplied fex-sigframe-repro.c reproducer under FEXInterpreter; done means it completes with an OK result and no frame corruption after repeated signals.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, linux
Domain
operating-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.