Skip to content

bun:ffi: copy the TinyCC diagnostic into the error cc() throws - #38062

Open
robobun wants to merge 5 commits into
mainfrom
farm/02710624/ffi-tcc-error-message-uaf
Open

bun:ffi: copy the TinyCC diagnostic into the error cc() throws#38062
robobun wants to merge 5 commits into
mainfrom
farm/02710624/ffi-tcc-error-message-uaf

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • When the wrapper that cc() compiles around a symbol fails in TinyCC, the Error thrown to JS has the first 8 bytes of its message replaced by garbage, e.g. error: @nÁÈ[or: unresolved reference to 'memmove' for TinyCC's tcc: error: unresolved reference to 'memmove' (seen on the Linux aarch64 lanes of bun:ffi: treat arguments not passed to a cc() symbol as undefined instead of reading past the call frame #38014; any wrapper diagnostic does it, on every platform).
  • Under an ASan build the same case is AddressSanitizer: heap-use-after-free reading the message (JSC::stringCopySameType <- JSON.stringify), freed in FFI::bun_ffi_cc at the end of cc().
  • Cause: src/runtime/ffi/ffi_body.rs:1235 (before this change) threw ZigString::init(msg).to_error_instance(...). For an untagged ZigString, toErrorInstance builds the message with the non-copying Zig::toString (src/jsc/bindings/helpers.h, getErrorInstance), so the Error's .message points straight at the Step::Failed buffer. That buffer is owned by the Function inside the CompileC local, which is dropped when cc() returns the exception, before JS can read it. The 8 garbage bytes are the allocator's free-list link written at the start of the freed block; the rest of the message survives, which is why the text after the first 8 bytes was intact in every sample.
  • Same call, second defect: init reads the bytes as Latin-1, but TinyCC's text is UTF-8 (it quotes source tokens), so a diagnostic mentioning a non-ASCII name comes out as mojibake ((got 'ñ') for (got 'ñ')).
  • Both TinyCC error callbacks (Function::handle_tcc_error, CompileC::handle_compilation_error) also skipped leading bytes outside 0x21..0x7e. The loop dates from 5e270f9 (2022), where the callback had been storing TinyCC's own buffer (freed by TinyCC right after the callback) and the same commit fixed that by copying; the loop runs on the freshly delivered text, so it never had garbage to remove. Wrapper diagnostics always start with <string>: or tcc:, so there it does nothing; a CompileC diagnostic starts with the source path exactly as the user passed it, so there it eats the first byte of a relative path that starts with a space or a non-ASCII character (ñsyntax.c:1: error: ... arrives as syntax.c:1: error: ...).

Fix

  • Throw ZigString::init_utf8(msg).to_error_instance(...). A UTF-8-tagged ZigString is decoded as UTF-8 and copied by toErrorInstance, so the Error owns a correctly decoded message. This is the tree's idiom for a message in a transient buffer (JSGlobalObject::create_error_instance, "Ensure we clone it"). Every Step::Failed message goes through this one site, so the static fail() messages are covered too.
  • Remove the leading-byte loop from both callbacks; they store the message exactly as TinyCC produced it.
  • Copy the message string when constructing Error/AggregateError from a ZigString #31451 (open) makes getErrorInstance copy for every caller; that removes the use after free for this site too, but not the Latin-1 decoding or the loops, and it has no test for this path. The two changes are independent (different files) and both stand with the other applied.
  • Intentionally not changed here: the three ZigString::init(function_name...) sites that name the JS functions and keys in the success paths of cc(), dlopen() and linkSymbols() (ffi_body.rs ~1234, ~1577, ~1663, plus the put calls next to them). They share the Latin-1 half of the defect (a non-ASCII symbol name comes back as añadir) but not the lifetime half, and need their own tests across the three entry points; filed separately.
  • Test: test/js/bun/ffi/cc.test.ts, "TinyCC diagnostics thrown by cc()". One spawned fixture exercises the paths and prints the messages:
    • an unresolved reference in the user's C (CompileC path), expected exactly 1 errors while compiling <file>\ntcc: error: unresolved reference to 'bun_test_missing_symbol'\n (on macOS TinyCC stores and reports C symbols with a _ prefix, so the expected name carries it there); this one passes before and after and pins the format of the untouched formatting path;
    • a syntax error in a file passed by the relative name relative.c (CompileC path), expected exactly 1 errors while compiling relative.c\n relative.c:1: error: ';' expected (got 'b')\n; before the change the second line loses its leading space (a non-ASCII name shows the same thing but cannot be opened through TinyCC's narrow open() on Windows, hence the space);
    • a C function named JSFunctionCall, which collides with the wrapper's entry point so the user's C compiles and the wrapper does not (Function path), expected <string>:<line>: error: ... 'JSFunctionCall';
    • a function exported under the asm label y ñ (asm labels are taken verbatim, so on macOS the label itself carries the _), so the wrapper's declaration of it is a syntax error quoting the non-ASCII token (Function path), expected <string>:<line>: error: ... 'ñ'; this still distinguishes init_utf8 from init once Copy the message string when constructing Error/AggregateError from a ZigString #31451 copies untagged strings, since that copy is Latin-1.
    • Without the fix, release build: the wrapper messages come back as AMM:371: error: incompatible types for redefinition of 'JSFunctionCall' and Pm.M:371: error: ';' expected (got 'ñ') (the 8-byte <string> prefix is exactly what gets overwritten), and the relative case loses its leading space, so the test fails on all three.
    • Without the fix, bun bd (ASan): the fixture dies with heap-use-after-free, freed at FFI::bun_ffi_cc (ffi_body.rs:1271, the drop of compile_c), so the test fails.
    • With the fix, bun bd test test/js/bun/ffi/cc.test.ts passes, including under ASan (the syntax-error cases go through TinyCC's longjmp and are clean there); the rest of test/js/bun/ffi/ is unchanged.

Background

  • cc() compiles the user's C into one TinyCC state (CompileC::compile), then, for each requested symbol, generates a small C wrapper (Function::print_source_code, which declares the user's function by name and defines JSFunctionCall) and compiles it from a string in a second TinyCC state (Function::compile). TinyCC delivers diagnostics through the error callback registered on each state. The user-source state's callback collects them and cc() formats them into a fresh string under a N errors while compiling <file> header (that path copies, and was only affected by the loop); the wrapper state's callback stores the text in Step::Failed { msg }, which cc() throws as-is (the path fixed here).
  • ZigString is a borrowed pointer+length with flag bits in the pointer. toErrorInstance decodes and copies the bytes when the UTF-8 flag is set; an untagged string is treated as Latin-1 and wrapped with StringImpl::createWithoutCopying, which is meant for static text. init leaves the string untagged; init_utf8 sets the flag.
  • The "first 8 bytes are garbage" shape is what a freed block looks like with mimalloc (the allocator in release builds): freeing writes the free-list next pointer into the first 8 bytes of the block and leaves the rest alone. The values seen in CI (for example 90 4b 7c 4e 0d 05 00 00, a pointer in the address range mimalloc reserves) are such pointers.
Related sites (not changed here)

no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/ffi/cc.test.ts

The Step::Failed message was wrapped into the Error instance through an
untagged ZigString, which does not copy, and the buffer was freed together
with the CompileC when cc() returned. Tag it UTF-8 so toErrorInstance
copies it, and drop the leading-byte skipping in both TinyCC error
callbacks, which was working around the symptom of that free.
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 50 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 186fad2d-4ef3-4605-8fa9-435d840d5356

📥 Commits

Reviewing files that changed from the base of the PR and between 04148c8 and 8a306cf.

📒 Files selected for processing (2)
  • src/runtime/ffi/ffi_body.rs
  • test/js/bun/ffi/cc.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix and test pushed (head 8a306cf), waiting on CI.

Reproduced on x64 before the change in two ways:

  • release build: cc() on a C file defining JSFunctionCall throws AMM:371: error: incompatible types for redefinition of 'JSFunctionCall' instead of <string>:371: ... (same shape as the tcc: err -> garbage messages on the aarch64 lanes of bun:ffi: treat arguments not passed to a cc() symbol as undefined instead of reading past the call frame #38014), a diagnostic quoting a non-ASCII token comes back as (got 'ñ'), and a diagnostic for a source passed as relative.c loses the leading space of its relative.c:1: line;
  • ASan debug build: heap-use-after-free reading the message, freed by the drop of compile_c at the end of FFI::bun_ffi_cc.

With the change, bun bd test test/js/bun/ffi/cc.test.ts passes (the new test runs under ASan as well); the rest of test/js/bun/ffi/ is unaffected.

Review so far: the Mach-O underscore in the expected unresolved-reference name (e80b5c4) and the relative-path case pinning the callback change (8a306cf) came out of it; the overlap with #31451 and the sibling symbol-name sites left unchanged are described in the PR body.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:53 PM PT - Aug 13th, 2026

@robobun, your commit 8a306cf has 1 failures in Build #94749 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38062

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

bun-38062 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Copy the message string when constructing Error/AggregateError from a ZigString #31451 - Fixes the same use-after-free at its shared source by making Zig::getErrorInstance copy untagged ZigStrings, which subsumes this PR's initinit_utf8 fix for every to_error_instance caller including cc().

🤖 Generated with Claude Code

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

LGTM — the initinit_utf8 switch makes toErrorInstance copy the transient Step::Failed buffer instead of wrapping it with createWithoutCopying, fixing the UAF.

What was reviewed:

  • Verified in helpers.h that getErrorInstancetoString uses StringImpl::createWithoutCopying for untagged strings and copies for the UTF-8 tag; init_utf8 sets that tag.
  • Confirmed line 1223 is the only site that reads Step::Failed { msg } and hands it to JS — link_symbols/CFunction go through create_jsc_ffi_function and don't touch this path.
  • The removed leading-byte-skip loops were a workaround for the same symptom (mimalloc free-list pointer written into the freed block), applied at the wrong point in time; removing them is correct.
  • Test spawns a fixture covering both callback paths (CompileC unresolved ref, Function wrapper clash) and asserts the exact TinyCC text; failure mode without the fix is documented for both release and ASan.
Extended reasoning...

Overview

Two-file change: src/runtime/ffi/ffi_body.rs (three hunks) and test/js/bun/ffi/cc.test.ts (one new spawned-fixture test). The Rust change is a one-token fix — ZigString::init(msg)ZigString::init_utf8(msg) at the site where cc() throws a per-symbol wrapper compile failure — plus removal of two identical "skip leading non-printable bytes" loops in the TinyCC error callbacks that were a misplaced workaround for this bug's symptom.

Correctness of the fix

I traced the C++ side: ZigString::to_error_instancegetErrorInstanceZig::toString (helpers.h:76). For an untagged pointer it returns StringImpl::createWithoutCopying (line 116), which borrows the bytes; for a UTF-8-tagged pointer it goes through fromUTF8ReplacingInvalidSequences, which allocates a copy. init_utf8 sets bit 61 (mark_utf8), so the message is now copied before compile_c (which owns the Function and thus the Step::Failed Box<[u8]>) is dropped on return. The PR's mimalloc-free-list explanation of the 8 garbage bytes matches the observed corruption exactly.

I checked for sibling sites: function.compile() is called only from the cc() path (line 1211), and line 1223 is the only consumer of Step::Failed { msg }. link_symbols and create_cfunction use create_jsc_ffi_function instead and never reach this. The removed byte-skipping loops in handle_compilation_error / handle_tcc_error ran before the buffer was freed, so they never stripped anything and would have eaten a leading non-ASCII path byte — deleting them is right.

Security risks

None. This is an error-path string-lifetime fix in the FFI cc() failure branch; no new inputs are trusted, no permissions or crypto touched. The change strictly improves memory safety (removes a heap UAF flagged by ASan).

Level of scrutiny

Low-to-medium. It's native code with a memory-safety implication, but the mechanism is fully understood, the fix is the tree's documented idiom for transient buffers (JSGlobalObject::create_error_instance uses the same tag for the same reason), and the diff is tiny and mechanical.

Other factors

The test is well-constructed per REVIEW.md: spawned fixture (crash-isolated), drains stdout/stderr/exited concurrently, asserts a combined object so a crashed child shows stderr, exercises both TinyCC callback owners, and the clash.c case (user defines JSFunctionCall) is a clever way to force the wrapper compile — the only Function-path failure reachable from JS. The PR description demonstrates fails-without / passes-with on both release and ASan builds. The test not being gated on isASAN is intentional and stated to pass; the setjmp/longjmp ASan conflict noted on other tests applies to in-process syntax-error paths, not these link/redefinition errors.

Comment thread src/runtime/ffi/ffi_body.rs 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.

I reviewed this PR and didn't find any bugs — the initinit_utf8 change correctly routes toString through fromUTF8ReplacingInvalidSequences (which copies) instead of createWithoutCopying, and the removed leading-byte loops were dead. A human look would still be worthwhile: the find-duplicate-prs bot flagged #31451 as fixing the same UAF at getErrorInstance for every caller, which is a layering call a maintainer should make, and comment-cop has an open flag on the 3-line comment at ffi_body.rs:1224.

What was reviewed:

  • Verified in helpers.h that untagged ZigStringgetErrorInstance uses createWithoutCopying while the UTF-8 tag copies via fromUTF8ReplacingInvalidSequences.
  • Checked the deleted "garbage-skipping" loops ran before the free that produced the garbage, so they never did anything and would have eaten a leading non-ASCII byte.
  • New test spawns a subprocess (so an ASan crash fails the test rather than the runner), covers both the CompileC and wrapper Function paths, and the non-ASCII case pins the UTF-8 decode.
Extended reasoning...

Overview

Two-file change: src/runtime/ffi/ffi_body.rs swaps ZigString::init(msg) for ZigString::init_utf8(msg) at the one site that throws a per-symbol wrapper compile diagnostic, and deletes two leading-byte-skipping loops from the TinyCC error callbacks. test/js/bun/ffi/cc.test.ts gains a spawned-fixture test covering both TinyCC failure paths (user-source link error and wrapper-compile clash) plus a non-ASCII diagnostic.

Security risks

None. This is an error-message lifetime fix on the cc() failure path; no new inputs are trusted and no security-gated code is touched.

Level of scrutiny

High — this is a use-after-free fix in native code. I traced the mechanism through src/jsc/bindings/helpers.h: getErrorInstance calls toString(ZigString), which for an untagged string returns WTF::StringImpl::createWithoutCopying over the borrowed bytes; the UTF-8 tag routes to WTF::String::fromUTF8ReplacingInvalidSequences, which allocates its own storage. msg is owned by the Function inside the local compile_c, which drops when bun_ffi_cc returns, so the untagged path is a genuine UAF and the tagged path is not. The removed loops in handle_tcc_error / handle_compilation_error ran during the callback, before the buffer they copied into is freed, so the "garbage" they were written to strip could never have been present at that point — dead code whose only effect was to eat a legitimate leading byte outside 0x21..0x7e.

Other factors

Two open automated comments push this to human review. First, find-duplicate-prs flagged #31451 as fixing the same class at Zig::getErrorInstance (copy untagged strings), which would subsume this call-site fix and also cover the AsyncModule.rs sibling the PR description explicitly leaves for later; per REVIEW.md's "fix bugs at the layer that owns the violated invariant" and "fix the whole class in the same PR", a maintainer should pick the layer. Second, comment-cop flagged the 3-line comment above the fix; I read it as explaining a non-obvious copy semantic rather than justifying a workaround, but it is an outstanding flag. The test itself follows harness conventions (tempDir, await using spawn, drains both pipes concurrently, asserts a combined object with exitCode last) and is not gated on isASAN — the author reports it passes under ASan, and the failure paths exercised go through the error callback rather than TinyCC's setjmp/longjmp. CI failures on the first commit (require-cache.test.ts, bake/deinitialization.test.ts) are unrelated to FFI.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

On the #31451 overlap: that PR makes getErrorInstance copy for every caller, which would also stop this site from reading freed memory, so the two overlap on the use after free. This one is still needed alongside it:

The two changes touch different files and are independent, so either can land first. The AsyncModule sites mentioned in the description are exactly what #31451 fixes; they are not touched here.

Comment thread test/js/bun/ffi/cc.test.ts

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

LGTM — the macOS underscore concern from my earlier review is addressed in e80b5c4 via symbolPrefix.

What was reviewed:

  • Verified in helpers.h that untagged ZigStringtoString uses StringImpl::createWithoutCopying while the UTF-8-tagged path copies via fromUTF8ReplacingInvalidSequences, so init_utf8 fixes both the UAF and the Latin-1 mojibake.
  • Confirmed the removed leading-byte-skip loops were dead: the "garbage" was mimalloc's free-list pointer written into the freed buffer after the callback had already copied it.
  • Test follows harness conventions (spawned fixture, tempDir, concurrent pipe drain, combined-object assertion) and now branches on isMacOS for the Mach-O _ prefix in both the asm label and the unresolved expectation.
Extended reasoning...

Overview

The PR changes one call site in src/runtime/ffi/ffi_body.rs from ZigString::init(msg) to ZigString::init_utf8(msg) when throwing a wrapper-compilation diagnostic from cc(), removes two dead leading-byte-skip loops in the TinyCC error callbacks, and adds a spawned-subprocess test to test/js/bun/ffi/cc.test.ts covering both diagnostic paths (CompileC and Function) plus a non-ASCII token.

Security risks

None. This is an error-message construction path; the only data flow is TinyCC diagnostic text → JS Error.message. The change moves from a borrowing string wrapper to a copying one, which strictly reduces the memory-safety surface (fixes a heap-use-after-free that ASan catches on the unfixed build).

Level of scrutiny

Medium. It's native memory-safety code, but the change is a one-token substitution to the tree's established idiom for transient-buffer error messages, verified against src/jsc/bindings/helpers.h:76-118: untagged strings hit StringImpl::createWithoutCopying (line 116), UTF-8-tagged strings hit fromUTF8ReplacingInvalidSequences (line 95, a copy). The dead-loop removal is well-justified — the garbage bytes are the allocator's free-list link written after the callback returns, so the loops could never have removed it, and would have incorrectly stripped a leading non-ASCII filename byte.

Other factors

My earlier inline finding (macOS leading_underscore making the exact-string unresolved assertion fail) was addressed in commit e80b5c4: the test now defines symbolPrefix = isMacOS ? "_" : "" and interpolates it into both the asm label and the expected unresolved reference to '...' string. The bug-hunting system found nothing on this revision. The test is hermetic (no network, subprocess-isolated, using tempDir), drains stdout/stderr/exited concurrently, and asserts a combined object so a crashed fixture surfaces stderr in the diff. The PR description demonstrates the test fails on the unfixed build in both release (garbage prefix + mojibake) and ASan (heap-use-after-free) modes.

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

LGTM — the macOS underscore issue from my earlier pass is addressed in e80b5c4 via the shared symbolPrefix.

What was reviewed:

  • Verified in helpers.h that untagged ZigStringtoString hits StringImpl::createWithoutCopying (line 116) while UTF-8-tagged hits fromUTF8ReplacingInvalidSequences (line 95), so init_utf8 both copies and decodes as claimed.
  • Checked that the removed leading-byte loops are pinned by the " relative.c" case (leading space survives), and that both callbacks now store the bytes verbatim.
  • Confirmed the test covers both throw sites (CompileC::deferred_errors header path and Function::Step::Failed bare path), the <string> prefix the UAF overwrote, and UTF-8 round-trip.
Extended reasoning...

Overview

Two files: src/runtime/ffi/ffi_body.rs changes one token at the Step::Failed throw site (ZigString::initinit_utf8) and deletes two identical leading-byte-skipping loops from the TinyCC error callbacks; test/js/bun/ffi/cc.test.ts adds a spawned-fixture describe block covering four diagnostic shapes. Net Rust change is one substitution plus ~22 deleted lines.

Security risks

None. The change tightens a use-after-free on an error path (diagnostic message text) and fixes a Latin-1/UTF-8 decoding mismatch. No new input surface, no auth/crypto/permissions.

Level of scrutiny

Native memory safety in FFI is the most-blocked category, so I traced the mechanism rather than trusting the description: Zig::toString at helpers.h:115-116 wraps untagged bytes with createWithoutCopying, and Step::Failed { msg } is a Box<[u8]> owned by Function inside the compile_c local — dropped when bun_ffi_cc returns the exception. init_utf8 routes through helpers.h:95 (fromUTF8ReplacingInvalidSequences), which allocates a fresh WTFStringImpl. The removed loops were a symptom-level workaround that could never fire (mimalloc writes the free-list link after the callback has copied the bytes out) and would have corrupted diagnostics beginning with a space or non-ASCII path byte; the new relative.c case pins that removing them preserves the leading byte.

Other factors

My previous review found the macOS leading_underscore mismatch in the unresolved assertion; e80b5c4 addresses it by deriving both the expected symbol name and the asm label from one isMacOS ? "_" : "" prefix, keeping the assertion exact per platform rather than loosening to a regex. The comment-cop note was addressed in 72c1b86 (one-line comment). All inline threads are resolved. The test follows harness conventions (subprocess, tempDir, drains all pipes, asserts a combined object with stderr visible on failure) and does not gate on isASAN — the author reports it passes under the ASan debug build, which is the point (it is the ASan repro for the UAF). CI is the final arbiter on the Windows leading-space filename and macOS lanes, but nothing here warrants holding for a human.

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