From 0aa8a922dbafe4e5f1ba893f5256983fb8df4a64 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 08:02:59 +0000 Subject: [PATCH 1/7] Fix use-after-free in Bun.Transpiler().transform() error path TransformTask.run() parses on a worker thread using an arena allocator that is destroyed before returning. Parse error messages were allocated in that arena but not read until then() runs on the JS thread, at which point the memory may have been decommitted. Clone log messages into the default allocator before the arena is freed so the error text survives until the promise is rejected. --- src/runtime/api/JSTranspiler.zig | 5 ++++ .../transpiler-async-error-uaf.test.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 test/js/bun/transpiler/transpiler-async-error-uaf.test.ts diff --git a/src/runtime/api/JSTranspiler.zig b/src/runtime/api/JSTranspiler.zig index 7d231902efe..1df5cbb0745 100644 --- a/src/runtime/api/JSTranspiler.zig +++ b/src/runtime/api/JSTranspiler.zig @@ -494,6 +494,11 @@ pub const TransformTask = struct { var arena = MimallocArena.init(); defer arena.deinit(); + // Log message text is allocated in the arena but read later on the JS + // thread in then(). Deep-clone out of the arena before it is freed. + defer for (this.log.msgs.items) |*msg| { + msg.* = bun.handleOom(msg.clone(bun.default_allocator)); + }; const allocator = arena.allocator(); var ast_memory_allocator = bun.handleOom(allocator.create(JSAst.ASTMemoryAllocator)); diff --git a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts new file mode 100644 index 00000000000..3ca27ba2164 --- /dev/null +++ b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; + +test("async transform() rejection with parse errors does not crash", async () => { + // When Bun.Transpiler().transform() runs on the thread pool, parse errors are + // recorded using an arena allocator that is destroyed before the promise is + // settled on the main thread. Converting those errors to JS previously read + // the freed arena memory. Run enough iterations that mimalloc decommits the + // arena's pages so the dangling read faults. + let last: unknown; + for (let i = 0; i < 500; i++) { + new Bun.Transpiler().transform("a b c d").catch(e => { + last = e; + }); + if (i % 10 === 0) { + await new Promise(r => setImmediate(r)); + } + } + await new Promise(r => setImmediate(r)); + + expect(last).toBeInstanceOf(AggregateError); + const err = last as AggregateError; + expect(err.message).toBe("Transform failed"); + expect(err.errors[0].message).toBe('Expected ";" but found "b"'); +}); From 52b84763893fa014da6d697777447a9d921c0edf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 08:12:11 +0000 Subject: [PATCH 2/7] Await all pending transforms before asserting in regression test --- .../bun/transpiler/transpiler-async-error-uaf.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts index 3ca27ba2164..3a9a364854e 100644 --- a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts +++ b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts @@ -7,15 +7,18 @@ test("async transform() rejection with parse errors does not crash", async () => // the freed arena memory. Run enough iterations that mimalloc decommits the // arena's pages so the dangling read faults. let last: unknown; + const pending: Promise[] = []; for (let i = 0; i < 500; i++) { - new Bun.Transpiler().transform("a b c d").catch(e => { - last = e; - }); + pending.push( + new Bun.Transpiler().transform("a b c d").catch(e => { + last = e; + }), + ); if (i % 10 === 0) { await new Promise(r => setImmediate(r)); } } - await new Promise(r => setImmediate(r)); + await Promise.allSettled(pending); expect(last).toBeInstanceOf(AggregateError); const err = last as AggregateError; From 49ebc047d20d6b3de9f850a5f19d26507c2b04f8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 09:33:49 +0000 Subject: [PATCH 3/7] Root BuildMessage cells on the stack in Log.to_js After merging main (which replaced the Zig runtime with Rust), the original Zig-side arena-text UAF no longer applies. The Rust port of Log.to_js has a different bug in the same path: it collects the per-message BuildMessage/ResolveMessage JS cells in a heap Vec, so the first cell can be swept while allocating the second, and the resulting AggregateError visits a zapped StructureID. Mirror the Zig spec's stack `[256]JSValue` so the conservative stack scan keeps every cell rooted until create_aggregate_error runs. Drop the now-dead JSTranspiler.zig change and reword the regression test comment to describe the actual failure mode. --- src/jsc/lib.rs | 13 ++++++++----- src/runtime/api/JSTranspiler.zig | 5 ----- .../transpiler/transpiler-async-error-uaf.test.ts | 11 ++++++----- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index e64f541b8b9..6caa8f737f3 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -2118,17 +2118,20 @@ impl LogJsc for bun_ast::Log { fn to_js(&self, global: &JSGlobalObject, message: &str) -> JsResult { let msgs = &self.msgs; // Spec: `@min(msgs.len, errors_stack.len)` — errors_stack is `[256]JSValue`. - let count = msgs.len().min(256); + // Must be a stack array so the conservative GC scan keeps the freshly + // allocated BuildMessage/ResolveMessage cells alive while we allocate + // the next one; a heap `Vec` would leave them unrooted. + let mut errors_stack = [JSValue::UNDEFINED; 256]; + let count = msgs.len().min(errors_stack.len()); match count { 0 => Ok(JSValue::UNDEFINED), 1 => msg_to_js(&msgs[0], global), _ => { - let mut errors_stack: Vec = Vec::with_capacity(count); - for msg in &msgs[0..count] { - errors_stack.push(msg_to_js(msg, global)?); + for (i, msg) in msgs[0..count].iter().enumerate() { + errors_stack[i] = msg_to_js(msg, global)?; } let out = bun_core::ZigString::init(message.as_bytes()); - global.create_aggregate_error(&errors_stack, &out) + global.create_aggregate_error(&errors_stack[0..count], &out) } } } diff --git a/src/runtime/api/JSTranspiler.zig b/src/runtime/api/JSTranspiler.zig index 1df5cbb0745..7d231902efe 100644 --- a/src/runtime/api/JSTranspiler.zig +++ b/src/runtime/api/JSTranspiler.zig @@ -494,11 +494,6 @@ pub const TransformTask = struct { var arena = MimallocArena.init(); defer arena.deinit(); - // Log message text is allocated in the arena but read later on the JS - // thread in then(). Deep-clone out of the arena before it is freed. - defer for (this.log.msgs.items) |*msg| { - msg.* = bun.handleOom(msg.clone(bun.default_allocator)); - }; const allocator = arena.allocator(); var ast_memory_allocator = bun.handleOom(allocator.create(JSAst.ASTMemoryAllocator)); diff --git a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts index 3a9a364854e..57a2d3b3aeb 100644 --- a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts +++ b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts @@ -1,11 +1,12 @@ import { expect, test } from "bun:test"; test("async transform() rejection with parse errors does not crash", async () => { - // When Bun.Transpiler().transform() runs on the thread pool, parse errors are - // recorded using an arena allocator that is destroyed before the promise is - // settled on the main thread. Converting those errors to JS previously read - // the freed arena memory. Run enough iterations that mimalloc decommits the - // arena's pages so the dangling read faults. + // When a failing Bun.Transpiler().transform() rejects, Log.to_js builds an + // AggregateError by allocating one BuildMessage JS cell per log entry. Those + // cells were previously collected in a heap Vec, so the first cell + // could be swept while allocating the second, leaving a zapped cell in the + // aggregate and tripping the StructureID assertion during GC. Run enough + // rejecting transforms that the allocation-triggered GC hits that window. let last: unknown; const pending: Promise[] = []; for (let i = 0; i < 500; i++) { From f96bca40443d5bb79ece9efb306792c8d4b8f30b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 10:36:44 +0000 Subject: [PATCH 4/7] Apply the same stack-array fix to process_fetch_log The module-loader multi-error path in VirtualMachine.rs had the same heap Vec pattern, leaving BuildMessage/ResolveMessage cells unrooted between allocations. Mirror the Zig spec's stack [256]JSValue here as well. --- src/jsc/VirtualMachine.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index e2fb4c99a64..df98a147438 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2499,14 +2499,15 @@ pub fn process_fetch_log( } _ => { - // Spec caps at 256 (`var errors_stack: [256]JSValue`). PERF(port): - // was inline switch — Zig stack-allocated; we heap-allocate the - // exact `len` since `JSValue` is a thin u64 and 256 * 8 B = 2 KiB - // is fine either way, but `Vec` avoids the uninit-array dance. - let len = log.msgs.len().min(256); - let mut errors: alloc::vec::Vec = alloc::vec::Vec::with_capacity(len); - for msg in log.msgs.drain(..len) { - let v = match msg.metadata { + // Spec caps at 256 (`var errors_stack: [256]JSValue`). Must be a + // stack array so the conservative GC scan keeps each freshly + // allocated BuildMessage/ResolveMessage cell alive while we + // allocate the next one; a heap `Vec` leaves them + // unrooted and they can be swept mid-loop. + let mut errors = [JSValue::UNDEFINED; 256]; + let len = log.msgs.len().min(errors.len()); + for (i, msg) in log.msgs.drain(..len).enumerate() { + errors[i] = match msg.metadata { bun_ast::Metadata::Build => take(BuildMessage::create(global_this, msg)), bun_ast::Metadata::Resolve(_) => take(ResolveMessage::create( global_this, @@ -2514,8 +2515,8 @@ pub fn process_fetch_log( referrer_utf8.slice(), )), }; - errors.push(v); } + let errors = &errors[..len]; // C++ `Zig::toString` does `createWithoutCopying`, so the buffer // must outlive the AggregateError. Mark it global so JSC adopts it @@ -2529,7 +2530,7 @@ pub fn process_fetch_log( message.mark_global(); *ret = ErrorableResolvedSource::err( err, - take(global_this.create_aggregate_error(&errors, &message)), + take(global_this.create_aggregate_error(errors, &message)), ); } } From 434fcd9236e21002d8e67e7d1b0ab7a0bf5a7e6a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 14 May 2026 11:48:27 +0000 Subject: [PATCH 5/7] Make regression test deterministic via subprocess with GC stress Spawn a subprocess with BUN_JSC_useZombieMode + collectContinuously so the collector reliably races the BuildMessage allocation loop in Log.to_js. With 256 errors per transformSync call (capped from 300 input lines) the unrooted heap Vec is swept mid-loop every run: 10/10 fail on both debug/ASAN and release before the fix, 10/10 pass after. --- .../transpiler-async-error-uaf.test.ts | 79 +++++++++++++------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts index 57a2d3b3aeb..6f7973eba06 100644 --- a/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts +++ b/test/js/bun/transpiler/transpiler-async-error-uaf.test.ts @@ -1,28 +1,61 @@ +// Log.to_js (used by Bun.Transpiler().transform/transformSync when rejecting +// with parse errors, and by the module loader via process_fetch_log) builds +// an AggregateError by allocating one BuildMessage JS cell per log entry. The +// Rust port collected those cells in a heap Vec, which the +// conservative GC scan does not see, so an earlier cell could be swept while +// allocating a later one and the AggregateError would reference a zapped +// StructureID. +// +// useZombieMode scribbles 0xbadbeef0 over swept cells so the dangling access +// manifests deterministically; collectContinuously races the collector against +// the allocation loop so it reliably sweeps mid-loop. import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; -test("async transform() rejection with parse errors does not crash", async () => { - // When a failing Bun.Transpiler().transform() rejects, Log.to_js builds an - // AggregateError by allocating one BuildMessage JS cell per log entry. Those - // cells were previously collected in a heap Vec, so the first cell - // could be swept while allocating the second, leaving a zapped cell in the - // aggregate and tripping the StructureID assertion during GC. Run enough - // rejecting transforms that the allocation-triggered GC hits that window. - let last: unknown; - const pending: Promise[] = []; - for (let i = 0; i < 500; i++) { - pending.push( - new Bun.Transpiler().transform("a b c d").catch(e => { - last = e; - }), - ); - if (i % 10 === 0) { - await new Promise(r => setImmediate(r)); +const fixture = ` +const src = Array.from({ length: 300 }, () => "a b").join("\\n"); +const t = new Bun.Transpiler(); +for (let i = 0; i < 20; i++) { + let err; + try { t.transformSync(src); } catch (e) { err = e; } + if (!(err instanceof AggregateError)) throw new Error("not AggregateError: " + err); + if (err.errors.length !== 256) throw new Error("wrong count: " + err.errors.length); + for (const m of err.errors) { + const msg = m.message; + if (msg !== 'Expected ";" but found "b"') { + throw new Error("corrupt BuildMessage: " + JSON.stringify(typeof msg) + " " + String(msg).slice(0, 80)); } } - await Promise.allSettled(pending); +} +console.log("OK"); +`; - expect(last).toBeInstanceOf(AggregateError); - const err = last as AggregateError; - expect(err.message).toBe("Transform failed"); - expect(err.errors[0].message).toBe('Expected ";" but found "b"'); -}); +test("Log.to_js roots BuildMessage cells across allocation", async () => { + using dir = tempDir("log-to-js-gc-root", { + "fixture.js": fixture, + }); + + // Windows + collectContinuously is prohibitively slow in CI and the code + // path is platform-agnostic, so rely on zombie mode alone there. + const gcEnv: Record = { + ...bunEnv, + BUN_JSC_useZombieMode: "1", + }; + if (!isWindows) gcEnv.BUN_JSC_collectContinuously = "1"; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.js"], + env: gcEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + if (exitCode !== 0) { + expect(stderr).toBe(""); + } + expect(stdout.trim()).toBe("OK"); + expect(exitCode).toBe(0); +}, 60_000); From 45e8e722b970f9da2022a38bd9b4f24b6feb8f69 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 14:04:20 +0000 Subject: [PATCH 6/7] Add regression test for the module-loader AggregateError path --- test/js/bun/resolve/build-error.test.ts | 32 ++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/js/bun/resolve/build-error.test.ts b/test/js/bun/resolve/build-error.test.ts index a623aa505e9..8398ea79767 100644 --- a/test/js/bun/resolve/build-error.test.ts +++ b/test/js/bun/resolve/build-error.test.ts @@ -1,4 +1,4 @@ -import { tempDir } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "node:path"; test("BuildError is modifiable", async () => { @@ -19,6 +19,36 @@ test("BuildError is modifiable", async () => { expect(error!.message).not.toBe(message); }); +test("importing a module with many build errors does not crash while reporting them", async () => { + // The AggregateError for a failed module build is assembled from one + // BuildMessage wrapper per log message (process_fetch_log). Those wrappers + // used to be collected only in a heap Vec (invisible to the conservative GC + // scan), so a GC during the loop could finalize earlier wrappers and free + // their native BuildMessage before the AggregateError was created, causing + // a use-after-free when the unhandled rejection was printed. + using dir = tempDir("build-error-many", { + // 40 declarations + 80 redeclarations -> ~80 build errors in one module + "bad.js": Array.from({ length: 120 }, (_, i) => `const x${i % 40} = 1;`).join("\n"), + "index.js": `import("./bad.js");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + cwd: String(dir), + env: { ...bunEnv, BUN_JSC_collectContinuously: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + // Every error in the AggregateError should have been printed. + expect(stderr).toContain('"x0" has already been declared'); + expect(stderr).toContain('"x39" has already been declared'); + // Unhandled rejection -> clean exit with code 1, not a crash. + expect(exitCode).toBe(1); +}); + test("BuildMessage finalize frees with the same allocator it was created with", async () => { // BuildMessage.create() clones the message with the passed allocator // but finalize() was freeing it with bun.default_allocator and never From 8ddc9ac1dd16d2fb69a4d66f6de9b6051ebcd13f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 23 May 2026 14:52:24 +0000 Subject: [PATCH 7/7] Gate collectContinuously behind !isWindows in build-error test Match the repo convention (and this PR's sibling test): continuous collection is prohibitively slow on Windows CI and the code path is platform-agnostic, so rely on zombie mode alone there. --- test/js/bun/resolve/build-error.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/js/bun/resolve/build-error.test.ts b/test/js/bun/resolve/build-error.test.ts index 8398ea79767..22b9d31a806 100644 --- a/test/js/bun/resolve/build-error.test.ts +++ b/test/js/bun/resolve/build-error.test.ts @@ -1,4 +1,4 @@ -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "node:path"; test("BuildError is modifiable", async () => { @@ -32,10 +32,18 @@ test("importing a module with many build errors does not crash while reporting t "index.js": `import("./bad.js");`, }); + // Windows + collectContinuously is prohibitively slow in CI and the code + // path is platform-agnostic, so rely on zombie mode alone there. + const gcEnv: Record = { + ...bunEnv, + BUN_JSC_useZombieMode: "1", + }; + if (!isWindows) gcEnv.BUN_JSC_collectContinuously = "1"; + await using proc = Bun.spawn({ cmd: [bunExe(), "index.js"], cwd: String(dir), - env: { ...bunEnv, BUN_JSC_collectContinuously: "1" }, + env: gcEnv, stdout: "pipe", stderr: "pipe", });