Skip to content

Fix use-after-free in Bun.Transpiler.transform() error path#30027

Closed
robobun wants to merge 1 commit into
mainfrom
farm/d517fa80/fix-transform-task-uaf
Closed

Fix use-after-free in Bun.Transpiler.transform() error path#30027
robobun wants to merge 1 commit into
mainfrom
farm/d517fa80/fix-transform-task-uaf

Conversation

@robobun

@robobun robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a use-after-free in the async Bun.Transpiler().transform() error path found by Fuzzilli (fingerprint Address:use-after-poison:bun-debug+0x827621e).

TransformTask.run() executes the parser on a worker thread using a MimallocArena that is destroyed at the end of run(). When parsing fails, the lexer/parser format their error messages with allocPrint using that arena allocator, and the resulting Msg.data.text slices are appended (shallowly, via appendToMaybeRecycled) into this.log.

Later, then() runs on the JS thread and calls this.log.toJS(), which clones each message. At that point the arena is already gone, so Data.clone's allocator.dupe(u8, this.text) reads freed memory. Whether this crashes depends on whether the arena's pages have been handed back out and re-poisoned, which is why it was flaky.

The fix: before tearing down the arena, deep-clone any accumulated log messages into bun.default_allocator so they outlive the worker thread.

How did you verify your code works?

Repro (crashed ~8/30 runs under ASAN before, 0/50 after):

const t = new Bun.Transpiler();
const p = t.transform("const x = 1 ++ 2 ++ 3;").catch(e => e);
for (let i = 0; i < 10000; i++) Buffer.alloc(64);
Bun.gc(true);
await p;

Added a regression test in test/bundler/transpiler/transpiler.test.js that asserts the rejection message is intact after allocation churn. It crashes the unfixed debug build at the same logger.Data.clone → Allocator.dupe frame the fuzzer hit and passes with the fix.

TransformTask.run() runs the parser on a worker thread with a
MimallocArena, then destroys the arena before returning. Parser/lexer
error messages had their text allocated from that arena, so when the
task's then() ran on the JS thread and tried to clone the messages
into a BuildMessage, it read from freed memory.

Clone the log messages with the default allocator before the arena is
destroyed.
@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:55 AM PT - May 1st, 2026

@robobun, your commit 749c06dfe13b84032f7c33e3e55761977d647ed6 passed in Build #49598! 🎉


🧪   To try this PR locally:

bunx bun-pr 30027

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

bun-30027 --bun

@github-actions github-actions Bot added the claude label May 1, 2026
@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

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: 639de1e1-4465-4648-bd8b-c6f17c2dcb9c

📥 Commits

Reviewing files that changed from the base of the PR and between ba437f0 and 749c06d.

📒 Files selected for processing (2)
  • src/bun.js/api/JSTranspiler.zig
  • test/bundler/transpiler/transpiler.test.js

Walkthrough

This PR fixes a memory management issue in JSTranspiler where log messages were being accessed after their arena was deallocated. A defer handler now clones log entries into a persistent allocator, and a regression test verifies the fix under allocator/GC stress conditions.

Changes

Cohort / File(s) Summary
JSTranspiler Arena Lifetime Fix
src/bun.js/api/JSTranspiler.zig, test/bundler/transpiler/transpiler.test.js
Clones log message entries into bun.default_allocator in a defer handler before the parsing/printing arena is deinitialized, preventing use-after-free in then(). Includes regression test that stresses allocator/GC with repeated transform calls and buffer allocation churn to verify the fix.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix use-after-free in Bun.Transpiler.transform() error path' clearly and specifically describes the main change: fixing a memory safety bug in error handling.
Description check ✅ Passed The PR description fully addresses both required template sections with detailed technical context, root cause analysis, fix explanation, and verification steps including reproduction code and test coverage.
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.


Review rate limit: 1/5 review remaining, refill in 41 minutes and 3 seconds.

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

@github-actions

github-actions Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Fix use-after-free in Bun.Transpiler async transform() errors #29958 - Also fixes the same use-after-free in TransformTask.run() where log message text slices from the MimallocArena are read after the arena is destroyed, using a different cloning approach

🤖 Generated with Claude Code

@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29958 which already has the more complete fix (tracks and frees the cloned buffers in TransformTask.deinit(), per review feedback there).

@robobun robobun closed this May 1, 2026
@robobun robobun deleted the farm/d517fa80/fix-transform-task-uaf branch May 1, 2026 02:37
Comment on lines +494 to +501
// Log messages produced during parsing/printing borrow text allocated
// from `arena`. Clone them into `bun.default_allocator` before the
// arena is destroyed so `then()` can safely read them on the JS thread.
defer {
for (this.log.msgs.items) |*msg| {
msg.* = bun.handleOom(msg.clone(bun.default_allocator));
}
}

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.

🟡 The cloned log messages are leaked: Msg.clone() dupes .text, location.file, location.line_text, and notes into bun.default_allocator, but TransformTask.deinit() only calls this.log.deinit() which is just msgs.clearAndFree() and never frees per-message allocations (and log.toJS()BuildMessage.create() clones again rather than taking ownership). Before this PR these strings lived in the arena and were reclaimed by arena.deinit(); the simple fix is to iterate this.log.msgs.items and call msg.deinit(bun.default_allocator) in TransformTask.deinit() before this.log.deinit().

Extended reasoning...

What the bug is

The new defer block in TransformTask.run() deep-clones every accumulated log message into bun.default_allocator:

defer {
    for (this.log.msgs.items) |*msg| {
        msg.* = bun.handleOom(msg.clone(bun.default_allocator));
    }
}

Msg.clone() (src/logger.zig:437) calls Data.clone() (line 230) which does allocator.dupe(u8, this.text) and Location.clone() (line 113) which dupes .file and .line_text; it also calls bun.clone(this.notes, allocator) to dupe the notes slice. All of these now live in bun.default_allocator.

However, nothing ever frees them.

Why existing code doesn't free it

There are two places that touch these messages afterwards, and neither takes ownership:

  1. then()this.log.toJS()BuildMessage.create() (src/bun.js/BuildMessage.zig:54) does msg.clone(allocator) again — it makes its own third copy and stores that in the BuildMessage. It does not take ownership of the incoming Msg.
  2. TransformTask.deinit()this.log.deinit() (src/logger.zig:840) is just log.msgs.clearAndFree(). That frees the ArrayList backing storage only; it never iterates the items to call Msg.deinit(), so the duped .text / .file / .line_text / .notes slices are dropped on the floor.

Before this PR these strings lived in the per-task MimallocArena and were reclaimed wholesale by arena.deinit(). By moving them to bun.default_allocator without adding a corresponding free, the fix trades a use-after-free for a leak.

Step-by-step proof

Take the regression test added in this PR: transpiler.transform("const x = 1 ++ 2 ++ 3;").

  1. TransformTask.run() runs on the worker thread. The parser fails and pushes a Msg into this.log whose data.text (Expected ";" but found "2") and data.location were allocated from arena.
  2. The new defer runs: msg.clone(bun.default_allocator) allocates fresh heap copies of text, location.file ("input.tsx"), location.line_text, and the notes slice, and overwrites msg.* with them.
  3. arena.deinit() frees the originals (fine — we no longer reference them).
  4. On the JS thread, then() sees this.log.hasAny() and calls this.log.toJS(). Internally that calls BuildMessage.create(globalThis, bun.default_allocator, msg), which at line 54 does msg.clone(allocator) — a third copy. The BuildMessage owns copy Copy source lines when generating error messages #3; copy Fix calling #private() functions in classes #2 is still sitting in this.log.msgs.items[0].
  5. then()'s defer this.deinit() runs → this.log.deinit()msgs.clearAndFree(). The ArrayList buffer is freed, but the heap allocations inside copy Fix calling #private() functions in classes #2 (text, file, line_text, notes) are never passed to bun.default_allocator.free().

The test loops 10 times, so it leaks 10 sets of these strings. Any long-running process that repeatedly calls transform() on invalid input will leak unboundedly.

Impact

Error-path-only and small per occurrence (an error string + file path + line text + notes slice), so this is not severe — and trading a flaky UAF crash for a small leak is clearly the right direction. But it is a regression introduced by this exact change and is trivially fixable, so worth doing in the same PR.

Suggested fix

In TransformTask.deinit(), free the per-message allocations before dropping the list:

pub fn deinit(this: *TransformTask) void {
    for (this.log.msgs.items) |*msg| {
        msg.deinit(bun.default_allocator);
    }
    this.log.deinit();
    ...
}

(Note: Location.deinit is currently a no-op with a // don't really know what's safe to deinit here! comment, so location.file/line_text would still leak even with this; if you want to be fully clean you'd need to free those explicitly too. But Msg.deinit at least reclaims data.text and the notes slice, which is the bulk of it and matches what the rest of the codebase does.)

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