Skip to content

Fix use-after-free in Bun.Transpiler async transform() parse errors#30029

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

Fix use-after-free in Bun.Transpiler async transform() parse errors#30029
robobun wants to merge 1 commit into
mainfrom
farm/d517fa80/fix-transpiler-async-error-uaf

Conversation

@robobun

@robobun robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator

What

Bun.Transpiler.prototype.transform() (the async variant) could read freed memory when reporting parse errors, producing garbage error messages in release builds and an ASAN crash in debug builds.

Why

TransformTask.run() runs on a thread-pool thread and creates a MimallocArena for parsing. The parser and lexer allocate their error message text (via log.addErrorFmt etc.) using that arena as the allocator. The arena is torn down at the end of run().

TransformTask.then() runs afterwards on the JS thread and calls this.log.toJS(), which clones each message — reading msg.data.text. At this point the text still points into the destroyed arena, so the read is a use-after-free.

Setting this.log.msgs.allocator = bun.default_allocator only covers the msgs ArrayList backing storage, not the per-message text payload.

Fix

Before the arena is destroyed, deep-copy any accumulated log messages into bun.default_allocator using the existing appendToWithRecycled(..., true) path, which rebuilds each message (text, location, notes) via a StringBuilder.

Testing

The new test runs 200 concurrent async transform() calls that all fail to parse, with MIMALLOC_PURGE_DELAY=0 so freed arena pages are purged immediately rather than being reused verbatim from the abandoned-page pool. Before this change, the error messages come back as garbage bytes (or ASAN aborts with use-after-poison). After, every rejection carries the expected Expected identifier but found "@" message.

Found by Fuzzilli.

TransformTask.run() allocates the parser/lexer arena and destroys it
before returning to the thread pool. Error messages added to the log
during parsing have their text allocated in that arena. When then()
later runs on the JS thread and calls log.toJS(), it reads text that
points into the destroyed arena.

Deep-copy any log messages into the default allocator before the arena
is torn down so then() can safely read them.
@github-actions github-actions Bot added the claude label May 1, 2026
@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:36 PM PT - Apr 30th, 2026

@robobun, your commit 707064d has 1 failures in Build #49610 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30029

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

bun-30029 --bun

@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: 3138402f-d4a7-40b8-b943-b8eb52f34fe9

📥 Commits

Reviewing files that changed from the base of the PR and between a03f69a and 707064d.

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

Walkthrough

Implements deferred log-rebinding in JSTranspiler.zig's TransformTask.run to create fresh logger.Log instances when per-task logs accumulate messages. Adds test validating async transform parse errors don't access freed arena memory through concurrent transpiler calls.

Changes

Cohort / File(s) Summary
Log Rebinding and UAF Prevention
src/bun.js/api/JSTranspiler.zig, test/js/bun/transpiler/transpiler-async-error-uaf.test.ts
Implements deferred log-rebinding in TransformTask.run to create fresh logger.Log instances via appendToWithRecycled. Adds comprehensive test validating async transform parse errors don't access freed arena memory through 200 concurrent transpiler calls with memory allocator debug environment variables.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: fixing a use-after-free bug in Bun.Transpiler's async transform() method when handling parse errors.
Description check ✅ Passed The PR description comprehensively covers the required template sections: explains what the bug is, why it occurs (with detailed technical context), how it's fixed, and how it's tested.
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: 3/5 reviews remaining, refill in 22 minutes and 21 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 use-after-free in async Bun.Transpiler transform() error messages by deep-copying arena-allocated log messages before the arena is destroyed

🤖 Generated with Claude Code

@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #29958, which already incorporates review feedback (tracks the cloned string/notes buffers on the task so they're freed in deinit()).

@robobun robobun closed this May 1, 2026
@robobun robobun deleted the farm/d517fa80/fix-transpiler-async-error-uaf branch May 1, 2026 03:36
Comment on lines +493 to +500
defer {
if (this.log.msgs.items.len > 0) {
var new_log = logger.Log.init(bun.default_allocator);
new_log.level = this.log.level;
bun.handleOom(this.log.appendToWithRecycled(&new_log, true));
this.log = new_log;
}
}

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.

🟡 Note that appendToWithRecycled allocates a single StringBuilder buffer and a notes_buf in bun.default_allocator, and Log.deinit() only frees the msgs ArrayList — so the cloned message text/notes leak on every failing async transform(). This is the same pattern used by ~10 other call sites (RuntimeTranspilerStore, BundleThread, ParseTask, bundle_v2, etc.) and trading a UAF for a small error-path leak is clearly the right call here; just flagging it as a follow-up for the Log ownership model rather than something to block on.

Extended reasoning...

What happens

cloneToWithRecycled (src/logger.zig:802-829) makes two heap allocations using other.msgs.allocator (which is bun.default_allocator in this call site):

  • string_builder.allocate(other.msgs.allocator) at line 818 — one contiguous buffer holding all message text, location text, and note text.
  • other.msgs.allocator.alloc(Data, notes_count) at line 819 — the notes array.

Neither pointer is stored anywhere on the Log. The individual Msg/Data text fields are slices into the shared StringBuilder buffer, so freeing them per-message would be wrong anyway.

Log.deinit() (logger.zig:840-844) only does log.msgs.clearAndFree(), which frees the ArrayList(Msg) backing storage but not the StringBuilder buffer or notes_buf. TransformTask.deinit() calls this.log.deinit(), so both allocations are leaked.

Step-by-step

  1. t.transform("@@", "js") schedules TransformTask.run() on a worker thread.
  2. The parser hits @@, calls log.addErrorFmt(...); the message text and location strings are allocated in the MimallocArena.
  3. run() returns; the new defer block fires (after the return but before arena.deinit()).
  4. appendToWithRecycled(&new_log, true) runs cloneToWithRecycled: it counts ~a few dozen bytes of text, allocates one StringBuilder buffer + one notes_buf in bun.default_allocator, and rewrites every Msg to point into those buffers. this.log = new_log.
  5. arena.deinit() frees the original arena text — fine, nothing references it anymore (UAF fixed ✅).
  6. On the JS thread, then() reads the cloned text via this.log.toJS(...) — works correctly.
  7. TransformTask.deinit()this.log.deinit()msgs.clearAndFree(). The StringBuilder buffer and notes_buf are never freed.

Each failing call leaks on the order of tens to a couple hundred bytes (error text + path/line strings + notes array). The test's 200-iteration loop leaks ~200× that; a long-running process that repeatedly transforms invalid input would grow unbounded.

Why existing code doesn't prevent it

Msg.deinit / Data.deinit exist but Log.deinit never calls them — and even if it did, they'd try to free individual slices that all alias one shared buffer, which would be invalid. The StringBuilder buffer handle is simply dropped on the floor after cloneToWithRecycled returns; nothing in Log records it.

Why this is a nit, not a blocker

This is the established pattern in the codebase. The same appendToWithRecycled / appendToMaybeRecycledLog.deinit lifecycle appears in RuntimeTranspilerStore.zig:326, BundleThread.zig:153/160, ParseTask.zig:358/369/380, bundle_v2.zig:4223/4371, HTMLBundle.zig:330, npm.zig:1859, Installer.zig:172, etc. — all of which have the identical leak. The PR correctly follows convention to move arena-backed messages into a stable allocator, and the trade (UAF/crash → small error-path-only leak) is unambiguously net-positive.

How to fix (follow-up)

The clean fix belongs in the Log API, not here: e.g. have cloneToWithRecycled stash the StringBuilder ptr/cap and notes_buf on the destination Log (or on each Msg) and free them in Log.deinit, or switch to per-message Msg.clone(allocator) so Log.deinit can iterate and free each one. That would fix this call site and the ~10 others simultaneously.

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