Skip to content

macros: copy the JSON text of a returned Blob into the arena before parsing it (heap-use-after-free) - #46677

Open
robobun wants to merge 1 commit into
mainfrom
robobun/46bf8d14/macro-json-blob-uaf
Open

robobun wants to merge 1 commit into
mainfrom
robobun/46bf8d14/macro-json-blob-uaf

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A JSON Response or Blob that a macro returns can be inlined from freed memory. bun build exits 0 and writes the bytes of a later allocation. ASAN reports heap-use-after-free in bun_js_parser::fold::e_string_eql_bytes.
  • The documented return fetch(url) is enough: main can write another response's body, var cfg = { "\x00\x00\x00": "SECRET_TOKEN=hunter2;....
  • expr_from_blob (src/js_parser_jsc/Macro.rs:1148) parses the JSON in place in the Blob's store. The parser keeps escape-free strings as borrows of its source (src/parsers/json.rs:1083). The store is freed when the Blob is collected.

Fix

  • Copy the JSON text into the macro arena before it is parsed. The text and base64 arms already copy.
  • Verified: new test in test/bundler/transpiler/macro-test.test.ts. Main prints [["ZZZ"],false,...]. The fetch repro is correct 3 of 3.
  • Self-reviewed: 1 concern raised, 1 addressed.

Background

  • Run::coerce turns the value a macro returns into AST nodes. For a Blob, expr_from_blob picks JSON, text or a base64 data URL by MIME type.
  • The arena (MacroContext.bump) lives as long as the transpiler. Considered a rooted Blob: the AST has no owner that could release it. macros: run every macro on one dedicated VM thread #40059 has this same line.

Downsides

  • Each macro-returned JSON body costs 1 more arena allocation (11 to 12 allocator calls).
  • The text stays in the arena. Under bun --hot, a 16 MiB JSON body adds 16 MB of RSS per reload, as a text body does on main. Main is flat for JSON because its strings point into freed memory.
  • The release binary grows by 256 bytes of text.
Notes

Repro with fetch only (no Bun.gc, no Blob constructor). A local server returns {"key":"AAAA…","apiBase":…} on / and a second JSON body on /private.

// m.ts
const url = process.env.CFG_URL!;
export function config() {
  return fetch(url);
}
export async function userCount() {
  const seen: string[] = [];
  for (let page = 0; page < 120; page++) {
    const data = await (await fetch(url + "private?page=" + page)).json();
    for (let i = 0; i < 40; i++) seen.push(JSON.stringify({ page, i, ...data }));
  }
  return seen.length;
}
// index.ts
import { config, userCount } from "./m.ts" with { type: "macro" };
export const cfg = config();
export const users = userCount();

bun build ./index.ts --target=bun --outfile=out/b.js, release builds, linux x64:

build var cfg = in the bundle
1.4.4 the other response's body, or NUL bytes (2 of 2)
main 6292c65 the other response's body 2 of 3, NUL bytes 1 of 3
this PR { key: "AAAA… 3 of 3

The test uses Bun.gc(true) and 200 same-size allocations in a second macro, so the reuse does not depend on timing. The ASAN debug build of main aborts on it. A release build of main prints the Z bytes.

Measurements (release, main 6292c65 against this PR, linux x64):

  • llvm-size: text 107,688,070 to 107,688,326. expr_from_blob 2,493 to 2,570 bytes.
  • Allocator calls inside expr_from_blob for new Blob(['{"banner":"BANNER-TEXT-16-B"}'], { type: "application/json" }), counted with gdb breakpoints on the mimalloc entry points over 101 call sites: 11 to 12 per call. Bytes that stay allocated: 144 to 173.
  • A text or binary body: no change.

Arena residency under bun --hot (release builds, linux x64). The entry file calls a macro that returns a 16 MiB body, and the file is rewritten 12 times. RSS in MB after each load:

build the macro returns RSS
main a JSON body 80, flat
this PR a JSON body 95 to 288, +16 per reload
main a text body 96 to 289, +16 per reload
main a string 111 to 496, +32 per reload

MacroContext.bump is never reset. For the main-thread transpiler it lives as long as the process, so this is about the entry file and modules that the main thread transpiles. A module that is transpiled on the work pool gets a context for each job, and the job frees it. To free a macro result once its file is printed is a change for every arm, and it is not in this PR.

Suites run on the debug build: test/bundler/transpiler/macro-test.test.ts (35 pass).

Related


[auto-merge] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/transpiler/macro-test.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
ninja: error: '../../src/codegen/generate-jssink.ts', needed by 'codegen/JSSink.h', missing and no known rule to make it
error: script "bd" exited with code 1
__F:-1:S:0

release without fix: all passed
bun test v1.4.4-canary.1 (fad9f2ef0)

test/bundler/transpiler/macro-test.test.ts:
(pass) bun builtins can be used in macros [0.07ms]
(pass) latin1 string [0.08ms]
(pass) ascii string [0.05ms]
(pass) type coercion [8.44ms]
(pass) object with Symbol keys [0.10ms]
(pass) escaping [0.32ms]
(pass) utf16 string [0.02ms]
(pass) import aliases [0.03ms]
(pass) default import
(pass) namespace import [0.16ms]
(pass) ireturnapromise [0.13ms]
(pass) the `assert { type: 'macro' }` spelling works for every form of import [258.49ms]
(pass) object argument with a sparse numeric key [26.18ms]
(pass) object destructuring of a macro result keeps every bound property regardless of key order or repeated keys [15.37ms]
(pass) spreading a macro result into JSX doesn't change the other uses of its const [28.91ms]
(pass) inlines every new array, object or promise returned by a macro result's getters [92.86ms]
(pass) a macro that returns a JSON or text Response or Blob is inlined by its content type [149.15ms]
(pass) a Response or Blob returned from a macro is classified by its MIME essence [12.78ms]
(pass) a JSON Blob or Response returned from a macro does not have to outlive the macro call 
... (truncated)
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/pr_gate.xml" test/bundler/transpiler/macro-test.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/transpiler/macro-test.test.ts:
[macro] call escapeHTML
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call symbolKeys
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call escape
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] call addStrings
[macro] ca
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 6722ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/382] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingHTTPParser.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingHTTPParser.cpp
[2/382] gen JSEvent.lut.h
Generating /workspace/bun/build/release/codegen/JSEvent.lut.h from /workspace/bun/src/jsc/bindings/webcore/JSEvent.cpp
[3/382] gen cpp.rs (cppbind)
[4/382] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[5/382] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[6/382] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[7/382] gen JSSink.{cpp,h,lut.h,rs}
generated_jssink.rs: 7 sinks, 91 exported symbols
Generating /workspace/bun/build/release/codegen/
... (truncated)
diff hotspot
src/js_parser_jsc/Macro.rs                 |  2 ++
 test/bundler/transpiler/macro-test.test.ts | 45 ++++++++++++++++++++++++++++++
 2 files changed, 47 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
src/js_parser_jsc/Macro.rs                      3      2     69
test/bundler/transpiler/macro-test.test.ts      2      5     69

The JSON parser leaves every escape-free string and key as a borrow of
its source. For a JSON Blob or Response returned from a macro, the
source was the Blob's store, and the store is freed when the Blob is
collected. The object that the macro call was inlined as could then
point at freed memory: a heap-use-after-free under ASAN, and the bytes
of a later allocation in the output of a release build.
@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How to reproduce. Use a build without this change.

// m.ts
const body = JSON.stringify({ key: Buffer.alloc(4000, "A").toString() });
export function blob() {
  return new Blob([body], { type: "application/json" });
}
export function collect() {
  Bun.gc(true);
  const sameSize = [];
  for (let i = 0; i < 200; i++) sameSize.push(new Blob([Buffer.alloc(body.length, "Z")]));
  return sameSize.length;
}
// index.ts
import { blob, collect } from "./m.ts" with { type: "macro" };
const value = blob();
const count = collect();
console.log(Object.keys(value), value.key === Buffer.alloc(4000, "A").toString(), count);
bun index.ts
  • Release build of main 6292c65: [ "ZZZ" ] false 200.
  • ASAN debug build of main: heap-use-after-free, READ of size 3 in bun_js_parser::fold::e_string_eql_bytes, freed by <bun_jsc::webcore_types::store::Bytes as Drop>::drop.
  • With this change: [ "key" ] true 200.

The PR body has a second repro that uses only fetch.

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:43 PM PT - Oct 23rd, 2026

✅ @robobun, your commit ecf75df87a8f6b0085dd0d52bf7f8ffc5d7b7e33 passed in Build #131632! 🎉


🧪   To try this PR locally:

bunx bun-pr 46677

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

bun-46677 --bun

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 7d240c1 and ecf75df.

Files selected for processing: 2
  • src/js_parser_jsc/Macro.rs
  • test/bundler/transpiler/macro-test.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

JSON Blob parsing now copies input bytes into the bump arena before creating the parser source. A regression test checks JSON Blob and Response results after garbage collection and additional allocations.

Changes

JSON Blob parsing

Layer / File(s) Summary
Arena-backed parsing and regression coverage
src/js_parser_jsc/Macro.rs, test/bundler/transpiler/macro-test.test.ts
expr_from_blob copies JSON Blob bytes into the bump arena before initializing the parser Source. A subprocess test checks that Blob and Response results retain the expected parsed string after garbage collection and further allocations.

Suggested reviewers: jarred-sumner

Priority: Normal

Merge Risk: Minimal · up to ecf75

No actionable merge-blocking issue is identified; the change appears ready for normal merge checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main fix: copying returned Blob JSON into the arena before parsing to prevent heap-use-after-free.
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.
Description check Passed The description follows the repository template with both required sections, "What does this PR do?" (Problem, Fix, Background, Downsides) and "How did you verify your code works?" (test added, repro …
Full details: Description check

Explanation

The description follows the repository template with both required sections, "What does this PR do?" (Problem, Fix, Background, Downsides) and "How did you verify your code works?" (test added, repro results, measurements, suites run). It states the root cause, the fix, the new regression test, and the tradeoffs (extra arena allocation, binary size, RSS growth under --hot). The verification section is detailed, with repro tables, ASAN and release results, and the debug-build suite run. The description is thorough and mostly complete.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I checked the sibling paths in expr_from_blob and Run::coerce for the same borrowed-source hazard: the text, base64, JS-string and object-key arms already copy into bump (Macro.rs:845, 895, 1184, 1199), and parse_for_macro has this single caller, so the JSON arm was the only site handing Blob-store memory to rehome (json.rs:1083). alloc_slice_copy routes OOM through out_of_memory() like the neighbouring arena allocations, and an empty body short-circuits in parse_for_macro before any slice is retained.

Extended reasoning...

The diff adds one arena copy in the JSON arm of expr_from_blob in src/js_parser_jsc/Macro.rs and a subprocess regression test in test/bundler/transpiler/macro-test.test.ts; it touches native memory-safety code in the macro path but no auth, crypto, or input-validation surface. The fix matches the pattern of every sibling arm and the JSON parser's rehome confirms escape-free strings stay borrowed from the source, so the copy is the right layer. One inline finding about arena residency across re-transpiles is being posted, so a human should weigh it; this note records what else was examined.


if is_json {
// The parsed strings borrow the source bytes; keep them in `bump`.
let bytes: &[u8] = bump.alloc_slice_copy(bytes);

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.

🟡 (optional) Long-lived processes that re-run JSON-returning macros (for example bun --hot reloads) now keep every returned JSON body resident for the life of the transpiler, so RSS grows by one body per macro call and never comes back. src/js_parser_jsc/Macro.rs:1149 copies the whole body into MacroContext.bump, and that arena is created once at Macro.rs:239-240 and never reset() anywhere in src/. On the base branch the text lived in the Blob store and was released on GC (the UAF this PR fixes). Fix: copy only what the AST keeps (make rehome at src/parsers/json.rs:1083 copy source-borrowed strings into bump) or reset/free the arena once the produced Expr has been printed; the sibling text arm at Macro.rs:1184 has the same lifetime but copies only the final string.

Why this was flagged

A project whose macro returns fetch(url) JSON (the documented pattern), run under bun --hot or any mode that keeps vm.transpiler alive across re-transpiles, calls the macro again on every reload. Each call reaches expr_from_blob via the Blob arm at src/js_parser_jsc/Macro.rs:747 and the new line Macro.rs:1149 allocates a full copy of the body in bump. bump is MacroContext.bump (Macro.rs:72), lazily created at Macro.rs:239-240; a grep of src/ shows no bump.reset or take on it outside benches, so it is only freed when the Transpiler drops (src/bundler/transpiler.rs:107). The base branch held none of this text in the arena (the Blob store was freed on GC, which is the UAF), so for a 5 MB JSON body and 100 reloads the process retains ~500 MB more than before. Remedy: copy only the borrowed strings into the arena, or scope the copy to the lifetime of the produced Expr.

Verification: nit (pre-existing route; acknowledged in diff: the PR description's "Downsides" states "its text stays in the arena", which is accurate). src/js_parser_jsc/Macro.rs:1149 let bytes: &[u8] = bump.alloc_slice_copy(bytes); copies the whole body into MacroContext.bump. So each such macro call retains one body-sized allocation for the transpiler's lifetime.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I measured it. bun --hot, an entry file whose macro returns a 16 MiB body, RSS in MB after each of 12 reloads (release builds, linux x64):

build the macro returns RSS
main a JSON body 80, flat
this PR a JSON body 95 to 288, +16 per reload
main a text body 96 to 289, +16 per reload
main a string 111 to 496, +32 per reload

The JSON arm now costs what the text arm costs on main. The flat line on main is the bug itself: the parsed strings point into the Blob's store, and the store is freed.

A copy of only the strings in rehome saves the structural bytes of the JSON and keeps the growth. The growth comes from the lifetime of MacroContext.bump, and no arm can shorten that alone. The arena is never reset, and for the main-thread transpiler it lives as long as the process. A module that is transpiled on the work pool gets a context for each job, and the job frees it. A fix has to free what a macro call allocated once its file is printed, for every arm. That is a separate change from this fix.

The Downsides section of the description now has these numbers.

This branch has not been deployed

No deployments
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.

2 participants