Skip to content

socket: guard flush() with isDetached() to prevent UAF on TLS close - #30136

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/cc674a0d/tls-flush-after-end-uaf
May 3, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/cc674a0d/tls-flush-after-end-uaf

Conversation

@robobun

@robobun robobun commented May 2, 2026

Copy link
Copy Markdown
Collaborator

Repro

const client = await Bun.connect({ hostname, port, tls, socket: { ... } });
// after handshake:
client.end("x");
client.flush();   // ← second markInactive frees *Handlers
// peer replies close_notify → onClose derefs freed Handlers

ASAN on debug build:

==4075==ERROR: AddressSanitizer: use-after-poison on address 0x7aff355e0469
READ of size 1 at 0x7aff355e0469 thread T0
    #0 bun.js.api.bun.socket.NewSocket(true).onClose  src/bun.js/api/bun/socket.zig:661:46
    #1 deps.uws.handlers.PtrHandler(...).onClose      src/deps/uws/handlers.zig:49:61
    ...
    #5 us_internal_ssl_on_close                       packages/bun-usockets/src/crypto/openssl.c:940:29

Cause

end() → internalFlush → canEndAfterFlush() → markInactive() → closeAndDetach(.normal) detaches this.socket and calls us_socket_close(code=0). For TLS with code==0, us_internal_ssl_close sends close_notify and defers the raw close until the peer replies (so the loop stays alive to receive it). markInactive returns early without clearing is_active, relying on the eventual onClose → markInactive to run handlers.markInactive() and free the client-mode *Handlers.

flush() was the only internalFlush() caller without an isDetached() guard. Calling it in that window re-enters canEndAfterFlush() (still is_active && end_after_flush) → markInactive(), which now sees the detached socket as closed and runs the full teardown: handlers.markInactive() → active_connections == 0 → vm.allocator.destroy(handlers). When the peer's close_notify later arrives, onClose calls this.getHandlers() on freed memory.

Fix

Add the same isDetached() early-return to flush() that end(), endBuffered(), onWritable, and every other internalFlush() caller already have.

Verification

New test in test/js/bun/net/socket.test.ts spawns a TLS client that does end("x"); flush(); flush(); after handshake and awaits close.

  • Without fix (git stash -- src/): subprocess aborts with the ASAN trace above; test fails.
  • With fix: subprocess prints OK and exits 0; test passes.

On a TLS client, end() → internalFlush → markInactive → closeAndDetach(.normal)
detaches this.socket and sends close_notify, but defers the raw close until
the peer replies — leaving is_active set so the eventual onClose can release
the Handlers allocation.

flush() was the only internalFlush() caller without an isDetached() guard, so
calling flush() in that window re-entered markInactive, saw the detached
socket as closed, and ran the full teardown — vm.allocator.destroy(handlers).
When the peer's close_notify then arrived, onClose dereferenced the freed
*Handlers (ASAN use-after-poison at socket.zig onClose).

Add the same isDetached() early-return that end(), endBuffered(), onWritable,
etc. already have.
@github-actions github-actions Bot added the claude label May 2, 2026
@robobun

robobun commented May 2, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:30 PM PT - May 2nd, 2026

@robobun, your commit 7f1608b is building: #50319

@coderabbitai

coderabbitai Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 57cbeab1-3a78-450b-b345-0b9d9b851ed2

📥 Commits

Reviewing files that changed from the base of the PR and between fcd764b and 7f1608b.

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

Walkthrough

This PR fixes a re-entrancy bug in Socket.flush() by adding an early guard that returns when the socket is already detached, preventing premature memory deallocation. A test is added to validate the fix for calling flush() after end().

Changes

Socket Flush Re-entrancy Fix

Layer / File(s) Summary
Core Bug Fix
src/bun.js/api/bun/socket.zig
flush() now checks socket.isDetached() and returns early with js_undefined, preventing internalFlush()/markInactive() from executing a re-entrant sequence that frees *Handlers prematurely before the deferred onClose path runs.
Test Coverage
test/js/bun/net/socket.test.ts
New concurrent test added: spawns a TLS server/subprocess, connects a TLS client, calls end() followed by flush() twice, then verifies subprocess completes cleanly (no ASAN violation, exit code 0). Harness import expanded to include tempDir.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: adding an isDetached() guard to flush() to prevent use-after-free on TLS close.
Description check ✅ Passed The description comprehensively covers the repro case, root cause analysis, the fix, and verification with test evidence, going well beyond the template requirements.
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.


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

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

@github-actions

github-actions Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun crashes with segmentation fault when network closes unexpectedly (Knex + PostgreSQL via Neon) #20104 - Segfault during TLS socket close teardown (Knex + PostgreSQL via Neon); stack trace goes through ssl_on_close → ssl_wrapped_context_on_close → NewSocketHandler, consistent with the UAF pattern where onClose dereferences freed handlers after a detached socket's flush() re-enters markInactive()

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

Fixes #20104

🤖 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 — one-line guard matching the established pattern in every other internalFlush() caller, with a clear ASAN repro and regression test.

Extended reasoning...

Overview

Adds a single if (this.socket.isDetached()) return .js_undefined; guard to the JS-exposed flush() method in src/bun.js/api/bun/socket.zig, plus a subprocess-isolated regression test in test/js/bun/net/socket.test.ts. The PR description includes an ASAN trace and a precise root-cause analysis of how end() → deferred TLS close leaves is_active set, allowing a follow-up flush() to re-enter markInactive() and free *Handlers before the deferred onClose runs.

Verification

I confirmed the PR's claim by grepping all internalFlush() call sites: onWritable (guarded at :250), writeBuffered/endBuffered (guarded at :948/:962), and end (guarded at :1337) all already early-return on isDetached(). flush() was the only outlier. The added guard is byte-for-byte the same idiom used in ~25 other methods in this file.

Security risks

None introduced. The change strictly narrows behavior — calling flush() on a detached socket now no-ops instead of proceeding into internalFlush() → markInactive(). This closes a use-after-free, so the security posture only improves.

Level of scrutiny

Low-to-moderate. The source change is a one-line defensive guard following an established, repeated pattern in the same file. The test is additive, runs in a subprocess (so an ASAN abort can't take down the test runner), and uses existing harness helpers (tempDir, bunExe, bunEnv, tls).

Other factors

No prior reviewer comments to address. The bug-hunting system found no issues. The PR description demonstrates the test fails without the fix (ASAN abort) and passes with it.

@Jarred-Sumner
Jarred-Sumner merged commit 9615455 into main May 3, 2026
76 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/cc674a0d/tls-flush-after-end-uaf branch May 3, 2026 00:15
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…ven-sh#30136)

## Repro

```js
const client = await Bun.connect({ hostname, port, tls, socket: { ... } });
// after handshake:
client.end("x");
client.flush();   // ← second markInactive frees *Handlers
// peer replies close_notify → onClose derefs freed Handlers
```

ASAN on debug build:

```
==4075==ERROR: AddressSanitizer: use-after-poison on address 0x7aff355e0469
READ of size 1 at 0x7aff355e0469 thread T0
    #0 bun.js.api.bun.socket.NewSocket(true).onClose  src/bun.js/api/bun/socket.zig:661:46
    oven-sh#1 deps.uws.handlers.PtrHandler(...).onClose      src/deps/uws/handlers.zig:49:61
    ...
    oven-sh#5 us_internal_ssl_on_close                       packages/bun-usockets/src/crypto/openssl.c:940:29
```

## Cause

`end()` → `internalFlush` → `canEndAfterFlush()` → `markInactive()` →
`closeAndDetach(.normal)` detaches `this.socket` and calls
`us_socket_close(code=0)`. For TLS with `code==0`,
`us_internal_ssl_close` sends close_notify and **defers** the raw close
until the peer replies (so the loop stays alive to receive it).
`markInactive` returns early without clearing `is_active`, relying on
the eventual `onClose` → `markInactive` to run `handlers.markInactive()`
and free the client-mode `*Handlers`.

`flush()` was the only `internalFlush()` caller without an
`isDetached()` guard. Calling it in that window re-enters
`canEndAfterFlush()` (still `is_active && end_after_flush`) →
`markInactive()`, which now sees the detached socket as closed and runs
the **full** teardown: `handlers.markInactive()` → `active_connections
== 0` → `vm.allocator.destroy(handlers)`. When the peer's close_notify
later arrives, `onClose` calls `this.getHandlers()` on freed memory.

## Fix

Add the same `isDetached()` early-return to `flush()` that `end()`,
`endBuffered()`, `onWritable`, and every other `internalFlush()` caller
already have.

## Verification

New test in `test/js/bun/net/socket.test.ts` spawns a TLS client that
does `end("x"); flush(); flush();` after handshake and awaits `close`.

- Without fix (`git stash -- src/`): subprocess aborts with the ASAN
trace above; test fails.
- With fix: subprocess prints `OK` and exits 0; test passes.

Co-authored-by: robobun <robobun@users.noreply.github.com>
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