Skip to content

bun:ffi: fix double-free of base_name in linkSymbols error paths - #29974

Closed
robobun wants to merge 1 commit into
mainfrom
farm/aba68533/ffi-linksymbols-double-free
Closed

bun:ffi: fix double-free of base_name in linkSymbols error paths#29974
robobun wants to merge 1 commit into
mainfrom
farm/aba68533/ffi-linksymbols-double-free

Conversation

@robobun

@robobun robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

Repro

const { linkSymbols, JSCallback } = require("bun:ffi");
const cb = new JSCallback(() => {}, { returns: "void", args: [] });
linkSymbols({
  "not a valid C identifier!": { ptr: cb.ptr, args: [], returns: "void" },
});

The symbol name is emitted verbatim into the generated C source, so TinyCC fails to compile it and linkSymbols enters the .failed cleanup branch.

Under a debug/ASAN build this aborts with:

AddressSanitizer: use-after-poison
  #2 Function.deinit     src/bun.js/api/ffi.zig:1468
  #3 FFI.linkSymbols     src/bun.js/api/ffi.zig:1252

Cause

In the .failed branch the cleanup loop does:

for (symbols.values()) |*value| {
    allocator.free(@constCast(bun.asByteSlice(value.base_name.?)));
    value.arg_types.clearAndFree(allocator);
}
...
function.deinit(global);

function is one of symbols.values(), so its base_name is freed in the loop and then freed again inside Function.deinit. The .pending and missing-ptr branches use the same manual-free pattern and additionally leak the TCC state (and step.failed.msg) of any symbols that already compiled successfully before the failure.

Fix

Call value.deinit(global) for every symbol in all three error branches, matching the adjacent compile() catch branch and the equivalent cleanup in dlopen. In the .failed branch, construct the error instance before the loop since err.msg is owned by function.step.

Verification

  • bun bd test test/js/bun/ffi/ffi-error-messages.test.ts — 6 pass
  • With src/ stashed, the new test aborts under ASAN with the trace above; with the fix it passes.

When a symbol fails to compile in linkSymbols(), the .failed cleanup
branch manually freed value.base_name for every symbol in the map and
then called function.deinit() on the failing one. Since function is one
of symbols.values(), its base_name was freed twice.

Replace the manual field frees in all three linkSymbols error branches
with value.deinit(global), matching the adjacent compile() catch branch
and the equivalent paths in dlopen. This also ensures previously
compiled symbols' TCC state and step.failed.msg are released instead of
leaked.
@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 7 minutes and 23 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 380263c5-6169-4021-8227-0b0ca0c01493

📥 Commits

Reviewing files that changed from the base of the PR and between 360bbb5 and fcd97a2.

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

Review rate limit: 0/5 reviews remaining, refill in 7 minutes and 23 seconds.

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

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:48 AM PT - Apr 30th, 2026

@robobun, your commit fcd97a2 has 1 failures in Build #49348 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29974

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

bun-29974 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. bun:ffi: throw when calling a symbol after close() instead of jumping into freed JIT memory #29946 - Superset of this PR: contains the identical linkSymbols error-path fix (replacing manual allocator.free(base_name) + arg_types.clearAndFree() with value.deinit(global) in all three error branches), plus an additional close-after-free guard

🤖 Generated with Claude Code

@robobun

robobun commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as duplicate of #29946, which contains the identical linkSymbols error-path fix (all three branches → value.deinit(global)) plus the dlopen defer-after-clearAndFree leak fix and a close()-after-free guard.

The one bit of extra coverage here is a test that triggers the .failed branch specifically via TinyCC compile failure (invalid C identifier as symbol name) rather than missing-ptr — branch farm/aba68533/ffi-linksymbols-double-free if it's wanted.

@robobun robobun closed this Apr 30, 2026

@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 — clean fix replacing ad-hoc partial cleanup with Function.deinit(), with a regression test. The two inline notes are pre-existing issues (not introduced or worsened here) and can be follow-ups.

Extended reasoning...

Overview

This PR fixes a double-free in FFI.linkSymbols() (src/bun.js/api/ffi.zig) by replacing the manual free(base_name); arg_types.clearAndFree() pattern in three error branches (missing-ptr, .failed, .pending) with the canonical value.deinit(global) loop. The .failed branch previously freed base_name for every symbol in the loop and then called function.deinit() on the failing symbol, freeing its base_name a second time — a real ASAN-detected use-after-poison. The fix mirrors the adjacent compile() catch branch which already does this correctly. A subprocess-isolated regression test is added to test/js/bun/ffi/ffi-error-messages.test.ts.

Security risks

None. This is error-path resource cleanup in the FFI module; no new user input is parsed, no auth/crypto/permissions touched. The change strictly reduces memory-safety risk by eliminating a double-free and several leaks (TCC state, step.failed.msg) on error paths.

Level of scrutiny

Medium-low. FFI memory management is sensitive in principle, but this change is small and mechanical: three identical hunks each replacing a two-line manual-free body with a single value.deinit(global) call, plus reordering in the .failed branch so err.msg is read before its owner is freed. Function.deinit() already correctly handles base_name, arg_types, state, the compiled callback wrapper, and step.failed.msg, so delegating to it is strictly safer than the open-coded subset. The .failed branch no longer calls function.deinit() separately because function is one of symbols.values() and is covered by the loop.

Other factors

  • The PR description includes a clear repro, ASAN trace, root-cause analysis, and verification that the new test fails without the fix and passes with it.
  • Both inline bug-hunter comments are explicitly tagged [Pre-existing]: (1) two analogous leak patterns remain in FFI.open() (dlopen), and (2) ZigString.init(err.msg).toErrorInstance() may create a non-copying string whose backing buffer is freed by the subsequent deinit — but the old code had the identical toErrorInstancefunction.deinit() sequence, so this PR neither introduces nor worsens it. These are reasonable follow-ups but do not block this fix.
  • No CODEOWNERS entry covers this path; no outstanding human review comments.

Comment thread src/bun.js/api/ffi.zig
Comment on lines 1225 to 1227
for (symbols.values()) |*value| {
allocator.free(@constCast(bun.asByteSlice(value.base_name.?)));
value.arg_types.clearAndFree(allocator);
value.deinit(global);
}

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.

🟣 Pre-existing, but worth noting since the PR description cites dlopen's cleanup as the model: FFI.open() still has the same leak class in two error paths. The dylib.lookup() orelse branch (~ffi.zig:1110-1118) uses the manual free(base_name); arg_types.clearAndFree() pattern instead of value.deinit(global), leaking the TCC.State of any previously-compiled symbol; and the .failed branch (~ffi.zig:1138-1146) registers the deinit loop with defer but then calls symbols.clearAndFree() inline, so the deferred loop runs over an already-empty map and frees nothing. Both are one-line fixes identical in spirit to the linkSymbols changes here and would be a natural addition to this PR.

Extended reasoning...

What the bug is

This PR fixes the error-cleanup paths in FFI.linkSymbols() by replacing the manual free(base_name); arg_types.clearAndFree() pattern with value.deinit(global), and the PR description explicitly cites "the equivalent cleanup in dlopen" as the model. However, FFI.open() (the dlopen implementation) still contains two error paths with the same leak class that this PR fixes:

  1. The dylib.lookup() orelse branch (around src/bun.js/api/ffi.zig:1110-1118) still uses the manual-free pattern:

    const resolved_symbol = dylib.lookup(*anyopaque, function_name) orelse {
        const ret = global.toInvalidArguments("Symbol \"{s}\" not found in \"{s}\"", ...);
        for (symbols.values()) |*value| {
            bun.default_allocator.free(@constCast(bun.asByteSlice(value.base_name.?)));
            value.arg_types.clearAndFree(bun.default_allocator);
        }
        symbols.clearAndFree(bun.default_allocator);
        dylib.close();
        return ret;
    };

    This frees base_name and arg_types but never calls Function.deinit(), so it never frees state (the TCC.State) or step.failed.msg.

  2. The .failed branch (around src/bun.js/api/ffi.zig:1138-1146) does call deinit, but via defer:

    .failed => |err| {
        defer for (symbols.values()) |*other_function| {
            other_function.deinit(global);
        };
        const res = ZigString.init(err.msg).toErrorInstance(global);
        symbols.clearAndFree(bun.default_allocator);
        dylib.close();
        return res;
    },

    In Zig, the defer body runs at scope exit — after symbols.clearAndFree() has already executed inline. By the time the deferred loop runs, symbols.values() returns an empty slice, so the loop iterates zero times and nothing is freed.

Code path that triggers it

The open() loop processes each symbol completely (lookup → compile() → switch on step) before moving to the next iteration. Function.compile() allocates a TCC.State and stores it in function.state on success.

Step-by-step proof for case (1)dlopen(lib, { good: {...}, missing: {...} }) where good exists in the library and missing does not:

  1. Iteration 1: dylib.lookup("good") succeeds → good.compile() runs → allocates a TCC.State into good.stategood.step = .compiled.
  2. Iteration 2: dylib.lookup("missing") returns null → enters the orelse branch.
  3. The cleanup loop runs over both symbols and frees only base_name + arg_types for each.
  4. good.state (a live TCC.State) is never freed → leaked.

Step-by-step proof for case (2)dlopen(lib, { good: {...}, bad: {...} }) where both resolve in the library but TinyCC compilation of bad fails (e.g. handleTCCError sets bad.step = .failed):

  1. Iteration 1: good is looked up and compiled → good.state holds a TCC.State.
  2. Iteration 2: bad is looked up, compile() runs, TinyCC reports an error → bad.step = .failed with an allocated err.msg.
  3. The .failed arm registers defer for (symbols.values()) |*f| f.deinit(global);.
  4. res is built from err.msg.
  5. symbols.clearAndFree() runs inline — the map's backing arrays are freed and values() is now empty.
  6. return res; triggers the defer. symbols.values() returns a zero-length slice, so the deinit loop body never executes.
  7. Net result: good.base_name, good.arg_types, good.state (TCC.State), bad.base_name, bad.arg_types, and bad.step.failed.msg all leak.

Why existing code doesn't prevent it

For (1), the adjacent compile() catch, .failed, and .pending branches in open() all already call value.deinit(global) — the dylib.lookup() orelse branch is simply the one that was missed when the others were converted. For (2), the code appears correct at a glance because it does call deinit on every value, but Zig's defer semantics mean the loop's iteration source (symbols.values()) is evaluated at scope-exit time, after clearAndFree() has emptied it. This is exactly the ordering bug this PR avoids in the linkSymbols .failed branch by running the deinit loop inline (after building res, before clearAndFree).

Impact

Each leaked TCC.State is a full TinyCC compiler state (relocated code, symbol tables, etc.) — typically tens of KB or more per symbol. In long-running processes that probe for symbols or repeatedly attempt dlopen against libraries with partial symbol coverage, this accumulates. The .failed path additionally leaks every base_name, every arg_types buffer, and the duplicated TinyCC error message string. These are not crashes or correctness bugs in user-visible behavior — they are pure memory leaks on the error path — but they are the same class of bug this PR is explicitly fixing in linkSymbols.

How to fix

Both are one-line changes mirroring this PR's linkSymbols edits:

  • dylib.lookup() orelse branch: replace the loop body with value.deinit(global); (identical to the change at lines 1225-1227 of this PR).
  • .failed branch: drop the defer and run the deinit loop inline after building res but before symbols.clearAndFree():
    .failed => |err| {
        const res = ZigString.init(err.msg).toErrorInstance(global);
        for (symbols.values()) |*other_function| {
            other_function.deinit(global);
        }
        symbols.clearAndFree(bun.default_allocator);
        dylib.close();
        return res;
    },
    This is exactly the structure this PR uses for the linkSymbols .failed branch at lines 1244-1252.

These are pre-existing — the PR doesn't touch FFI.open() — but since the PR description holds dlopen up as the reference implementation, it's worth flagging that the reference has the same bug.

Comment thread src/bun.js/api/ffi.zig
Comment on lines +1245 to 1250
// Build the error before deinit: `err.msg` is owned by `function.step`
// and `function` is one of `symbols.values()`.
const res = ZigString.init(err.msg).toErrorInstance(global);
for (symbols.values()) |*value| {
allocator.free(@constCast(bun.asByteSlice(value.base_name.?)));
value.arg_types.clearAndFree(allocator);
value.deinit(global);
}

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.

🟣 Pre-existing, but worth noting since this PR's new comment implies the ordering makes it safe: ZigString.init(err.msg).toErrorInstance() does not copy the bytes — Zig::getErrorInstance uses toString() (unlike its siblings getTypeErrorInstance/getSyntaxErrorInstance/getRangeErrorInstance which use toStringCopy()), so for an untagged Latin1 ZigString it hits WTF::StringImpl::createWithoutCopying and the returned Error's .message directly references err.msg's heap buffer. After the value.deinit(global) loop frees step.failed.msg, reading e.message from JS is a use-after-free. The same pattern exists in dlopen's .failed branch and FFI.callback's .failed branch; the simplest local fix is ZigString.fromUTF8(err.msg).toErrorInstance(global) (or change getErrorInstance in helpers.h to use toStringCopy).

Extended reasoning...

What the bug is

Building the JS Error before the deinit loop is necessary but not sufficient, because ZigString.init(...).toErrorInstance() does not copy the message bytes for plain Latin1 strings. The returned Error object's .message property is a JSString whose StringImpl points directly into err.msg's heap allocation. The subsequent value.deinit(global) loop frees that allocation (via Function.deinitval.allocator.free(val.step.failed.msg) when .allocated == true), so any later read of e.message from JavaScript dereferences freed memory.

Code path

  1. err.msg is heap-allocated. TinyCC reports the compilation error through handleTCCError (ffi.zig:1531), which sets this.step = .{ .failed = .{ .msg = this.allocator.dupe(u8, msg), .allocated = true } }.

  2. ZigString.init(err.msg) produces an untagged pointer. ZigString.init (ZigString.zig:481-483) just stores .{ ._unsafe_ptr_do_not_use = slice.ptr, .len = slice.len } — no UTF-8 tag, no UTF-16 tag, no external/global tag.

  3. toErrorInstanceZig::getErrorInstancetoString() takes the no-copy path. In src/bun.js/bindings/helpers.h, getErrorInstance (line ~383) calls toString(*str). For an untagged, non-UTF8, non-external Latin1 ZigString, toString() falls through to its final branch (helpers.h:115-118):

    return WTF::String(WTF::StringImpl::createWithoutCopying({ untag(str.ptr), str.len }));

    Note the contrast: getTypeErrorInstance, getSyntaxErrorInstance, and getRangeErrorInstance (helpers.h:395-413) all call toStringCopy(*str). getErrorInstance is the lone outlier that uses non-copying toString().

  4. JSC::createError stores the same StringImpl. createError does putDirect(vm.propertyNames->message, jsString(vm, message)); jsString just refs the existing StringImpl for length > 1, no byte copy.

  5. The deinit loop frees the buffer. Back in linkSymbols, the loop reaches the failing function and Function.deinit executes if (val.step == .failed and val.step.failed.allocated) val.allocator.free(val.step.failed.msg). The JSString backing the returned Error's .message now points into a freed mimalloc allocation.

  6. JS reads dangling memory. When user code does String(e.message), console.log(e), or the error propagates to top-level, the freed buffer is dereferenced.

Why ordering alone doesn't help

The PR's comment ("Build the error before deinit: err.msg is owned by function.step") correctly addresses construction-time safety — you can't pass a freed pointer to ZigString.init. But it does not address post-return lifetime: the constructed JSString still aliases the buffer with no ownership, so freeing after construction is just as bad as freeing before. The comment encodes a stronger invariant than actually holds.

Step-by-step proof

Take the PR's own new test case:

linkSymbols({ "not a valid C identifier!": { ptr: cb.ptr, args: [], returns: "void" } });
  • TinyCC fails to compile the generated C (the symbol name is emitted verbatim), and handleTCCError heap-allocates the diagnostic into step.failed.msg with .allocated = true.
  • linkSymbols enters the .failed branch, builds res = ZigString.init(err.msg).toErrorInstance(global)res.message's StringImpl now points at the duped diagnostic buffer.
  • The deinit loop frees that buffer.
  • The test only checks threw = true and never touches e.message, so ASAN doesn't flag it. Change the catch to catch (e) { threw = true; String(e.message); } and run under ASAN to observe the heap-use-after-free.

Status and scope

This is pre-existing: the old code did exactly the same thing (const res = ZigString.init(err.msg).toErrorInstance(global); function.deinit(global);). The PR neither introduces nor worsens it. The identical pattern also exists in:

  • dlopen's .failed branch (const res = ZigString.init(err.msg).toErrorInstance(global) followed by defer for ... other_function.deinit(global))
  • FFI.callback's .failed branch (const message = ZigString.init(err.msg).toErrorInstance(globalThis); func.deinit(globalThis);)
  • Bun__FFI__cc's .failed branch

Flagging it here because the PR rewrites these exact lines for memory-safety reasons and the new comment reads as though the lifetime issue is handled.

Fix

Either of:

  • Call site: const res = ZigString.fromUTF8(err.msg).toErrorInstance(global);fromUTF8 sets the UTF-8 tag, so toString() takes the fromUTF8ReplacingInvalidSequences path which allocates a copy.
  • Root cause: change getErrorInstance in src/bun.js/bindings/helpers.h to use toStringCopy(*str) like getTypeErrorInstance/getSyntaxErrorInstance/getRangeErrorInstance already do. This fixes all four call sites at once.

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