Skip to content

Bun.Transpiler: coerce loader before capturing the code buffer - #34970

Closed
robobun wants to merge 2 commits into
mainfrom
farm/b9c621a2/transpiler-loader-uaf
Closed

robobun wants to merge 2 commits into
mainfrom
farm/b9c621a2/transpiler-loader-uaf

Conversation

@robobun

@robobun robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

transformSync(), scan(), and scanImports() read the code argument's backing bytes before coercing the loader argument. JSValue::is_string() is is_string_like(), so a String object passes the check and loader_from_js calls toString() on it. A hostile toString() can transfer() the code ArrayBuffer, free its backing store, and have the allocator recycle the block before the parser reads from the stale pointer. The result is the transpiler emitting / scanning whatever foreign heap block now lives at that address.

const N = 1 << 20, keep = [], enc = new TextEncoder();
const fill = (u, s) => { u.fill(0x20); enc.encodeInto(s, u); };
const src = new Uint8Array(new ArrayBuffer(N));
fill(src, 'import "original-mod"; export const WHICH = "ORIGINAL_INPUT";');
const loader = Object.assign(new String("js"), { toString() {
  src.buffer.transfer(0); Bun.gc(true);
  for (let i = 0; i < 64; i++) { const x = new Uint8Array(N);
    fill(x, 'import "recycled-mod"; export const WHICH = "RECYCLED_FOREIGN_HEAP";'); keep.push(x); }
  Bun.gc(true); return "js"; } });
console.log(new Bun.Transpiler().transformSync(src, loader));

Before:

import"recycled-mod";
export const WHICH = "RECYCLED_FOREIGN_HEAP";

After: empty output (the buffer is detached before its bytes are read, so the parser sees a zero-length input).

The fix moves the loader_from_js call ahead of StringOrBuffer::from_js(code) in all three functions, so any user toString() runs before the code buffer's ptr/len are captured. Async transform() was already safe because it copies the bytes into an owned slice before coercing the loader.

Related: #34966 pins the backing buffer inside StringOrBuffer::from_js on the sync path, which closes the same class of bug at a lower layer for the node:crypto call sites. The two changes touch different files and are complementary.

How did you verify your code works?

test/js/bun/transpiler/transpiler-loader-uaf.test.ts spawns a subprocess that exercises transformSync, scan, and scanImports with a hostile String-object loader. It fails on the released binary (USE_SYSTEM_BUN=1 bun test ... emits transformSync emitted recycled heap: "import\"recycled-mod\";...") and passes on the debug build. test/js/bun/transpiler/ and test/bundler/transpiler/transpiler.test.js pass unchanged.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/transpiler/transpiler-loader-uaf.test.ts
bun test v1.4.0 (ba8b645a2)

test/js/bun/transpiler/transpiler-loader-uaf.test.ts:
78 |       stdout: "pipe",
79 |     });
80 | 
81 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
82 | 
83 |     expect({ stdout: normalizeBunSnapshot(stdout), stderr: normalizeBunSnapshot(stderr), exitCode }).toEqual({
                                                                                                          ^
error: expect(received).toEqual(expected)

  {
-   "exitCode": 0,
-   "stderr": "",
-   "stdout": "OK",
+   "exitCode": 1,
+   "stderr": 
+ "30 | 
+ 31 | {
+ 32 |   const src = mkSrc();
+ 33 |   const out = t.transformSync(src, hostile(src));
+ 34 |   if (out.includes("recycled-mod") || out.includes("RECYCLED_FOREIGN_HEAP")) {
+ 35 |     throw new Error("transformSync emitted recycled heap: " + JSON.stringify(out.slice(0, 120)));
+                    ^
+ error: transformSync emitted recycled heap: "import/"recycled-
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (ba8b645a2)

test/js/bun/transpiler/transpiler-loader-uaf.test.ts:
(pass) Bun.Transpiler loader coercion ordering > transformSync/scan/scanImports read code after loader toString() runs [63.01ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 test across 1 file. [213.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/transpiler/transpiler-loader-uaf.test.ts
bun test v1.4.0 (ba8b645a2)

test/js/bun/transpiler/transpiler-loader-uaf.test.ts:
(pass) Bun.Transpiler loader coercion ordering > transformSync/scan/scanImports read code after loader toString() runs [806.61ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 test across 1 file. [2.84s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 693ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m   Compiling�[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 44s
[2/5] link bun-profile
[3/5] bun-profile --revision
1.4.0-canary.1+ba8b645a2
[5/5] strip bun
[build] done
bun test v1.4.0-canary.1 (ba8b645a2)

test/js/bun/transpiler/transpiler-loader-uaf.test.ts:
(pass) Bun.Transpiler loader coercion ordering > transformSync/scan/scanImports read code after loader toString() runs [62.20ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 test across 1 file. [215.00ms]
__F:0:S:0
diff hotspot
src/runtime/api/JSTranspiler.rs                    | 64 +++++++++-------
 .../bun/transpiler/transpiler-loader-uaf.test.ts   | 89 ++++++++++++++++++++++
 2 files changed, 125 insertions(+), 28 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                  reads  edits  tests
src/runtime/api/JSTranspiler.rs                           3      3      0
test/js/bun/transpiler/transpiler-loader-uaf.test.ts      2      3      0

transformSync(), scan(), and scanImports() called StringOrBuffer::from_js
on the code argument (caching the ArrayBuffer's ptr/len) before calling
loader_from_js on the loader argument. loader_from_js accepts String
objects (is_string() is is_string_like()) and runs their toString(), so a
hostile String-object loader could transfer() the code ArrayBuffer inside
toString(), free its backing store, and leave the parser reading whatever
heap block replaced it. The emitted output / import list is then derived
from foreign heap contents.

Move the loader coercion ahead of the code-buffer capture in all three
functions. Async transform() was already safe: it copies the code bytes
into an owned slice before coercing the loader.
@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced with USE_SYSTEM_BUN=1 bun test test/js/bun/transpiler/transpiler-loader-uaf.test.ts (fails: transformSync emitted recycled heap: "import\"recycled-mod\";...").

Passes with bun bd test test/js/bun/transpiler/transpiler-loader-uaf.test.ts.

Existing transpiler suites (test/js/bun/transpiler/, test/bundler/transpiler/transpiler.test.js) pass unchanged.

Review nits addressed in ba8b645 (trimmed header comment, combined-object assertion).

@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Bun.Transpiler now coerces loaders before extracting code buffers in transformSync, scan, and scanImports, with explicit lifetime handling. A regression test exercises hostile loader coercion, buffer detachment, garbage collection, and foreign-heap reuse.

Transpiler loader lifetime handling

Layer / File(s) Summary
Loader and code argument ordering
src/runtime/api/JSTranspiler.rs
Loader coercion precedes code conversion across the three transpiler APIs, with deferred cleanup and lifetime guards preserved.
Detached-buffer regression coverage
test/js/bun/transpiler/transpiler-loader-uaf.test.ts
A spawned Bun test validates behavior when hostile loader coercion detaches and reallocates the input buffer.

Possibly related PRs

  • oven-sh/bun#34615: Also changes Bun.Transpiler loader handling and loader_from_js validation.
  • oven-sh/bun#34966: Also addresses buffer lifetime issues in synchronous transpiler paths.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: loader coercion now happens before code buffer capture.
Description check ✅ Passed The description includes both required sections and provides detailed behavior, fix, and verification information.

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

@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:16 AM PT - Jul 21st, 2026

✅ @robobun, your commit ba8b645a2206fdcd4e9b0df9399165130e301087 passed in Build #76965! 🎉


🧪   To try this PR locally:

bunx bun-pr 34970

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

bun-34970 --bun

@claude claude Bot left a comment

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.

Beyond the two inline style nits, I verified the reordering itself: code_arg is captured as a stack-held JSValue before args.eat() in all three functions, so reading it after loader_from_js runs is sound; the third-arg (js_ctx_value) path in transformSync only calls .is_object() (no user code) so it can't re-detach the buffer post-capture; and relative drop order of arena / code_holder / _restore is preserved. Deferring to a human because this is a native UAF fix.

Extended reasoning...

The reordering is mechanically correct and the test demonstrably fails on system bun / passes on the debug build. The only observable behavior change is that an invalid loader now throws before an invalid code type, which is the expected trade-off. Deferring because native memory-safety fixes in src/runtime/api/ warrant a maintainer sign-off even when the diff is a pure statement reorder.

Comment thread test/js/bun/transpiler/transpiler-loader-uaf.test.ts Outdated
Comment thread test/js/bun/transpiler/transpiler-loader-uaf.test.ts Outdated

@claude claude Bot left a comment

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.

Both earlier nits are addressed (2-line header, combined-object assertion) and the reorder looks correct to me — loader_from_js now runs before StringOrBuffer::from_js in all three sync paths, and argument iteration order is preserved. Deferring to a maintainer only because this is a native memory-safety fix touching raw-pointer lifetime invariants in JSTranspiler.rs.

What was reviewed

  • Verified args.eat() placement keeps arg indices identical in scan/transform_sync/scan_imports after the reorder.
  • Checked no remaining user-code entry point sits between StringOrBuffer::from_js(code) and parse (arg-2 ctx read in transform_sync is is_object() only, no coercion).
  • Confirmed async transform() copies bytes into an owned slice before loader_from_js, so it was already safe as the PR states.
Extended reasoning...

Overview

Reorders three host functions in src/runtime/api/JSTranspiler.rs (scan, transform_sync, scan_imports) so that loader_from_js(global, arg) — which can invoke a user-supplied toString() on a String object — runs before StringOrBuffer::from_js(global, code_arg) captures the code ArrayBuffer's ptr/len. Adds a spawned-subprocess regression test that heap-sprays after detaching the buffer inside toString() and asserts the transpiler sees a zero-length input rather than recycled heap.

Security risks

The PR closes a use-after-free where the parser reads from a freed/recycled ArrayBuffer backing store. The fix itself introduces no new unsafe blocks, allocations, or control flow — it is a pure statement reorder plus relocated args.eat() calls. I traced argument iteration in all three functions and the indices consumed are unchanged. In transform_sync, arena creation moved from before to after loader coercion, which is inert (loader_from_js doesn't touch the arena). After the reorder, the only code between buffer capture and parse in transform_sync is the arg-2 ctx read, which calls is_object() without coercion, so no user JS can run in that window.

Level of scrutiny

High. JSTranspiler.rs is native code dense with unsafe, detach_lifetime_ref, and RAII guards over raw pointers into stack-local arenas. REVIEW.md flags memory safety as the most-blocked category, and the specific rule this fix implements ("do all coercions first while holding no raw pointers") is one where a maintainer confirming completeness — that no other coercion site was missed — is worth the extra look. The change is mechanically small, but the invariants it interacts with are not.

Other factors

Both nits from the prior review pass were addressed in ba8b645 and the threads are resolved. The test was verified to fail with USE_SYSTEM_BUN=1 and pass on the debug build, and existing transpiler suites pass unchanged. The bug-hunting system found no issues on the current revision. I'm not approving solely because native memory-safety changes in this repo warrant a human sign-off; the diff itself reads as correct.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #35757, which consolidates all four sites into one change: StringOrBuffer::from_js now pins ArrayBuffer-backed inputs on the sync path (so every caller that goes through it is covered structurally), plus the two reorders for Bun.indexOfLine and Bun.randomUUIDv5 which snapshot the buffer directly.

@robobun

robobun commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #35757 carries this same Transpiler fix and its test, together with the randomUUIDv5 and RedisClient sites. It was rebased onto main today and is mergeable.

@robobun robobun closed this Sep 12, 2026
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