Skip to content

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

Closed
robobun wants to merge 10 commits into
mainfrom
farm/9ef4a177/ffi-close-uaf
Closed

bun:ffi: throw when calling a symbol after close() instead of jumping into freed JIT memory#29946
robobun wants to merge 10 commits into
mainfrom
farm/9ef4a177/ffi-close-uaf

Conversation

@robobun

@robobun robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

dlopen() / cc() / linkSymbols() create a JSFFIFunction for each symbol whose native entry point is TinyCC-JIT-compiled trampoline code living in a per-function TCC.State. On non-Windows, JSFFIFunction::createForFFI wrapped that trampoline address directly as the function's NativeExecutable.

lib.close() runs tcc_delete on every function's state, freeing/unmapping the JIT pages — but the JSFFIFunction objects are still reachable from JS (via the cached symbols object or any reference the user captured before closing), and their executables still point at the freed code. Calling one jumps straight into freed memory.

Repro

const { linkSymbols, JSCallback } = require("bun:ffi");
const cb = new JSCallback(x => x + 1, { args: ["i32"], returns: "i32" });
const lib = linkSymbols({ inc: { args: ["i32"], returns: "i32", ptr: cb.ptr } });
const inc = lib.symbols.inc;
inc(41);       // 42
lib.close();   // tcc_delete frees the trampoline pages
inc(1);        // SEGV — jump into freed JIT memory
panic(main thread): Segmentation fault at address 0x3CC1F383A04

Fix

  • JSFFIFunction::createForFFI now always routes through the static trampoline host function (previously Windows-only), so the TinyCC pointer lives in the mutable m_function field rather than being baked into a NativeExecutable.
  • trampoline checks m_function for null and throws TypeError: Cannot call an FFI function after the library has been closed instead of dereferencing it.
  • Function.deinit (called from FFI.close()) nulls m_function via a new Bun__FFIFunction_setClosed before freeing the TCC state.

The indirection is one load + one indirect call per FFI invocation — the same cost Windows was already paying — and makes the freed pointer unreachable from the JS call path.

This is independent of #29858, which keeps the FFI wrapper alive while any symbol is GC-reachable; that PR does not guard user-initiated close().

Verification

New tests in test/js/bun/ffi/ffi.test.js cover linkSymbols (with and without args so both the FFIBuilder-wrapped and raw JSFFIFunction paths are exercised) and dlopen:

inc(1) after lib.close()
before ASAN SEGV in freed JIT pages
after TypeError: Cannot call an FFI function after the library has been closed

All existing test/js/bun/ffi/* tests pass.

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - May 4th, 2026

@robobun, your commit 039a89e has 1 failures in Build #51399 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29946

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

bun-29946 --bun

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

FFI runtime functions are rooted at creation; teardown now detaches generated JS trampolines via a new native hook (Bun__FFIFunction_setClosed) and releases GC roots when appropriate. The C++ trampoline now throws if the native pointer is cleared. Tests added for post-close behavior and idempotence.

Changes

Cohort / File(s) Summary
Zig FFI API Layer
src/bun.js/api/ffi.zig
Added extern "c" fn Bun__FFIFunction_setClosed(JSValue) void;. New logic roots runtime functions at creation (cb.protect()), calls Bun__FFIFunction_setClosed(...) during Function.deinit and releases GC root (unprotect()) when ffi_callback_function_wrapper == null. Reordered deinit so compiled-step cleanup and js_function detachment occur before val.state.deinit(). Added unused-parameter suppression (_ = globalThis).
C++ FFI Implementation
src/bun.js/bindings/JSFFIFunction.cpp, src/bun.js/bindings/JSFFIFunction.h
Exported native hook Bun__FFIFunction_setClosed(JSC::EncodedJSValue) added to mark a JSFFIFunction closed by nulling its native trampoline/function pointer and clearing the dynamic-library symbol reference. trampoline is now cross-platform and throws a TypeError when the stored native pointer is null. createForFFI simplified to route FFI calls through the trampoline; added public setFunction to update the stored function pointer.
FFI Regression Tests
test/js/bun/ffi/ffi.test.js
New tests (skipped on Windows ARM64) asserting that callables produced by linkSymbols and dlopen and their .native variants throw a synchronous TypeError with the closure message after lib.close(). Tests cover close() idempotence, symbol-as-callable cases, safety of lib.close() after symbol deletion + GC, and that JSCallback.close() does not detach an FFI symbol passed as the callback input.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: preventing use-after-free by throwing instead of jumping into freed JIT memory on FFI function calls after close().
Description check ✅ Passed The description thoroughly covers the problem (use-after-free in FFI), the fix (static trampoline with null checks), and verification (new tests). It matches the required template structure with problem, repro, fix, and verification sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/bun/ffi/ffi.test.js`:
- Around line 978-1034: The tests currently call lib.close() directly and may
exit before it runs on assertion failures; wrap the usage of each linked/dlopen
library (the lib created in the "linkSymbols: throws...", "linkSymbols:
zero-arg...", and "dlopen: throws..." tests) in a try/finally and call
lib.close() in the finally block (similar to how cb.close() is handled) so the
native library is always closed even on early failures; locate the lib variable
in these tests and add try/finally around the assertions, calling lib.close() in
the finally.
🪄 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: 5413e2e4-874d-4414-a537-cd25add860b4

📥 Commits

Reviewing files that changed from the base of the PR and between 3e65f14 and 64763dd.

📒 Files selected for processing (1)
  • test/js/bun/ffi/ffi.test.js

Comment thread test/js/bun/ffi/ffi.test.js
Comment thread src/runtime/ffi/ffi.zig
Comment thread src/runtime/ffi/ffi.zig

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/api/ffi.zig`:
- Around line 813-815: When cc() aborts mid-loop the CompileC defer path
(CompileC.deinit() / SymbolsMap.deinit()) skips per-symbol teardown so
JSFFIFunction roots and their TCC.State leak; modify the abort/unwind path to
call each Function's teardown: locate the symbols map used by cc() and in
CompileC.deinit() (or the cc() defer that runs on error) iterate the stored
JSFFIFunctions and invoke the Function deinit/close routine (e.g.,
Function.deinit() or the JSFFIFunction-specific detach/unprotect method) so that
cb.protect()ed roots are unprotected and each per-symbol TCC.State is freed on
error. Ensure this cleanup runs before SymbolsMap.deinit() completes to avoid
leaks.
🪄 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: 20540e52-31d1-4686-8bb5-3c7243ef659a

📥 Commits

Reviewing files that changed from the base of the PR and between b5a09e5 and 104eb88.

📒 Files selected for processing (2)
  • src/bun.js/api/ffi.zig
  • test/js/bun/ffi/ffi.test.js

Comment thread src/runtime/ffi/ffi.zig

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/bun.js/api/ffi.zig:1170-1172 — The new cb.protect() is balanced by unprotect() only inside Function.deinit(), but several mid-loop error paths in open() (dlopen) and linkSymbols() never call deinit() on earlier-compiled symbols, so those JSFFIFunctions become permanent GC roots. Affected: dlopen's symbol-not-found path (1115-1121), linkSymbols' missing-ptr path (1231-1235), .failed (1252-1259) and .pending (1263-1267); the cc() path at line 815 has the same issue (already flagged by CodeRabbit). The TCC.State leak on these paths is pre-existing, but the GC-root leak is new in this PR — the simplest fix is to make each cleanup loop call value.deinit(global) (mirroring the compile() catch branches that already do).

    Extended reasoning...

    What the bug is

    This PR adds cb.protect() immediately after each JSFFIFunction is created in the per-symbol compile loops of cc() (line 815), open()/dlopen (line 1172) and linkSymbols() (line 1284). The matching unprotect() lives only inside Function.deinit() (gated on ffi_callback_function_wrapper == null). On the happy path that's fine — FFI.close() iterates this.functions and calls deinit() on every value.

    But each of these loops also has mid-loop error-return paths that perform partial cleanup which never reaches Function.deinit() for the entries that already succeeded. On those paths, every cb.protect() taken on earlier iterations is never balanced, permanently rooting those JSFFIFunction cells in the GC.

    The specific code paths

    open() (dlopen), ffi.zig:1108-1176 — the dylib.lookup() failure branch at 1113-1121 loops symbols.values() and only does free(base_name) + arg_types.clearAndFree(). It never calls value.deinit(). Contrast with the three other error paths in the same function (compile() catch at 1133-1135, .failed at 1142-1144, .pending at 1152-1154), which do call value.deinit(global) on every entry and are therefore fine — only the symbol-not-found path leaks.

    linkSymbols(), ffi.zig:1226-1289 — three branches leak:

    • missing-ptr at 1229-1237: cleanup loop only frees base_name + arg_types;
    • .failed at 1251-1260: same partial frees, then function.deinit() only on the current (failed, never-protected) symbol;
    • .pending at 1262-1268: same partial frees.

    Only the compile() catch path (1244-1246) correctly calls value.deinit(global) for all values.

    cc(), ffi.zig:781-818 — already flagged by CodeRabbit at line 815: on compile() throw / .failed / .pending, the only cleanup is the defer { if (hasException()) compile_c.deinit(); } at 623-627, and CompileC.deinit()SymbolsMap.deinit() (546-551) iterates only map.keys() to free key strings — it never walks Function values, so Function.deinit() never runs.

    Why existing code doesn't prevent it

    The partial-cleanup loops predate this PR; they were written when a Function whose step is .compiled held nothing that needed releasing beyond base_name/arg_types/the TCC state. Now that .compiled also implies a live gcProtect(), the only routine that releases it is Function.deinit(), and these branches simply don't call it. The asymmetry with the sibling branches that do call deinit() (e.g. dlopen's compile() catch) shows the omission is accidental rather than by design.

    Step-by-step proof

    StringArrayHashMap preserves insertion order, so:

    // linkSymbols, missing-ptr branch
    linkSymbols({
      a: { returns: "i32", ptr: somePtr },   // iteration 0
      b: { returns: "i32" },                 // iteration 1 — no ptr
    });
    1. Iteration 0: a has a ptr → skips the missing-ptr check → compile() succeeds → .compiledcb.protect() runs at line 1284.
    2. Iteration 1: b.symbol_from_dynamic_library == null → enters 1229-1237. The cleanup loop frees base_name/arg_types for both entries, then symbols.clearAndFree() and return ret.
    3. a's Function.deinit() is never reached, so a's unprotect() never runs. The JSFFIFunction for a is now a permanent GC root for the lifetime of the VM.

    The dlopen analogue is equally easy: dlopen(libc, { strlen: {...}, nonexistent_xyz: {...} })strlen compiles and is protected, then nonexistent_xyz fails dylib.lookup() and hits 1115-1121.

    Impact

    Each occurrence permanently pins one JSFFIFunction cell per already-compiled symbol. The objects are small, but the leak is unbounded if the user retries (e.g. probing for optional symbols in a loop), and it's reachable from very ordinary public-API misuse like a typo'd symbol name. The per-symbol TCC.State leak on the same paths is pre-existing — these branches never freed val.state either — but the GC-root leak is new in this PR because the protect() calls are new.

    How to fix

    Make each affected cleanup loop call value.deinit(global) instead of (or in addition to) the manual free(base_name)/arg_types.clearAndFree(), mirroring what the compile() catch branches at 1133-1135 / 1244-1246 already do — Function.deinit() already handles base_name, arg_types, the new unprotect(), and state.deinit() in one place. For cc(), either iterate compile_c.symbols.map.values() calling deinit() in the error defer, or have SymbolsMap.deinit() walk values as well as keys.

    🔬 also observed by coderabbitai

Comment thread src/runtime/ffi/ffi.zig
Comment thread src/runtime/ffi/ffi.zig

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

All four issues I flagged earlier are now addressed and the current revision looks correct to me, but given this touches FFI/JIT memory lifetime + GC rooting across several error-unwind paths and changes the non-Windows hot-path dispatch (every FFI call now goes through the trampoline indirection), I'd like a human to give the final state a once-over before merge.

Extended reasoning...

Overview

This PR makes calling a bun:ffi symbol after lib.close() throw a TypeError instead of jumping into freed TinyCC JIT pages. It touches four files: src/bun.js/api/ffi.zig (per-symbol protect()/unprotect() rooting, Function.deinit teardown reordering, several mid-loop error-path cleanup rewrites, new Bun__FFIFunction_setClosed extern), src/bun.js/bindings/JSFFIFunction.{cpp,h} (cross-platform trampoline with null check, new setFunction() setter, createForFFI now always routes through the trampoline instead of baking the TinyCC pointer into the NativeExecutable), and test/js/bun/ffi/ffi.test.js (six new regression tests).

Security risks

The change is strictly safety-improving on the headline path (SEGV → catchable TypeError). The new protect()/unprotect() pairing and the dynamicDowncast in Bun__FFIFunction_setClosed introduced their own UAF/leak hazards during review, but all four were fixed (JSCallback-path gating, GC rooting of compiled.js_function, and two rounds of mid-loop error-unwind balancing including the defer-after-clearAndFree() ordering bug). I don't see remaining security issues in the final diff.

Level of scrutiny

High. This is native memory-safety code at the FFI/JSC-GC boundary: it adds GC roots that must be balanced across ~7 error-unwind branches in open()/linkSymbols()/cc(), reorders state.deinit() relative to js_function detachment, and changes how every FFI call dispatches on non-Windows (one extra load + indirect call per invocation, matching what Windows already paid). The fact that it took four bug-hunter iterations to converge — each finding a real correctness issue — is itself a signal that the invariants here are subtle.

Other factors

Test coverage for the new behavior is good (both FFIBuilder-wrapped and raw JSFFIFunction paths, idempotent close(), delete-symbol-then-GC-then-close, mid-loop failure, JSCallback-borrowed-symbol). All earlier review threads (mine and CodeRabbit's) are resolved. What I'd want a human to sanity-check: (1) the perf tradeoff of always-trampoline on non-Windows is acceptable, (2) the final set of error-unwind branches in open()/linkSymbols() is now exhaustively balanced, and (3) the interaction with #29858's GC-reachability changes is as described.

robobun and others added 9 commits May 4, 2026 10:27
… into freed JIT memory

JSFFIFunction::createForFFI previously baked the TinyCC-compiled
trampoline address directly into a NativeExecutable on non-Windows
platforms. When lib.close() ran tcc_delete and freed the JIT pages, the
JSFFIFunction (which is still reachable via the cached symbols object
or any user-held reference) still pointed its executable at the freed
code, so calling it jumped into unmapped/reused memory.

Route all createForFFI() calls through the static trampoline (the same
path Windows already used) so the native pointer lives in the mutable
m_function field. On close(), Function.deinit now nulls m_function via
Bun__FFIFunction_setClosed before freeing the TCC state; the trampoline
checks for null and throws a TypeError instead of dereferencing freed
memory.
Function.deinit is shared between the dlopen/linkSymbols/cc paths
(where step.compiled.js_function is the owned JSFFIFunction whose
trampoline lives in the TCC state being freed) and the JSCallback path
(where it is the user-supplied callback, which may itself be a
JSFFIFunction from a still-open library). Only detach on the former,
identified by ffi_callback_function_wrapper == null.
compiled.js_function is stored in the Zig heap and was only reachable
from GC via the mutable symbols object. If the user deleted a symbol
from lib.symbols and a GC ran before lib.close(), Function.deinit
would pass a stale encoded JSValue to Bun__FFIFunction_setClosed,
which dereferences it via dynamicDowncast.

Protect each JSFFIFunction when storing it in compiled.js_function
(dlopen/linkSymbols/cc paths only; the JSCallback path already roots
the user callback via FFICallbackFunctionWrapper's Strong<JSFunction>)
and unprotect it in Function.deinit after detaching.
SymbolsMap.deinit (reached via CompileC.deinit when cc() throws after
some symbols have already been compiled) previously only freed the map
keys. Each Function value's TCC.State, arg_types, and now the
protect()ed JSFFIFunction root were leaked. Since each map key aliases
the Function's base_name allocation, route the per-key cleanup through
Function.deinit instead, which frees base_name plus the rest.

Split out Function.deinitWithoutGlobal so SymbolsMap can call it without
a JSGlobalObject (the parameter was already unused).
dlopen's symbol-not-found branch and linkSymbols' missing-ptr / .failed
/ .pending branches did partial cleanup (free base_name + arg_types)
instead of calling Function.deinit on each value, so any symbol that
had already reached .compiled on an earlier iteration leaked its
protect()ed JSFFIFunction root and TCC.State. The sibling compile()
catch branches already used value.deinit(); make these consistent.

Also fixes a pre-existing double-free in linkSymbols' .failed branch
where base_name was freed in the partial-cleanup loop and then again
via function.deinit() on the current (failed) symbol.
…fter

The deferred for-loop evaluated after symbols.clearAndFree() had already
emptied the map, so it iterated nothing and no Function.deinit ever ran
on this path (pre-existing; now also leaks the protect() root). Match
the .pending branch and the fixed linkSymbols .failed: capture err.msg
first, then run the deinit loop inline before clearAndFree().
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/9ef4a177/ffi-close-uaf branch from 3627839 to 4e34f76 Compare May 4, 2026 10:27
Comment thread src/runtime/ffi/ffi.zig
@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #31960. This PR implemented the fix against src/bun.js/api/ffi.zig, but the Rust port (#30412) moved the implementation to src/runtime/ffi/ffi_body.rs and the Zig file is now a non-compiled porting reference, so these changes can no longer take effect. #31960 applies the same design (trampoline on all platforms, invalidate on close) to the current implementation, plus coverage for the CFunction finalization registry and the finalize() leak path.

@robobun robobun closed this Jun 7, 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