-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Fix use-after-free in AggregateError creation for multi-error module loads #31399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,58 @@ | |
| expect(error!.message).not.toBe(message); | ||
| }); | ||
|
|
||
| test("AggregateError from a module with many build errors survives GC during its creation", async () => { | ||
| // A module that fails to load with multiple errors produces an | ||
| // AggregateError of BuildMessage objects. The wrappers used to be collected | ||
| // in a heap Vec the conservative GC scan cannot see, so a collection in the | ||
| // middle of creating them freed the native payloads and printing or | ||
| // inspecting the AggregateError afterwards was a use-after-free. | ||
| using dir = tempDir("aggregate-build-errors", { | ||
| "bad.js": Array.from({ length: 250 }, (_, i) => `let dup${i} = 1; let dup${i} = 2;`).join("\n"), | ||
| "index.js": ` | ||
| let aggregate; | ||
| try { | ||
| await import("./bad.js"); | ||
| throw new Error("expected import to fail"); | ||
| } catch (e) { | ||
| aggregate = e; | ||
| } | ||
| Bun.gc(true); | ||
| if (aggregate.constructor.name !== "AggregateError") { | ||
| throw new Error("expected AggregateError, got " + aggregate.constructor.name); | ||
| } | ||
| if (aggregate.errors.length < 2) { | ||
| throw new Error("expected multiple build errors, got " + aggregate.errors.length); | ||
| } | ||
| for (const sub of aggregate.errors) { | ||
| if (typeof sub.message !== "string") throw new Error("bad message"); | ||
| void sub.position; | ||
| } | ||
| console.error(aggregate); | ||
| console.log("OK", aggregate.errors.length); | ||
| `, | ||
| }); | ||
|
|
||
| await using proc = Bun.spawn({ | ||
| cmd: [bunExe(), "index.js"], | ||
| cwd: String(dir), | ||
| env: { | ||
| ...bunEnv, | ||
| // Make a collection land while the per-message error wrappers are being created. | ||
| BUN_JSC_collectContinuously: "true", | ||
| BUN_JSC_forceRAMSize: "1000000", | ||
| }, | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
|
|
||
| const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); | ||
|
|
||
| expect(stderr).not.toContain("AddressSanitizer"); | ||
|
Check warning on line 69 in test/js/bun/resolve/build-error.test.ts
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit: Extended reasoning...What this isThe new regression test asserts
Checking for the absence of an Why it is redundantWalk through the two CI configurations:
A grep across ImpactNone functionally — the test still passes/fails correctly with this line present. It is purely dead weight that contradicts an explicit repo testing convention. FixDelete line 69. The remaining |
||
| expect(stdout).toContain("OK 250"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 Heads-up: the same heap-
Vec<JSValue>UAF pattern this PR fixes still exists inLogJsc::to_jsatsrc/jsc/lib.rs:2146-2151—let mut errors_stack: Vec<JSValue> = …followed by a loop pushingmsg_to_js()(which calls the sameBuildMessage::create/ResolveMessage::create). That path is live viaBun.Transpiler(scanImports/transformSync),web_worker,TOMLObject,JSONCObject,css_internals, andfilesystem_routerfor any input with 2+ log messages. This is pre-existing code the PR doesn't touch, but it's the identical root cause with the identical 3-line fix ([JSValue::ZERO; 256]likesrc/ast_jsc/lib.rs:50already does), so it's probably worth applying here too.Extended reasoning...
What the bug is
This PR fixes a use-after-free in
process_fetch_log(src/jsc/VirtualMachine.rs) by replacing a heapVec<JSValue>with a stack[JSValue; 256], so JSC's conservative stack/register scan keeps freshly-createdBuildMessage/ResolveMessagewrappers rooted while later loop iterations callcreate()(which can trigger GC). The PR's new comment at lines 2561-2568 documents exactly why a heapVec<JSValue>is unsafe in this pattern.However, the identical buggy pattern remains in
src/jsc/lib.rs:2146-2151, inimpl LogJsc for bun_ast::Log → fn to_js:The local is even named
errors_stack(after the Zig spec's on-stack[256]JSValue), but it is a heapVec.msg_to_js(lib.rs:2131-2135) dispatches toBuildMessage::create/ResolveMessage::create— the exact same GC-allocating calls that motivated this PR's fix.Code path that triggers it
LogJsc::to_jsis a live path. Verified callers (viause crate::LogJsc+.to_js(...)):src/runtime/api/JSTranspiler.rs— lines 903, 1029, 1046, 1083, 1398, 1404, 1589, 1595, 1818, 1824 (e.g.transformSync/scanImportson input with multiple parse errors)src/jsc/web_worker.rs:1432, 1680src/runtime/api/TOMLObject.rs:25, 30, 49src/runtime/api/JSONCObject.rs:27src/css_jsc/css_internals.rs:226, 386src/runtime/api/filesystem_router.rs:262, 297, 311, 497, 523Any of these producing 2+ log messages enters the
_ =>branch and stages the wrappers in a heapVec.Why existing code doesn't prevent it
Quoting this PR's own new comment: "JSC's conservative scan only covers the stack/registers, so a heap
Vec<JSValue>would let earlier wrappers be swept (and their native payloads freed) before the AggregateError exists." That reasoning applies verbatim tolib.rs:2146. Untilcreate_aggregate_errorruns, the only references to the per-message wrappers live in theVec's heap buffer; theVecstruct on the stack holds a pointer/len/cap, and conservative scanning does not chase heap pointers.For contrast, the parallel implementation in
src/ast_jsc/lib.rs:49-50already does this correctly:and the Zig spec at
src/ast_jsc/logger_jsc.zig:44usesvar errors_stack: [256]jsc.JSValue. Only thesrc/jsc/lib.rsport diverged.Step-by-step proof
Concrete walk-through with
new Bun.Transpiler().transformSync(src)wheresrchas 3 parse errors, underBUN_JSC_collectContinuously=true:log.msgs.len() == 3→JSTranspiler.rscallslog.to_js(global, "...").to_jsenters the_ =>arm, allocatesVec<JSValue>with capacity 3 — the 24-byte buffer lives on the heap.msg_to_js→BuildMessage::createallocates a JS cell wrapping a 152-byteBox<BuildMessage>payload; the resultingJSValueis written tovec_buf[0]on the heap. No stack slot or register continues to hold it afterpushreturns.msg_to_js→BuildMessage::createallocates again. WithcollectContinuously, this triggers a full GC. The conservative scan walks the native stack and registers; it sees theVec's(ptr, len, cap)triple but does not dereferenceptr, so the wrapper from iteration 0 has zero visible references. It is swept;BuildMessageClass__finalizeruns and frees the 152-byteBox<BuildMessage>.create_aggregate_error(&errors_stack, …)readserrors_stack[0]— a staleJSValuepointing at a swept cell — and stores it in the AggregateError'serrorsarray.e.errors[0].message/.position, or when the runtime prints the error, it dereferences the freed 152-byte payload → heap-use-after-free, identical fingerprint to the one this PR fixes.Impact
Same as the bug this PR fixes: heap UAF reading freed
BuildMessage/ResolveMessagepayloads when constructing/printing anAggregateErrorfrom a multi-error log, reachable from several public APIs (Bun.Transpiler, TOML/JSONC parsing, CSS internals, FileSystemRouter, Workers). It requires a GC to land mid-loop, so it's rare in practice but deterministic underBUN_JSC_collectContinuously=true— exactly the conditions in this PR's new regression test.How to fix
Apply the same change as this PR:
This is pre-existing code that the PR does not touch, so I've marked it as such — but since the PR's commit title is "Keep multi-error AggregateError children GC-visible while they are created" and this is a second instance of that exact class with the same 3-line fix, it seems worth folding in here.