Fix use-after-free when a GC runs while aggregating module build errors#31377
Fix use-after-free when a GC runs while aggregating module build errors#31377robobun wants to merge 1 commit into
Conversation
… is created When a module fails to load with more than one log message, processFetchLog and Log.toJS wrap each BuildMessage/ResolveMessage in a JS cell and collect them into an AggregateError. The cells were accumulated in a heap Vec<JSValue>, which JSC's conservative stack scan cannot see, so a GC triggered by any allocation during aggregation could sweep them before the AggregateError adopted them. Printing the error afterwards then read the freed native BuildMessage (heap-use-after-free). Use a fixed stack array for the accumulation, matching the stack-rooted contract of createAggregateError.
|
Updated 12:06 AM PT - May 25th, 2026
❌ @robobun, your commit 9e706dc has 1 failures in
🧪 To try this PR locally: bunx bun-pr 31377That installs a local version of the PR into your bun-31377 --bun |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughThis PR optimizes aggregate error handling by replacing heap-allocated Vec with fixed-size stack arrays in two error construction paths, then adds a GC stress test to validate the optimization remains safe under memory pressure. ChangesStack allocation for aggregate errors
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
I didn't find any issues — the fix correctly restores the stack-rooted [JSValue; 256] from the Zig spec so the conservative scanner keeps the cells alive through create_aggregate_error — but GC-rooting changes in the module loader path are worth a maintainer's eyes.
Extended reasoning...
Overview
This PR fixes a fuzzer-found heap-use-after-free in two places that aggregate BuildMessage/ResolveMessage cells into an AggregateError: process_fetch_log in src/jsc/VirtualMachine.rs and Log::to_js in src/jsc/lib.rs. The Rust port had replaced the original Zig var errors_stack: [256]JSValue stack array with a heap Vec<JSValue>, which JSC's conservative stack scanner cannot see; a GC triggered by any allocation during aggregation could sweep the freshly created cells before the AggregateError adopted them. The fix switches back to a fixed [JSValue::UNDEFINED; 256] stack array, fills the first len slots via iter_mut().zip(...), and passes &errors_stack[..len] to create_aggregate_error. A regression test exercises both the main-thread and Worker paths under BUN_JSC_collectContinuously=1.
Security risks
None introduced — this is a memory-safety improvement that eliminates a UAF. No new attack surface, input parsing, or auth/permission logic is touched.
Level of scrutiny
Medium-high. The diff is small and mechanical, and it restores the exact shape of the Zig spec the comment already referenced. The zip bound and [..len] slicing are correct, and the stack array is borrowed across the create_aggregate_error call so it stays live in the frame. That said, this is core JSC/module-loader code where correctness depends on a subtle runtime invariant (conservative stack scanning finding values in a Rust local array), and the original bug was itself a subtle port regression in exactly this spot. A maintainer familiar with the JSC GC-rooting conventions should confirm this is the preferred pattern (vs. e.g. MarkedArgumentBuffer).
Other factors
The PR includes a targeted regression test that the author reports fails 10/10 on an unfixed ASAN build and passes 20/20 with the fix, and bun bd test passes locally. The bug-hunting system found no issues. The 2 KiB stack array matches the original Zig footprint and is not a stack-depth concern in these call paths.
|
Closing as a duplicate of #30671, which makes the same change (stack-rooting the For the record, this PR came from a separate fuzzer-found reproduction of the same bug: a # many-errors.js: ~60 lines that each produce a recoverable parse error
BUN_JSC_collectContinuously=1 bun-debug -e 'new Worker("./many-errors.js").addEventListener("error", () => {});'I verified the stack-array fix (as in #30671) resolves that reproduction as well. |
What does this PR do?
Fixes a fuzzer-found heap-use-after-free (fingerprint
Address:heap-use-after-free:bun-debug+0x11afa5ac, READ of size 1 at offset 144 of a freed 152-byteBuildMessage, on a Worker thread).When a module fails to load with more than one log message,
process_fetch_log(andLog::to_js) wrap eachBuildMessage/ResolveMessagein a JS cell and aggregate them into anAggregateError. The freshly created cells were accumulated in a heapVec<JSValue>, which JSC's conservative stack scan cannot see. Any allocation during the aggregation (anotherBuildMessage::create, or theJSArrayallocated insidecreateAggregateError) can trigger a GC that sweeps those cells, destroying their nativeBuildMessage(152 bytes). TheAggregateErrorthen holds dangling cells, and printing the error afterwards reads the freed native object:The Zig implementation this was ported from used
var errors_stack: [256]JSValue— a stack array — which was load-bearing:createAggregateError's contract expects a stack-rooted slice so the conservative scan keeps the cells alive. The port switched it to a heapVec, losing that protection.The fix restores the fixed stack array in both places that accumulate message cells before creating the
AggregateError:src/jsc/VirtualMachine.rsprocess_fetch_log(module loader fetch errors)src/jsc/lib.rsLog::to_js(workerflush_logsand otherLog.toJSusers)The fuzzer hit this on a Worker whose entry module failed to transpile (a Worker pointed at a non-JS file), where the worker thread aggregates and prints the error; the same window exists on the main thread.
How did you verify your code works?
BUN_JSC_collectContinuously=1hits the exact UAF 10/10 times on an unfixed ASAN debug build (and the same report appears on the main thread when running such a file directly).test/js/bun/resolve/build-error.test.tsthat runs both scenarios underBUN_JSC_collectContinuously=1. It fails on an unfixed ASAN debug build (AddressSanitizer report in stderr) and passes with this fix.bun bd test test/js/bun/resolve/build-error.test.ts: 3 pass, 0 fail.