Skip to content

bun:ffi: throw when calling a symbol after close() instead of jumping into freed TinyCC memory - #31960

Closed
robobun wants to merge 8 commits into
mainfrom
farm/756a2a43/ffi-close-uaf
Closed

bun:ffi: throw when calling a symbol after close() instead of jumping into freed TinyCC memory#31960
robobun wants to merge 8 commits into
mainfrom
farm/756a2a43/ffi-close-uaf

Conversation

@robobun

@robobun robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

Calling a bun:ffi symbol after its library has been closed jumps into freed TinyCC-JIT'd memory. This is currently the most frequent non-OOM native crash in the wild (~155 events/day across 1.3.x), fingerprinting on Windows as:

Zig::JSFFIFunction::trampoline  src/jsc/bindings/JSFFIFunction.cpp
<anonymous>                     (freed heap / address 0x0)

On POSIX the same use-after-free jumps straight from JSC into TCC memory with no Bun frame, so those events land in anonymous noise buckets.

const { linkSymbols, JSCallback } = require("bun:ffi");
const cb = new JSCallback(() => 42, { returns: "i32", args: [] });
const lib = linkSymbols({ answer: { returns: "i32", args: [], ptr: cb.ptr } });
const answer = lib.symbols.answer;
answer();      // 42
lib.close();   // tcc_delete frees the wrapper's executable memory
answer();      // jumps into freed memory
panic(main thread): Segmentation fault at address 0x5B7DBA0CA04

Cause

Each dlopen() / cc() / linkSymbols() symbol owns a TCC::State; tcc_relocate allocates the compiled wrapper inside it. FFI::close() (src/runtime/ffi/ffi_body.rs) destroys those states, but nothing invalidates the JSFFIFunction objects, which stay reachable (cached symbols object, or any reference captured before closing) and callable:

  • On POSIX, JSFFIFunction::createForFFI baked the TCC pointer directly into the NativeExecutable, so JSC calls freed memory with no check anywhere.
  • On Windows, calls already routed through JSFFIFunction::trampoline (SYSV vs MS x64 ABI), which did function->function()(...) with zero validation.

CFunction additionally registered its JS wrapper in a FinalizationRegistry whose cleanup calls close(), while exposing the inner native function as .native, so collection of the wrapper could in principle free code that is still callable.

Fix

  • JSFFIFunction::createForFFI now always routes through the static trampoline (previously Windows-only), keeping the TCC pointer in the mutable m_function field. The trampoline throws TypeError: Cannot call this FFI function: its library has been closed when m_function is null. Cost on POSIX is one load, one predictable branch and an indirect call per FFI invocation, the same dispatch Windows has always used. Only TinyCC-compiled functions are affected; internal host functions created with add_ptr_property = false keep the direct path.
  • Each Rust Function holds a jsc::Strong to the JSFFIFunction it created, and Function::drop invalidates it (via the new Bun__JSFFIFunction__invalidate) before destroying the TCC state. This covers close() and every mid-loop error path, and works even though src/js/bun/ffi.ts replaces symbols entries with plain JS wrappers (walking the symbols object would miss the real functions).
  • FFI::close() clears the functions map first, so every JS function is invalidated before the shared cc() state and the dylib are freed.
  • The intentional leak in FFI::finalize() (unclosed library GC'd while symbols are still reachable) now releases just the Strong roots: the leaked TCC states keep the code valid, and the JS functions remain collectable exactly as before.
  • CFunction's FinalizationRegistry registers .native instead of the wrapper. Note this registry currently never fires at all: the held close callback references the library, which references the registered target, and a held value that reaches its target prevents collection. That makes it a pre-existing leak rather than a crash source; making it actually fire is out of scope here.
  • docs: one sentence stating that symbols of a closed library throw.

Verification

New tests in test/js/bun/ffi/ffi.test.js (library close() describe): a subprocess fixture running the exact crash scenario, wrapper + .native invalidation, independence of other open libraries, a real dlopen(libc) close-then-call (gated on the existing libPath probe), CFunction.close(), and a GC stress test proving a CFunction stays callable while only .native is retained (10x Bun.gc(true)).

On the unfixed debug+ASAN build the suite fails exactly as production does: the subprocess exits with code 132 dying inside freed TCC memory, and the in-process variant segfaults the runner at a wild heap address (Segmentation fault at address 0x5B7DBA0CA04). With the fix, all pass, including under BUN_JSC_validateExceptionChecks=1.

Also ran: full ffi.test.js, cc.test.ts, ffi-error-messages.test.ts, addr32.test.ts, ffi-viewSource-non-object.test.ts, test/regression/issue/30717.test.ts, test/napi/napi-value-ffi.test.ts.

Notes:

  • The cc() entry point shares the identical teardown (same Function::drop invalidation); it has no separate test here because TinyCC's setjmp error handling conflicts with ASan (see the existing skips in cc.test.ts).
  • While enabling the full suite locally I fixed a latent test bug: the FFI runner fixture check uses the platform suffix, but the dlopen call hardcoded /tmp/bun-ffi-test.dylib, so the suite could never load its fixture anywhere but macOS. With the fixture actually built on Linux debug+ASAN, that pre-existing suite shows unrelated failures (exhaustive integer-identity loops time out, and an ASan bad-free in JSBuffer__bufferFromPointerAndLengthAndDeinit via toBuffer) that exist on main and are not touched by this PR. CI does not build that fixture, so nothing changes there.

Supersedes #29946, which implemented the same design against the Zig implementation before the Rust port (#30412) orphaned it.

Related to #31941 by crash fingerprint (JSFFIFunction::trampoline is the last Bun frame for every Windows FFI call, so that bucket groups all FFI-adjacent crashes), but this PR does not explain that report: the repro there never calls close(), and the shared low-bit fault addresses point to a fixed-offset data dereference rather than a jump into freed code. See #32013 for the likely #31941 root cause. This PR is worth landing on its own: every path that frees TinyCC executable memory now invalidates the JS functions first, so calling a torn-down symbol surfaces as a catchable TypeError instead of a segfault.

close() frees the TinyCC-compiled wrapper code while the JSFFIFunction
objects remain reachable and callable from JS. Calling one jumped into
freed executable memory.

Route all platforms through the trampoline (Windows already did for ABI
reasons) and null-check the function pointer there. Each Rust Function
now holds a Strong reference to its JS function and invalidates it in
Drop before the TCC state is destroyed. The CFunction finalization
registry now keys collection on the native function instead of the
wrapper.
@mintlify

mintlify Bot commented Jun 7, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 7, 2026, 4:19 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions github-actions Bot added the claude label Jun 7, 2026
@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:54 AM PT - Jul 7th, 2026

@Jarred-Sumner, your commit 945c292 has some failures in Build #69816 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 31960

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

bun-31960 --bun

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Segfault in JSFFIFunction::trampoline on Windows standalone executable after sustained FFI polling #31941 - Segfault in JSFFIFunction::trampoline on Windows after sustained FFI polling; the PR's null-check trampoline and invalidate() mechanism directly prevents this use-after-free crash

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #31941

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Prevents calling FFI symbols after a library is closed by adding a JSC trampoline guard and invalidate bridge, rooting and invalidating JSFFIFunctions in Rust with reordered teardown, updating FinalizationRegistry tracking, and adding tests and docs for close behavior.

Changes

FFI Symbol Use-After-Free Prevention

Layer / File(s) Summary
JSC Binding Guard and Invalidation
src/jsc/bindings/JSFFIFunction.h, src/jsc/bindings/JSFFIFunction.cpp
Adds JSFFIFunction::invalidate() and an unconditional trampoline() that checks function->function() and throws TypeError if null; adds Bun__JSFFIFunction__invalidate C API.
Rust FFI Lifecycle Management
src/runtime/ffi/ffi_body.rs
Adds js_function: Option<jsc::Strong> to Function, a Rust wrapper invalidate_js_function, centralizes symbol attachment via attach_compiled_symbol(), invalidates JS roots during Drop/finalize, and clears functions before destroying TinyCC/dylib in FFI::close().
Symbol Creation Call Site Refactoring
src/runtime/ffi/ffi_body.rs
Refactors FFI::bun_ffi_cc(), FFI::open(), and FFI::link_symbols() to use attach_compiled_symbol() rather than duplicating JS function creation logic.
JavaScript FinalizationRegistry Update
src/js/bun/ffi.ts
CFunction FinalizationRegistry now registers the .native callable instead of the JS wrapper so cleanup triggers when the native function is collected.
Tests and Documentation
docs/runtime/ffi.mdx, test/js/bun/ffi/ffi.test.js
Docs note that dlopen() results must be closed and that symbol calls after close() throw TypeError. Tests add a library close() suite covering close idempotency, invalidation of wrapper and .native, linked-library independence, platform runner path fix, and GC/FinalizationRegistry behavior.

Possibly Related Issues

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: preventing use-after-free crashes by throwing TypeError instead of jumping into freed memory when calling FFI symbols after close().
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 clearly explains the change, rationale, and verification, and it covers the template’s required info even though the headings differ.

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

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/bun/ffi.ts`:
- Around line 563-567: The FinalizationRegistry heldValue currently uses
result.symbols[identifier].close (a bound function) which retains result and
prevents GC; instead register a lightweight token object (e.g. const token = {})
as the held value when calling
cFunctionRegistry.register(result.symbols[identifier].native, token), and store
a Map from token to a WeakRef of result plus the identifier (e.g.
cFunctionCloseMap.set(token, { libRef: new WeakRef(result), id: identifier }));
update onCloseCFunction to accept the token, lookup the WeakRef from
cFunctionCloseMap, call libRef.deref()?.close(id) if present, then delete the
map entry—this removes the strong reference cycle while still allowing cleanup
to call result.close for result.close.bind(result) indirectly.

In `@src/jsc/bindings/JSFFIFunction.cpp`:
- Around line 114-120: The invalidate path currently only calls
Zig::JSFFIFunction::invalidate which poisons the trampoline target but leaves
the public exported pointer reachable; update Bun__JSFFIFunction__invalidate to
also clear/neutralize the exported ptr so re-wrapping oldSymbol.ptr cannot jump
into freed code. Specifically, in Bun__JSFFIFunction__invalidate add logic to
zero out the exported symbol (the field that holds the TinyCC wrapper / public
.ptr — e.g. m_exported or the public ptr member on Zig::JSFFIFunction) or call a
new helper on Zig::JSFFIFunction that clears both m_function and the exported
ptr; ensure this change covers the code paths where
generate_symbol_for_function, linkSymbols, and CFunction may receive numeric
ptrs so the public ptr is set to 0 when invalidate runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0c4e6532-db1e-4ff0-b485-7fdc8cc6297b

📥 Commits

Reviewing files that changed from the base of the PR and between a988615 and aa27bec.

📒 Files selected for processing (6)
  • docs/runtime/ffi.mdx
  • src/js/bun/ffi.ts
  • src/jsc/bindings/JSFFIFunction.cpp
  • src/jsc/bindings/JSFFIFunction.h
  • src/runtime/ffi/ffi_body.rs
  • test/js/bun/ffi/ffi.test.js

Comment thread src/js/bun/ffi.ts
Comment thread src/jsc/bindings/JSFFIFunction.cpp
Comment thread test/js/bun/ffi/ffi.test.js
A library left open at process exit leaks its TCC states by design,
which LeakSanitizer reports on the ASAN CI lane.
Comment thread test/js/bun/ffi/ffi.test.js Outdated
JSCallback, linkSymbols and CFunction all require TinyCC, which is
disabled on Windows ARM64. Matches the gate used by cc.test.ts and
ffi-error-messages.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.

No further issues from me — both earlier comments are addressed (Windows ARM64 gate landed in e3cd54d). Deferring to a human for the final look since this reworks native FFI lifecycle across Rust/C++/JSC (Strong roots in Function, Drop/teardown ordering in FFI::close/finalize, and routing all POSIX FFI calls through the trampoline).

Extended reasoning...

Overview

This PR fixes a production use-after-free where calling a bun:ffi symbol after close() jumps into freed TinyCC-JIT'd memory. It touches six files across three languages:

  • JSFFIFunction.{h,cpp} — adds invalidate() and an unconditional trampoline (previously Windows-only) that null-checks m_function and throws TypeError; adds the Bun__JSFFIFunction__invalidate C ABI entry point.
  • ffi_body.rs — adds an Option<jsc::Strong> to Function so each compiled symbol roots its JSFFIFunction; Function::drop invalidates before freeing the per-function TCC state; FFI::close() reorders teardown (functions → shared TCC state → dylib); FFI::finalize() releases just the Strong roots when leaking; refactors three call sites into attach_compiled_symbol().
  • ffi.tsCFunction's FinalizationRegistry now registers .native instead of the wrapper.
  • ffi.test.js — six new tests in a library close() describe (subprocess crash regression, wrapper/.native invalidation, library independence, dlopen close, CFunction close, GC retention), plus a drive-by fix to use suffix instead of hardcoded .dylib. Gated with describe.skipIf(isWindows && isArm64) after my earlier comment.
  • ffi.mdx — one sentence documenting the new throw behaviour.

Security risks

This is memory-safety hardening, not new attack surface. bun:ffi already grants arbitrary native-pointer access; this change strictly removes a UAF crash class by turning it into a catchable TypeError. The CodeRabbit note about .ptr re-wrapping is correctly out of scope (raw pointers are inherently unverifiable). No auth/crypto/permissions touched.

Level of scrutiny

High. This is exactly the kind of change that benefits from a maintainer's eyes:

  • Cross-language GC lifecyclejsc::Strong held in a Rust struct whose Drop calls back into C++ to mutate a JSC cell, with the comment noting "Function is only created and dropped on the JS thread, which Strong requires." That invariant and the finalize() path (releasing roots while intentionally leaking TCC states) are subtle.
  • Teardown ordering in FFI::close() is now load-bearing (functions must drop before the shared cc() state and dylib).
  • Hot-path change on POSIX — every FFI call now goes through trampoline instead of the direct NativeExecutable baked pointer. The PR description quantifies this as one load + one branch + one indirect call, matching what Windows already paid, but it's a deliberate perf trade-off a maintainer should ack.

Other factors

  • The bug-hunting system found nothing on the latest revision.
  • Both of my earlier inline comments are resolved: the stderr-assertion nit was reasonably declined (ASAN lane noise), and the Windows ARM64 TinyCC gate was applied in e3cd54d.
  • Both CodeRabbit threads are resolved with sound author rebuttals (pre-existing FinalizationRegistry cycle is acknowledged and out of scope; .ptr revocation is impossible by design).
  • Test coverage is thorough, including a subprocess fixture that reproduces the original segfault on an unfixed build.
  • Remaining CI failures on the latest reported commit (musl LTO link errors, bunx.test.ts) are unrelated infrastructure/flakes, not FFI.

The PR is well-reasoned and well-tested; I'm deferring solely because native memory-management changes of this shape should get a human sign-off, not because of any open concern.

@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green. ffi.test.js passes on every lane, including Linux ASAN x64/aarch64 (the LeakSanitizer failure was fixed in aeb9051) and Windows ARM64 (TinyCC gate in e3cd54d).

The only remaining red is test/cli/install/bunx.test.ts ("should handle package that requires node 24", which runs bunx --bun @angular/cli@latest --help against the live npm registry and got exit 3). It has failed on a different lane in each of the last four builds (macOS, Windows x64, Windows x64-baseline, Linux ASAN) while passing on the rest, and does not touch anything in this PR.

Ready for review.

@robobun

robobun commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with the library close() suite in test/js/bun/ffi/ffi.test.js: on an unfixed build the subprocess repro dies inside freed TinyCC memory (exit 132) and the in-process variant segfaults the runner at a wild heap address; with the fix every variant throws TypeError: Cannot call this FFI function: its library has been closed.

Branch is current with main as of 945c292 (maintainer merge). The diff vs main is unchanged (6 files, 215+/51-). The Fixes #31941 line was removed per review; that report is now "Related to #31941 by crash fingerprint ... see #32013".

Build 69816: the two red darwin-26 aarch64 jobs both died in "buildkite-agent artifact download timed out after 120s" before running any tests (artifact-storage hiccup, same class seen on 66367 and 61680). The remaining lanes are still draining. On every completed run, ffi.test.js passes on all lanes including Linux ASAN and Windows ARM64. Retrying those two jobs should turn the build green.

PR: #31960

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

No issues found, but this is a cross-language memory-lifecycle change (Rust Drop → C++ invalidate, new jsc::Strong rooting, unconditional trampoline on the POSIX hot path) that warrants a human sign-off.

Extended reasoning...

Overview

This PR fixes the most frequent non-OOM native crash in production by making FFI symbols throw a TypeError after close() instead of jumping into freed TinyCC executable memory. It spans three layers: JSFFIFunction.{h,cpp} adds an unconditional trampoline with a null-check and a new Bun__JSFFIFunction__invalidate C entrypoint; ffi_body.rs adds a jsc::Strong root per Function, an attach_compiled_symbol helper used by bun_ffi_cc/open/link_symbols, invalidation in Function::drop, reordered teardown in FFI::close(), and Strong-release in the intentional-leak path of FFI::finalize(); ffi.ts retargets CFunction's FinalizationRegistry to .native. Tests cover subprocess crash regression, wrapper+.native invalidation, library independence, real dlopen, CFunction.close(), and a GC-stress retention case. Docs add one sentence.

Security risks

The change is strictly a hardening: it converts a use-after-free into a catchable exception. It does not add new attack surface, expose data, or touch auth/permissions. The CodeRabbit note about .ptr re-wrapping is correctly out of scope (raw pointers in bun:ffi are inherently caller-trusted, and a copied integer cannot be revoked).

Level of scrutiny

High. This is native memory-lifecycle code at the JSC/Rust/TinyCC boundary: it introduces a new GC root (jsc::Strong) whose lifetime is tied to Rust Drop, reorders destruction in close()/finalize(), and changes the POSIX FFI call path from a direct jump to a trampoline+indirect call on every invocation. Each of these is correct by my reading and well-justified in comments, but the interactions (Drop ordering, Strong release in the leak path, finalize vs close, JS-thread-only Strong) are subtle enough that a maintainer familiar with the FFI/JSC ownership model should confirm — particularly the per-call overhead trade-off on POSIX and the finalize() Strong-release semantics.

Other factors

All prior review threads (CodeRabbit ×2, my Windows ARM64 gate and stderr nit) are resolved; the ARM64 gate was applied in e3cd54d and the author reports CI green on all lanes after the merge with main. The bug-hunting system found nothing. The diff is well-commented, the test coverage is thorough, and the design rationale in the description is detailed. This is a good PR — it just isn't in the "simple/mechanical" bucket that auto-approval targets.

…se-uaf

# Conflicts:
#	test/js/bun/ffi/ffi.test.js
@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Merged main again (bab9156). Two small conflicts, both in the test file:

The native side auto-merged cleanly on top of #32792 (TerminationException handling) and #32787 (NaN purification). Re-verified with a fresh debug build: ffi.test.js is 12 pass, 0 fail.

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

No issues found, but this is a native memory-lifecycle change spanning C++/Rust/JS (new jsc::Strong roots, Drop-ordered invalidation, and switching POSIX FFI calls to the trampoline path), so it warrants a human pass on the design.

Extended reasoning...

Overview

This PR fixes a use-after-free where calling a bun:ffi symbol after close() jumps into freed TinyCC-JIT'd memory. It touches src/jsc/bindings/JSFFIFunction.{cpp,h} (unconditional trampoline with null-check, new invalidate() + Bun__JSFFIFunction__invalidate C ABI), src/runtime/ffi/ffi_body.rs (each Function now holds a jsc::Strong to its JSFFIFunction; Drop invalidates before freeing TCC state; FFI::close() teardown reordered; FFI::finalize() releases roots on the intentional-leak path; three call sites refactored into attach_compiled_symbol), src/js/bun/ffi.ts (FinalizationRegistry target changed from wrapper to .native), plus tests and a one-line docs note.

Security risks

None in the traditional sense — this removes an exploitable-class UAF (jump into freed executable memory) and replaces it with a catchable TypeError. The remaining raw-pointer surface (linkSymbols accepting numeric ptrs) is pre-existing and by design, as already discussed and resolved in the CodeRabbit thread.

Level of scrutiny

High. This is native memory-lifecycle code coordinating GC roots (jsc::Strong), Rust Drop ordering, and TinyCC executable-page lifetime across three languages. The Strong must be correctly released on every path (close(), finalize() leak path, mid-loop error paths) or it leaks JS objects; the invalidation must run strictly before tcc_delete on every path or the original UAF remains. It also changes the POSIX FFI hot path from a direct NativeExecutable call to an indirect trampoline call — a deliberate perf trade-off the author quantifies, but one a maintainer should sign off on.

Other factors

The bug-hunting system found nothing. All prior review threads (CodeRabbit ×2, my Windows-ARM64 gating finding, my stderr nit) are resolved. Test coverage is solid: subprocess crash regression, wrapper/.native invalidation, library independence, real dlopen, CFunction.close(), and a GC-stress retention test; the author reports the suite fails as expected on an unfixed ASAN build. CI is green per the author's status updates (only an unrelated flaky bunx.test.ts remains). The design looks correct to me, but the cross-language lifecycle and hot-path change put it outside what I'll approve without a human reviewer.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

For whoever does the human pass, the three lifecycle points in the review above are each enforced in one place:

  • Invalidation ordering: the only code that frees TinyCC executable memory is Function::drop (per-symbol state) and FFI::close (shared cc() state). Function::drop invalidates its JSFFIFunction before TCC::State::destroy, and close() clears the functions map (running those drops) before destroying the shared state or dlclosing. No path frees code while a callable function still points at it.
  • Strong lifetime: the jsc::Strong lives on Function, so it is released wherever the Function is dropped (close, and every mid-loop error path in open/link_symbols/cc), plus explicitly in the unclosed finalize() leak path so GC retention of the JS functions is unchanged from before this PR.
  • POSIX call path: createForFFI now always uses the static trampoline, which is the dispatch Windows has always used for ABI reasons. The added per-call cost is the callee load, a predictable null check, and an indirect call.

On CI: the only red job on the current build is the darwin-aarch64 lane failing in "buildkite-agent artifact download timed out after 120s" before any tests ran; the test lanes that completed are green.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

The close()-after-use UAF fix here looks real and well-tested on its own terms, but this should not carry Fixes #31941 — I don't think it explains that crash, and letting it auto-close the issue would silently leave the reported bug open.

  • The Segfault in JSFFIFunction::trampoline on Windows standalone executable after sustained FFI polling #31941 reproduction never calls close(). The reporter's repro is a setInterval polling GetConsoleMode in a Windows standalone exe that crashed once after ~116 minutes (~70k successful calls). A close()-then-call UAF would fault deterministically on the first post-close call, not after 70k good ones.
  • Both fault addresses in the issue's crash reports share low bits 0x...108 — that's a fixed field offset in a data dereference through a stale pointer, not a jump into an unmapped freed code region.
  • JSFFIFunction::trampoline is the last Bun frame of essentially every Windows FFI call, so a crash bucketed on that frame groups all FFI-adjacent crashes; matching on that signature isn't evidence of a shared root cause.

The likelier #31941 root cause is what #32013 addresses (TinyCC JIT run memory allocated from the CRT heap, then VirtualProtect'd RWX with RtlAddFunctionTable unwind tables registered inside it — a verbatim backport of upstream TinyCC's own fix for it). I've just rebased #32013.

Suggest editing the body to remove the Fixes #31941 line and replace it with something like: "Related to the crash-report bucket in #31941 by fingerprint, but does not explain that report (which never calls close()); see #32013 for the likely #31941 root cause." The PR's own hedge ("if the crash there turns out to be unrelated ... it should be reopened") already anticipates this — better not to close it in the first place. The close() UAF is worth landing regardless, on its own merits.

@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed, and updated. I had over-indexed on the trampoline fingerprint; the three points hold: no close() in the repro, one failure after ~70k good calls rather than the deterministic first-call fault this PR produces, and the shared 0x...108 low bits read as a fixed field offset in a data deref. The body now says "Related to #31941 by crash fingerprint ... does not explain that report" and points at #32013 as the likely root cause for that issue, so merging this will not auto-close it. The hedge line is gone.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing this since #35246 (bun:ffi: use the engine-native FFI when available) merged and covers the same ground. Thank you @robobun for the PR — if there's a piece of this that #35246 didn't pick up, please say so and we'll take another look.

(This comment was written by Claude, on behalf of the Bun team.)

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

There is one piece #35246 did not pick up: calling a dlopen'd symbol after lib.close() still crashes on current main (fd25110). The mechanism changed (the engine-FFI'd function now calls the dlclosed library's address directly, instead of jumping into freed TinyCC memory), but the user-visible crash class from the Sentry bucket is the same.

Repro on a library that genuinely unmaps on dlclose (libc-family libraries mask it because other references keep them resident):

// cc -shared -fPIC -o libt42.so t42.c
int fortytwo(void) { return 42; }
const { dlopen } = require("bun:ffi");
const lib = dlopen("/tmp/libt42.so", { fortytwo: { args: [], returns: "i32" } });
lib.symbols.fortytwo(); // 42
lib.close();            // dlclose unmaps the library
lib.symbols.fortytwo(); // SEGV

Debug/ASAN build of main:

SUMMARY: AddressSanitizer: SEGV (<unknown module>)

Where the gap lives now: FFI::do_close() (ffi_body.rs) still dlcloses and destroys TCC states without invalidating the JS functions, and the new JSC::JSFFIFunction has m_target plus the IC stub but no invalidation hook (JSFFICallback got close(); the function side did not). Since m_target is presumably baked into the JIT'd stub, a proper fix likely needs JSC-side support (null the target and discard/guard the stub), which is why I am reporting rather than re-opening a PR against the vendored WebKit.

Two smaller notes:

  • linkSymbols(...).close() then call now silently succeeds (the raw pointer target is still alive). Not a crash, but close() is a no-op there from the caller's perspective.
  • The library close() regression tests from this PR (subprocess crash repro, dlopen-close-call) did not land with bun:ffi: use the engine-native FFI when available #35246; they would catch this on the ASAN lane once a fix exists.

Happy to take another run at this against the new architecture if you want it Bun-side; say the word.

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