Description
On macOS, a .NET process can crash with SIGSEGV at address 0x0 if it was launched by a parent that had installed an SA_SIGINFO signal handler for SIGUSR1.
The cause is an interaction between a macOS execve behavior and the runtime's activation signal handler.
1. macOS leaks sa_flags across execve.
POSIX resets handled signals to SIG_DFL across exec. macOS does reset the handler, but retains sa_flags. A child process therefore starts with:
SIGUSR1 = { sa_handler = SIG_DFL (NULL), sa_flags = SA_SIGINFO | ... }
This is representable because sa_handler and sa_sigaction are a union at the same address. It is also a state that man sigaction describes as invalid to assign ("This bit should not be set when assigning SIG_DFL or SIG_IGN"), yet the kernel produces it on its own across exec, so consumers have to tolerate it.
2. The runtime installs its activation handler over that inherited disposition.
INJECT_ACTIVATION_SIGNAL is defined as SIGRTMIN where available, falling back to SIGUSR1 on macOS, which has no SIGRTMIN:
#ifdef SIGRTMIN
#define INJECT_ACTIVATION_SIGNAL SIGRTMIN
#else
#define INJECT_ACTIVATION_SIGNAL SIGUSR1
#endif
When installing its own handler, the runtime saves the existing disposition so it can chain to it later. That saved copy is now the leaked { NULL, SA_SIGINFO } pair.
3. Chaining to the saved disposition dereferences NULL.
Here is the chaining code, from NativeAOT's ActivationHandler in src/coreclr/nativeaot/Runtime/unix/PalUnix.cpp (~L1081):
// Call the original handler when it is not ignored or default (terminate).
if (g_previousActivationHandler.sa_flags & SA_SIGINFO)
{
_ASSERTE(g_previousActivationHandler.sa_sigaction != NULL);
g_previousActivationHandler.sa_sigaction(code, siginfo, context);
}
else
{
if (g_previousActivationHandler.sa_handler != SIG_IGN &&
g_previousActivationHandler.sa_handler != SIG_DFL)
{
_ASSERTE(g_previousActivationHandler.sa_handler != NULL);
g_previousActivationHandler.sa_handler(code);
}
}
The comment states the intent correctly: skip the previous handler when it is SIG_IGN or SIG_DFL. But that test only appears inside the else branch, which runs when SA_SIGINFO is clear. With the leaked flags, SA_SIGINFO is set, so control takes the first branch, which performs no such test and calls sa_sigaction directly. Since sa_sigaction is SIG_DFL (NULL), the handler jumps to address 0x0.
Put differently: the guard is attached to the one path where the pointer is guaranteed valid, and omitted from the path where it can be NULL.
This is the same class of defect as #55645, which was fixed for 6.0.0 in System.Native and in the PAL's hardware-signal path. The activation handlers were not updated.
Severity differs between the two runtimes
CoreCLR's inject_activation_handler in src/coreclr/pal/src/exception/signal.cpp contains the same block, so both runtimes are vulnerable. They differ in when they reach it:
|
when the saved disposition is chained |
practical impact |
| NativeAOT |
unconditionally, on every activation signal, after the thread-hijack block |
crashes on the first GC suspension; deterministic, needs no external trigger |
| CoreCLR |
only in the else branch, i.e. when the signal is not one of the runtime's own activations |
latent; needs a SIGUSR1 from another process |
In CoreCLR the chaining sits inside the fallback path:
if (g_activationFunction != NULL && (siginfo->si_pid == getpid() || siginfo->si_pid == 0))
{
// ... normal activation, previous handler never consulted ...
}
else
{
// Call the original handler when it is not ignored or default (terminate).
if (g_previous_activation.sa_flags & SA_SIGINFO)
{
_ASSERTE(g_previous_activation.sa_sigaction != NULL);
g_previous_activation.sa_sigaction(code, siginfo, context);
}
...
}
So a GC-driven activation in CoreCLR takes the first branch and never touches the saved disposition, while NativeAOT runs its chaining block on every delivery. Both crash identically once the block is reached.
Reproduction Steps
Requires a C compiler and the .NET SDK on macOS. No third-party tooling.
The recipe below builds a launcher that installs an SA_SIGINFO handler for SIGUSR1 and then execvs its target, which is all that is needed to hand a child the leaked disposition. Two test applications follow: a NativeAOT one that faults on its own during garbage collection, and a CoreCLR one that faults when signalled from another process. Each is run twice, directly as a control and then through the launcher.
1. Create launch.c:
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static void handler(int sig, siginfo_t *info, void *ctx) { (void)sig; (void)info; (void)ctx; }
int main(int argc, char **argv)
{
if (argc < 2) { fprintf(stderr, "usage: %s <program> [args...]\n", argv[0]); return 2; }
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = handler;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGUSR1, &sa, NULL) != 0) { perror("sigaction"); return 1; }
execv(argv[1], &argv[1]);
perror("execv");
return 127;
}
2. Create gctest/gctest.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
3. Create gctest/Program.cs:
using System;
using System.Threading;
class Program
{
static volatile bool stop;
static void Main()
{
Console.WriteLine("START");
for (int i = 0; i < 4; i++)
{
var t = new Thread(() => { long x = 0; while (!stop) { x++; } GC.KeepAlive(x); });
t.IsBackground = true;
t.Start();
}
Thread.Sleep(300);
for (int i = 0; i < 10; i++)
{
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
Thread.Sleep(30);
}
stop = true;
Console.WriteLine("SURVIVED_GC");
}
}
4. Create sigtest/sigtest.csproj with the same contents as gctest.csproj, and sigtest/Program.cs:
using System;
using System.Threading;
class Program
{
static void Main()
{
Console.WriteLine("READY");
Console.Out.Flush();
Thread.Sleep(5000);
Console.WriteLine("SURVIVED_EXTERNAL_SIGUSR1");
}
}
5. Build the launcher and both applications:
$ cc -o launch launch.c
$ dotnet publish gctest -c Release -r osx-arm64 -o ./aot
$ dotnet publish sigtest -c Release -r osx-arm64 -p:PublishAot=false -p:SelfContained=true -o ./clr
6. Run the NativeAOT case, first directly, then through the launcher:
$ ./aot/gctest
START
SURVIVED_GC
$ ./launch ./aot/gctest
START
Segmentation fault: 11
7. Run the CoreCLR case, first directly, then through the launcher, sending SIGUSR1 from the shell in both runs:
$ ./clr/sigtest & sleep 1; kill -USR1 $!
READY
SURVIVED_EXTERNAL_SIGUSR1
$ ./launch ./clr/sigtest & sleep 1; kill -USR1 $!
READY
Segmentation fault: 11
Both launcher runs fault; both direct runs complete.
Notes on the recipe
The spin loops in gctest are deliberately allocation-free so the optimizer emits no GC poll sites in them. The runtime then has to interrupt those threads with the activation signal in order to suspend them for a collection, which is what drives the crash without any external signal.
sigtest needs the explicit kill -USR1 because CoreCLR only consults the saved disposition for signals that are not its own activations, as described above.
The faulting runs exit with 139 (128 + SIGSEGV), not 158 (128 + SIGUSR1), confirming a genuine fault rather than SIGUSR1's default terminate action.
Confirming the inherited disposition (optional)
To observe the leaked state directly rather than inferring it from the crash, build a dumper that reports the disposition it was started with:
#include <signal.h>
#include <stdio.h>
int main(void)
{
struct sigaction sa;
if (sigaction(SIGUSR1, NULL, &sa) != 0) { perror("sigaction"); return 1; }
printf("SIGUSR1: sa_handler=%p sa_flags=0x%x SA_SIGINFO=%s is_SIG_DFL=%s\n",
(void *)sa.sa_handler, sa.sa_flags,
(sa.sa_flags & SA_SIGINFO) ? "yes" : "no",
(sa.sa_handler == SIG_DFL) ? "yes" : "no");
return 0;
}
$ cc -o dumpsig dumpsig.c
$ ./dumpsig
SIGUSR1: sa_handler=0x0 sa_flags=0x0 SA_SIGINFO=no is_SIG_DFL=yes
$ ./launch ./dumpsig
SIGUSR1: sa_handler=0x0 sa_flags=0x42 SA_SIGINFO=yes is_SIG_DFL=yes
The second line shows the problematic combination: SA_SIGINFO set with a NULL handler.
Expected behavior
The application runs to completion. An inherited default disposition is treated as SIG_DFL regardless of whether SA_SIGINFO is set.
Actual behavior
Deterministic crash, verified 3/3 per configuration. Both runtimes produce the same fault:
exception: EXC_BAD_ACCESS (SIGSEGV)
KERN_INVALID_ADDRESS at 0x0000000000000000
frames: 0 ??? 0x0
1 libsystem_platform.dylib _sigtramp
2 libcoreclr.dylib ...ThreadNativeWait(...) # CoreCLR case
Frame #0 is address 0x0, reached via _sigtramp, i.e. the signal handler itself jumped to NULL.
Regression?
Not a regression; the code appears to have always had this shape. The equivalent defect in System.Native and the PAL's hardware-signal path was reported as #55645 and fixed for 6.0.0, but the activation handlers were not updated.
Known Workarounds
Workaround for affected hosts: reset signal dispositions to SIG_DFL, clearing sa_flags, between fork and exec. We shipped this in microsoft/dcp#244. It has to be done in the child after forking; clearing the flags in the parent both breaks the parent's own signal handling and does not survive the way Go's os/exec re-arms its handlers. It's not particularly straightforward.
Configuration
- .NET SDK 10.0.201, host 10.0.5 (commit
a612c2a105), osx-arm64 (also reproduced with an AOT build using 10.0.400)
- macOS 26.6.2 (25G83), Apple silicon
- NativeAOT reproduces in both
-c Debug and -c Release
- This is specific to macOS, but reproduces on both Arm64 and x86
Other information
Why macOS only
- Linux clears
sa_flags across execve, so the inherited state never arises.
INJECT_ACTIVATION_SIGNAL is SIGRTMIN where available and only falls back to SIGUSR1 on macOS. A realtime signal is far less likely to carry an inherited disposition from a parent than SIGUSR1.
Suggested fix
Test for the default and ignored dispositions before dispatching on SA_SIGINFO, in both handlers.
The PAL already contains predicates for exactly this, used by the hardware-signal handlers via invoke_previous_action, and their comment documents the macOS behavior described above (signal.cpp, ~L404):
static bool IsSigDfl(struct sigaction* action)
{
// macOS can return sigaction with SIG_DFL and SA_SIGINFO.
// SA_SIGINFO means we should use sa_sigaction, but here we want to check sa_handler.
// So we ignore SA_SIGINFO when sa_sigaction and sa_handler are at the same address.
return (&action->sa_handler == (void*)&action->sa_sigaction || !IsSaSigInfo(action)) &&
action->sa_handler == SIG_DFL;
}
The activation handlers do not route through invoke_previous_action, so they never reach these checks. Reordering the chaining logic to use them resolves both cases:
if (!IsSigDfl(&previous) && !IsSigIgn(&previous))
{
if (previous.sa_flags & SA_SIGINFO)
{
previous.sa_sigaction(code, siginfo, context);
}
else
{
previous.sa_handler(code);
}
}
NativeAOT would need equivalents of these predicates. It may also be worth auditing other handlers that chain to a saved previous action for the same pattern.
Description
On macOS, a .NET process can crash with
SIGSEGVat address0x0if it was launched by a parent that had installed anSA_SIGINFOsignal handler forSIGUSR1.The cause is an interaction between a macOS
execvebehavior and the runtime's activation signal handler.1. macOS leaks
sa_flagsacrossexecve.POSIX resets handled signals to
SIG_DFLacrossexec. macOS does reset the handler, but retainssa_flags. A child process therefore starts with:This is representable because
sa_handlerandsa_sigactionare a union at the same address. It is also a state thatman sigactiondescribes as invalid to assign ("This bit should not be set when assigningSIG_DFLorSIG_IGN"), yet the kernel produces it on its own acrossexec, so consumers have to tolerate it.2. The runtime installs its activation handler over that inherited disposition.
INJECT_ACTIVATION_SIGNALis defined asSIGRTMINwhere available, falling back toSIGUSR1on macOS, which has noSIGRTMIN:When installing its own handler, the runtime saves the existing disposition so it can chain to it later. That saved copy is now the leaked
{ NULL, SA_SIGINFO }pair.3. Chaining to the saved disposition dereferences NULL.
Here is the chaining code, from NativeAOT's
ActivationHandlerinsrc/coreclr/nativeaot/Runtime/unix/PalUnix.cpp(~L1081):The comment states the intent correctly: skip the previous handler when it is
SIG_IGNorSIG_DFL. But that test only appears inside theelsebranch, which runs whenSA_SIGINFOis clear. With the leaked flags,SA_SIGINFOis set, so control takes the first branch, which performs no such test and callssa_sigactiondirectly. Sincesa_sigactionisSIG_DFL(NULL), the handler jumps to address0x0.Put differently: the guard is attached to the one path where the pointer is guaranteed valid, and omitted from the path where it can be NULL.
This is the same class of defect as #55645, which was fixed for 6.0.0 in
System.Nativeand in the PAL's hardware-signal path. The activation handlers were not updated.Severity differs between the two runtimes
CoreCLR's
inject_activation_handlerinsrc/coreclr/pal/src/exception/signal.cppcontains the same block, so both runtimes are vulnerable. They differ in when they reach it:elsebranch, i.e. when the signal is not one of the runtime's own activationsSIGUSR1from another processIn CoreCLR the chaining sits inside the fallback path:
So a GC-driven activation in CoreCLR takes the first branch and never touches the saved disposition, while NativeAOT runs its chaining block on every delivery. Both crash identically once the block is reached.
Reproduction Steps
Requires a C compiler and the .NET SDK on macOS. No third-party tooling.
The recipe below builds a launcher that installs an
SA_SIGINFOhandler forSIGUSR1and thenexecvs its target, which is all that is needed to hand a child the leaked disposition. Two test applications follow: a NativeAOT one that faults on its own during garbage collection, and a CoreCLR one that faults when signalled from another process. Each is run twice, directly as a control and then through the launcher.1. Create
launch.c:2. Create
gctest/gctest.csproj:3. Create
gctest/Program.cs:4. Create
sigtest/sigtest.csprojwith the same contents asgctest.csproj, andsigtest/Program.cs:5. Build the launcher and both applications:
6. Run the NativeAOT case, first directly, then through the launcher:
7. Run the CoreCLR case, first directly, then through the launcher, sending
SIGUSR1from the shell in both runs:Both launcher runs fault; both direct runs complete.
Notes on the recipe
The spin loops in
gctestare deliberately allocation-free so the optimizer emits no GC poll sites in them. The runtime then has to interrupt those threads with the activation signal in order to suspend them for a collection, which is what drives the crash without any external signal.sigtestneeds the explicitkill -USR1because CoreCLR only consults the saved disposition for signals that are not its own activations, as described above.The faulting runs exit with 139 (
128 + SIGSEGV), not 158 (128 + SIGUSR1), confirming a genuine fault rather thanSIGUSR1's default terminate action.Confirming the inherited disposition (optional)
To observe the leaked state directly rather than inferring it from the crash, build a dumper that reports the disposition it was started with:
The second line shows the problematic combination:
SA_SIGINFOset with a NULL handler.Expected behavior
The application runs to completion. An inherited default disposition is treated as
SIG_DFLregardless of whetherSA_SIGINFOis set.Actual behavior
Deterministic crash, verified 3/3 per configuration. Both runtimes produce the same fault:
Frame #0 is address
0x0, reached via_sigtramp, i.e. the signal handler itself jumped to NULL.Regression?
Not a regression; the code appears to have always had this shape. The equivalent defect in
System.Nativeand the PAL's hardware-signal path was reported as #55645 and fixed for 6.0.0, but the activation handlers were not updated.Known Workarounds
Workaround for affected hosts: reset signal dispositions to
SIG_DFL, clearingsa_flags, betweenforkandexec. We shipped this in microsoft/dcp#244. It has to be done in the child after forking; clearing the flags in the parent both breaks the parent's own signal handling and does not survive the way Go'sos/execre-arms its handlers. It's not particularly straightforward.Configuration
a612c2a105),osx-arm64(also reproduced with an AOT build using 10.0.400)-c Debugand-c ReleaseOther information
Why macOS only
sa_flagsacrossexecve, so the inherited state never arises.INJECT_ACTIVATION_SIGNALisSIGRTMINwhere available and only falls back toSIGUSR1on macOS. A realtime signal is far less likely to carry an inherited disposition from a parent thanSIGUSR1.Suggested fix
Test for the default and ignored dispositions before dispatching on
SA_SIGINFO, in both handlers.The PAL already contains predicates for exactly this, used by the hardware-signal handlers via
invoke_previous_action, and their comment documents the macOS behavior described above (signal.cpp, ~L404):The activation handlers do not route through
invoke_previous_action, so they never reach these checks. Reordering the chaining logic to use them resolves both cases:NativeAOT would need equivalents of these predicates. It may also be worth auditing other handlers that chain to a saved previous action for the same pattern.