Skip to content

transpiler: fix use-after-free in async transform() error path#30744

Closed
robobun wants to merge 1 commit into
mainfrom
farm/893676da/fix-transpiler-async-error-uaf
Closed

transpiler: fix use-after-free in async transform() error path#30744
robobun wants to merge 1 commit into
mainfrom
farm/893676da/fix-transpiler-async-error-uaf

Conversation

@robobun

@robobun robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.Transpiler#transform() (async) could read freed arena memory when the parse produced errors, tripping an ASAN use-after-poison in debug builds.

Why

TransformTask.run() runs the parser on a worker thread using a local MimallocArena as the transpiler allocator, then frees the arena on return. The parser/lexer allocate error-message text and notes with that arena allocator. Those Msgs are shallow-copied into TransformTask.log (only the outer msgs list uses bun.default_allocator).

When then() later runs on the JS thread and calls this.log.toJS()BuildMessage.createMsg.cloneData.clone, it dupes the arena-backed text after the arena is already freed.

#1 mem.Allocator.dupe
#2 logger.Data.clone              logger.zig:232
#3 logger.Msg.clone               logger.zig:416
#4 BuildMessage.create            BuildMessage.zig:54
#5 logger_jsc.logToJS             logger_jsc.zig:59
#6 JSTranspiler.TransformTask.then JSTranspiler.zig:572

How

Add a defer in TransformTask.run() (ordered before arena.deinit()) that deep-copies any log messages into bun.default_allocator via cloneToWithRecycled(&cloned, true), so then() reads stable memory.

Repro

const t = new Bun.Transpiler();
const ps = [];
for (let i = 0; i < 200; i++) {
  ps.push(t.transform("const @@@ x = {{{{{ !!!! import export from }}}}}").catch(e => {
    String(e);
    for (const err of e?.errors ?? []) String(err);
  }));
}
await Promise.all(ps);

Found by Fuzzilli.

TransformTask.run() parses with an arena allocator on a worker thread and
frees the arena on return. Parser/lexer error messages allocate their text
and notes in that arena; the Msg structs were shallow-copied into this.log,
so when then() ran on the JS thread and called log.toJS() -> Msg.clone() it
read freed arena memory (ASAN use-after-poison).

Deep-copy the log into bun.default_allocator in a defer that runs before
arena.deinit() so the rejection path sees stable strings.
@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:50 PM PT - May 14th, 2026

@robobun, your commit 627a54e has 15 failures in Build #54469 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30744

That installs a local version of the PR into your bun-30744 executable, so you can run:

bun-30744 --bun

@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 32647656-390a-4d46-be2b-5b681ecc2086

📥 Commits

Reviewing files that changed from the base of the PR and between 175f62a and 627a54e.

📒 Files selected for processing (2)
  • src/runtime/api/JSTranspiler.zig
  • test/js/bun/transpiler/transpiler-async-error-uaf.test.ts

Walkthrough

This PR fixes a use-after-free bug in the Bun transpiler's async error handling. When TransformTask rejects with a transpilation error, its deferred log-cloning operation copies log messages to persistent memory before the task's arena is freed, allowing the JS thread to safely access rejection details. A regression test validates the fix by running 200 concurrent failing transforms and confirming clean exit.

Changes

Transpiler UAF Prevention

Layer / File(s) Summary
Log memory cloning in TransformTask
src/runtime/api/JSTranspiler.zig
TransformTask.run defers cloning the logger.Log from the per-task arena to bun.default_allocator, then clears arena-owned message references so the JS thread can safely read log contents after rejection without accessing freed memory.
Regression test for async rejection memory safety
test/js/bun/transpiler/transpiler-async-error-uaf.test.ts
A regression test spawns a child Bun process running 200 concurrent failing transform() calls, validates rejections are captured in an AggregateError, confirms stderr is empty, and asserts a clean exit code.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing a use-after-free bug in the async transform() error path of the transpiler.
Description check ✅ Passed The PR description covers both required sections: 'What' explains the issue and 'Why' provides detailed context; however, the 'How' section describes the implementation rather than verification steps as the template requests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix use-after-free in Bun.Transpiler async transform() errors #29958 - Same use-after-free fix: deep-copies arena-allocated log messages into default_allocator before arena teardown in async transform() error path
  2. JSTranspiler: fix use-after-free of log messages in async transform() #30180 - Same use-after-free fix: clones log messages via cloneToWithRecycled() before arena is destroyed in TransformTask.run()
  3. Fix use-after-free in Bun.Transpiler async transform() errors #30263 - Same use-after-free fix: deep-clones accumulated log messages into default_allocator before arena teardown in async transform()
  4. Fix use-after-free in Bun.Transpiler.transform() error reporting #30309 - Same use-after-free fix: clones each log message into default_allocator in a defer before arena.deinit() in async transform()

🤖 Generated with Claude Code

@robobun

robobun commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29958 (and #30180, #30263, #30309). #29958 is the oldest and also frees the cloned buffers in deinit(), so closing this in favor of that one.

@robobun robobun closed this May 14, 2026
@robobun robobun deleted the farm/893676da/fix-transpiler-async-error-uaf branch May 14, 2026 22:49
Comment on lines +498 to +507
// Log message text/notes may be allocated in the arena by the parser.
// Deep-copy them into default_allocator before the arena is freed so
// `then()` can safely read them on the JS thread.
defer if (this.log.msgs.items.len > 0) {
var cloned = logger.Log.init(bun.default_allocator);
cloned.level = this.log.level;
this.log.cloneToWithRecycled(&cloned, true) catch {};
this.log.msgs.clearAndFree();
this.log = cloned;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This fix is applied only to src/runtime/api/JSTranspiler.zig, which per CLAUDE.md is a non-compiled porting reference — the shipped implementation is src/runtime/api/JSTranspiler.rs. TransformTask::run() in the .rs file (lines 771-863) has the identical arena-then-drop pattern (it even launders the arena to 'static via unsafe { bun_ptr::detach_lifetime_ref(&arena) } at :787) and is untouched by this PR, so the UAF described in the title is not actually fixed in the binary. The clone-out-of-arena logic needs to be ported into TransformTask::run() in JSTranspiler.rs before arena drops.

Extended reasoning...

What's wrong

CLAUDE.md:141 is explicit: ".zig files alongside many .rs files … are the original Zig implementation, kept only as a porting reference — they are not compiled and not shipped. New code goes in .rs. … Never add new behavior to a .zig file." src/CLAUDE.md repeats this, there is no top-level build.zig, and the build is driven by Cargo (scripts/build/rust.ts produces libbun_rust.a). The active implementation is src/runtime/api/JSTranspiler.rs (line 1814: // ported from: src/runtime/api/JSTranspiler.zig), and src/runtime/dispatch.rs imports crate::api::JSTranspiler::AsyncTransformTask from the Rust module — that is what runs at runtime.

This PR's only source change is the new defer block in JSTranspiler.zig. Since that file is not compiled into the binary, the change is a no-op for shipped behavior, and the PR title "fix use-after-free in async transform() error path" is not satisfied.

The Rust path has the same shape

TransformTask::run() in JSTranspiler.rs:771-863 mirrors the Zig structure exactly:

  • :776 — let arena = Arena::new(); (drops at end of run())
  • :784-787 — // SAFETY: arena outlives every use through self.transpiler in this fn body followed by self.transpiler.set_arena(unsafe { bun_ptr::detach_lifetime_ref(&arena) }), laundering the borrow to 'static
  • :824 — self.transpiler.parse(parse_options, None) runs the parser/lexer with that arena
  • no clone of self.log before the function returns and arena drops
  • :887 in then()self.log.to_js(self.global, "Transform failed") reads the log on the JS thread

The SAFETY comment's invariant ("outlives every use … in this fn body") is precisely the assumption that breaks if any Msg payload escapes via self.log into then().

Why existing Rust code doesn't obviously prevent it

bun_ast::Location stores file and line_text as Cow<'static, [u8]>, and the PORT NOTE at src/ast/lib.rs:780-783 explicitly states the "Borrowed arm may carry a lifetime-erased view into Source.contents (see init_or_null, css_parser.rs, error.rs, JSBundler.rs)". Location::init (:883-893) and init_or_null (:897-937) both construct Cow::Borrowed views. The hand-written Clone impl deep-dupes specifically because the derived clone "would re-borrow that pointer". So the Rust Log is designed under the same hazard model as Zig: borrowed slices that must be deep-copied before their backing storage goes away.

One nuance: some Rust call sites already own their bytes (e.g. init_or_null at :934 does Cow::Owned(...to_vec()) for line_text, and Data.text from alloc_print is heap-owned), so the Rust path may dangle on fewer fields than Zig did. But Location.file at :900/:917 is still Cow::Borrowed(source.path.text), and Location::init accepts borrowed line_text. Given the explicit detach_lifetime_ref lifetime erasure and the PORT NOTE acknowledging borrowed arena views, the Rust side cannot be assumed safe without the same clone-before-drop step.

Step-by-step proof that the fix doesn't reach the binary

  1. User calls new Bun.Transpiler().transform("const @@@ …").
  2. Dispatch goes through src/runtime/dispatch.rscrate::api::JSTranspiler (the Rust module). JSTranspiler.zig is not part of the crate graph.
  3. TransformTask::create (Rust) builds the task; worker thread calls TransformTask::run() at JSTranspiler.rs:771.
  4. run() creates arena, launders it to 'static, parses; lexer pushes error Msgs into self.log whose Location fields may Cow::Borrowed arena-/source-backed bytes (per lib.rs:780-783).
  5. run() returns → arena drops. There is no clone step — the new Zig defer is in a file that was never compiled.
  6. JS thread runs then()self.log.to_js(...) at :887 → BuildMessage::createMsg::cloneLocation::clone reads the (potentially freed/lifetime-erased) bytes.

The new test may pass anyway because Rust's bump arena does not poison freed pages the way Zig's debug MimallocArena does, so a green CI run here is not evidence the UAF is gone.

Impact

The PR is effectively a doc/test-only change against the shipped binary. Async transform() rejections in release builds remain exposed to the same dangling-read described in the PR body, and a future arena-poisoning/ASAN run on the Rust path would still trip.

Fix

Port the same logic into TransformTask::run() in src/runtime/api/JSTranspiler.rs — before arena drops (i.e. before returning), deep-clone self.log into the global allocator (the Rust analogue of cloneToWithRecycled, e.g. iterate self.log.msgs and Msg::clone() each into a fresh Log, then swap). Optionally also revert/drop the .zig edit since per CLAUDE.md new behavior should not be added there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant