diff --git a/src/bun.js/bindings/FuzzilliREPRL.cpp b/src/bun.js/bindings/FuzzilliREPRL.cpp index a6e93d2b6400..99f98c30db40 100644 --- a/src/bun.js/bindings/FuzzilliREPRL.cpp +++ b/src/bun.js/bindings/FuzzilliREPRL.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -19,8 +20,14 @@ extern "C" { -// Signal handler to ensure output is flushed before crash -static void fuzzilliSignalHandler(int sig) +// Previous handlers (JSC's jscSignalHandler, which itself chains to ASAN's +// handler). We flush stdio so Fuzzilli captures buffered output, then forward +// to the prior handler so JSC's VMTraps/WASM fault handling keeps working and +// ASAN can print its report. Using signal()+SIG_DFL here used to swallow both, +// leaving crash reports with just "TERMSIG: 11" and no stack. +static struct sigaction fuzzilliOldActions[NSIG]; + +static void fuzzilliSignalHandler(int sig, siginfo_t* info, void* ucontext) { // Flush all output fflush(stdout); @@ -28,11 +35,34 @@ static void fuzzilliSignalHandler(int sig) fsync(STDOUT_FILENO); fsync(STDERR_FILENO); - // Re-raise the signal with default handler + struct sigaction& old = fuzzilliOldActions[sig]; + if (old.sa_flags & SA_SIGINFO) { + if (old.sa_sigaction) { + old.sa_sigaction(sig, info, ucontext); + return; + } + } else if (old.sa_handler && old.sa_handler != SIG_DFL && old.sa_handler != SIG_IGN) { + old.sa_handler(sig); + return; + } + + // No previous handler: re-raise with default handler. signal(sig, SIG_DFL); raise(sig); } +static void installFuzzilliSignalHandler(int sig) +{ + struct sigaction action; + action.sa_sigaction = fuzzilliSignalHandler; + sigfillset(&action.sa_mask); + // SA_ONSTACK so stack-overflow faults are delivered on the sigaltstack + // ASAN/JSC already set up; otherwise the kernel can't push the frame and + // the process dies with a bare SIGSEGV before we reach the chain. + action.sa_flags = SA_SIGINFO | SA_ONSTACK; + sigaction(sig, &action, &fuzzilliOldActions[sig]); +} + // Implementation of the global fuzzilli() function for Bun // This function is used by Fuzzilli to: // 1. Test crash detection with fuzzilli('FUZZILLI_CRASH', type) @@ -259,12 +289,20 @@ void Bun__REPRL__registerFuzzilliFunctions(Zig::GlobalObject* globalObject) { JSC::VM& vm = globalObject->vm(); - // Install signal handlers to ensure output is flushed before crashes - // This is important for ASAN output to be captured - signal(SIGABRT, fuzzilliSignalHandler); - signal(SIGSEGV, fuzzilliSignalHandler); - signal(SIGILL, fuzzilliSignalHandler); - signal(SIGFPE, fuzzilliSignalHandler); + // Install signal handlers to ensure output is flushed before crashes. + // Chain to the previous handler (JSC's jscSignalHandler → ASAN) so + // VMTraps/WASM fault handling keeps working and ASAN reports are printed. + // Signal dispositions are process-wide; this function runs once per + // GlobalObject (main thread, macros, and every Worker), so guard with a + // once-flag — re-installing would save ourselves into fuzzilliOldActions + // and recurse on the next signal. + static std::once_flag installOnce; + std::call_once(installOnce, [] { + installFuzzilliSignalHandler(SIGABRT); + installFuzzilliSignalHandler(SIGSEGV); + installFuzzilliSignalHandler(SIGILL); + installFuzzilliSignalHandler(SIGFPE); + }); globalObject->putDirectNativeFunction( vm, diff --git a/test/internal/fuzzilli-signal-chain.test.ts b/test/internal/fuzzilli-signal-chain.test.ts new file mode 100644 index 000000000000..75de2e4cda37 --- /dev/null +++ b/test/internal/fuzzilli-signal-chain.test.ts @@ -0,0 +1,90 @@ +// The fuzzilli REPRL setup installs SIGSEGV/SIGILL/SIGFPE/SIGABRT handlers so +// buffered stdio is flushed before a crash. Those handlers must chain to the +// previously-installed handler (WTF::jscSignalHandler → ASAN) instead of +// re-raising with SIG_DFL, otherwise: +// • ASAN never prints a report for null derefs / wild accesses, so every +// signal-based fuzzer crash shows up as a bare "TERMSIG: 11". +// • JSC's signal-based VMTraps and WASM fault handling are broken, turning +// intentional JIT trap breakpoints into hard process crashes. +// +// Only runs when the binary under test was built with FUZZILLI_ENABLED (the +// `fuzzilli()` global exists). Normal debug/release builds skip. + +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +declare const fuzzilli: unknown; +const isFuzzilliBuild = typeof fuzzilli === "function"; + +// Skip symbolization so ASAN writes its report and exits immediately instead +// of shelling out to llvm-symbolizer for every frame (several seconds on the +// fuzz binary). The presence of "AddressSanitizer: SEGV" is enough to prove +// the handler chain reached ASAN. allow_user_segv_handler keeps JSC from +// disabling its own fault handling when it sees ASAN_OPTIONS is set. +const fastCrashEnv = { + ...bunEnv, + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "allow_user_segv_handler=1", "symbolize=0"].filter(Boolean).join(":"), +}; + +test.skipIf(!isFuzzilliBuild)("fuzzilli crash signal handler chains to JSC/ASAN for SIGSEGV", async () => { + // FUZZILLI_CRASH type 5 writes to a volatile null pointer. With a working + // handler chain the fault reaches jscSignalHandler → ASAN and we get an + // "AddressSanitizer: SEGV" report on stderr; with signal()+SIG_DFL the + // process dies with stderr containing only the [COV] banner. + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", 'fuzzilli("FUZZILLI_CRASH", 5);'], + env: fastCrashEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("FUZZILLI_CRASH: 5"); + expect(stderr).toContain("AddressSanitizer: SEGV"); + // ASAN aborts after printing; a bare SIGSEGV would surface as signalCode + // "SIGSEGV" with nothing useful on stderr. + expect(proc.signalCode).not.toBe("SIGSEGV"); +}); + +test.skipIf(!isFuzzilliBuild)("fuzzilli crash signal handler survives Worker global creation", async () => { + // Bun__REPRL__registerFuzzilliFunctions runs for every GlobalObject (main, + // macros, Workers). Without a once-guard the Worker's call re-installs the + // handler, saving fuzzilliSignalHandler itself into fuzzilliOldActions and + // turning the next SIGSEGV into infinite self-recursion → bare TERMSIG 11. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + 'const w = new Worker("data:text/javascript,postMessage(0)"); await new Promise(r => (w.onmessage = r)); w.terminate(); fuzzilli("FUZZILLI_CRASH", 5);', + ], + env: fastCrashEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("FUZZILLI_CRASH: 5"); + expect(stderr).toContain("AddressSanitizer: SEGV"); + expect(proc.signalCode).not.toBe("SIGSEGV"); +}); + +test.skipIf(!isFuzzilliBuild)("fuzzilli crash signal handler still terminates for SIGABRT", async () => { + // FUZZILLI_CRASH type 0 is std::abort(). No JSC/ASAN handler is registered + // for SIGABRT by default, so the chain falls through to SIG_DFL and the + // process terminates with SIGABRT — Fuzzilli's crash detection relies on + // this. + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", 'fuzzilli("FUZZILLI_CRASH", 0);'], + env: fastCrashEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("FUZZILLI_CRASH: 0"); + expect(exitCode).not.toBe(0); + expect(proc.signalCode).toBe("SIGABRT"); +});