Skip to content

Fix use-after-free in JSReadable*Controller end()/close() when onClose re-enters the event loop - #32597

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/40a5c9eb/http-sink-end-uaf
Jun 23, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/40a5c9eb/http-sink-end-uaf

Conversation

@robobun

@robobun robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Sentry BUN-2WJA / BUN-2WKB (~290 events combined, Windows x86_64, http_server=True, bun 1.2.23 through 1.3.14):

Segmentation fault at address 0xFFFFFFFFFFFFFFFF
  endWithSink      src/runtime/webcore/Sink.zig:577
  endFromJS        src/runtime/webcore/streams.zig:1200
  finalize         src/runtime/webcore/streams.zig:1301
  clearAndFree     src/collections/baby_list.zig:148
  memset           (fault at 0xFFFFFFFFFFFFFFFF)

Cause

The generated JSReadable*Controller end() and close() host functions (src/codegen/generate-jssink.ts) stash m_sinkPtr in a local, call controller->detach(), and only afterward dereference the stashed pointer via endWithSink() / ${name}__close():

void *ptr = controller->wrapped();
controller->detach();              // runs onClose JS synchronously
return ${name}__endWithSink(ptr, lexicalGlobalObject);  // derefs ptr

detach() invokes the stored onClose callback. For a type: "direct" stream this is readDirectStream's close(stream, reason), which calls underlyingSource.cancel(). That is arbitrary user code running while ptr is still live on the C++ stack.

If the stream's pull() promise has already settled, RequestContext::on_resolve_stream is sitting in the microtask queue. Any path from cancel() that drains microtasks (e.g. the server-side drain points in on_response / do_render_with_body, or an explicit drainMicrotasks()) runs handle_resolve_stream, which calls destroy_sink and frees the HTTPServerWritable. endWithSink(ptr) then enters end_from_js on the freed allocation; finalize() reads garbage for pooled_buffer / buffer.cap / buffer.ptr and faults in the memset the allocator's free-scrub path performs.

The same ordering appears in the Rust port (streams.rs / Sink.rs) unchanged.

Fix

In ${controller}__end and ${controller}__close, finish the native sink operation before any JS runs:

  1. Call ${name}__controllerDetached(ptr, controller) and null m_sinkPtr up front (so end_from_js's own signal.close() stays a no-op, matching the previous behaviour, and so the later detach() won't touch the native side again).
  2. Run endWithSink(ptr) / close(ptr).
  3. Call controller->detach() last. With m_sinkPtr already null it only clears m_onPull and fires onClose; by now we hold no reference into the sink, so re-entrant teardown is safe.

Verification

New ASAN-gated test in test/js/bun/http/serve-direct-readable-stream.test.ts reproduces the exact UAF deterministically by draining microtasks from the stream's cancel() callback (the test uses require("bun:jsc").drainMicrotasks() to force the drain that the production crash hits via the server's own drain points).

ASAN output on the unfixed build
==22203==ERROR: AddressSanitizer: heap-use-after-free on address 0x6ee5f87602ca
READ of size 1 at 0x6ee5f87602ca thread T0
    #0 HTTPServerWritable::end_from_js src/runtime/webcore/streams.rs:1831
    #2 JSSink::js_end_with_sink src/runtime/webcore/Sink.rs:1107
    #4 WebCore::JSReadableHTTPResponseSinkController__end JSSink.cpp:620

freed by thread T0 here:
    #10 HTTPServerWritable::destroy src/runtime/webcore/streams.rs:1950
    #11 RequestContext::destroy_sink src/runtime/server/RequestContext.rs:1930
    #12 RequestContext::handle_resolve_stream src/runtime/server/RequestContext.rs:2680
    #13 RequestContext::on_resolve_stream src/runtime/server/RequestContext.rs:2716
    ...
    #24 JSC::VM::drainMicrotasks()

With the fix the fixture completes normally. Existing suites (serve.test.ts, bun-server.test.ts, direct-readable-stream.test.tsx, streams.test.js, the sink leak tests) show no new failures against the unfixed build.

…ler.end/close

The generated JSReadable*Controller end() and close() host functions stashed
m_sinkPtr in a local, then called controller->detach() (which synchronously
invokes the JS onClose callback), and only afterward dereferenced the stashed
pointer via endWithSink()/close(). If the stream's pull() promise had already
settled, the onClose callback can re-enter the event loop and run the queued
on_resolve_stream reaction, which calls RequestContext::destroy_sink and
frees the HTTPServerWritable before endWithSink() reads from it.

Crash signature:
  Segmentation fault at address 0xFFFFFFFFFFFFFFFF
    endWithSink -> endFromJS -> finalize -> clearAndFree -> memset

Fix: clear the native pointer and run endWithSink()/close() first, then let
detach() fire onClose once we no longer hold a reference into the sink. The
signal.ptr is cleared via controllerDetached before endWithSink so
end_from_js's own signal.close() stays a no-op, matching the previous
behaviour.

Adds an ASAN-gated regression test that reproduces the heap-use-after-free
deterministically by draining microtasks from the stream's cancel() callback.
@robobun

robobun commented Jun 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:00 PM PT - Jun 22nd, 2026

❌ @robobun, your commit a1c3aec has 3 failures in Build #63915 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32597

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

bun-32597 --bun

@coderabbitai

coderabbitai Bot commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

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: bea8ca91-2d7f-4507-b983-89e89a5be1f5

📥 Commits

Reviewing files that changed from the base of the PR and between 31f471a and 2c0cd3d.

📒 Files selected for processing (1)
  • src/codegen/generate-jssink.ts

Walkthrough

The codegen template for JSSink controllers (generate-jssink.ts) is updated so that both __close and __end paths null out m_sinkPtr and invoke the native sink operation before calling controller->detach(), preventing heap-use-after-free during JS re-entrancy. An ASAN-gated regression test is added to verify the fix.

Changes

Sink controller shutdown re-entrancy fix

Layer / File(s) Summary
Code generator: reorder close/end detach steps
src/codegen/generate-jssink.ts
For both ${controller}__close and ${controller}__end, the generated C++ now calls __controllerDetached, nulls m_sinkPtr, and executes the native close/end operation (with exception checks) before controller->detach(). Previously, detach() ran first, allowing the sink pointer to be freed while the native call still needed it.
ASAN regression test for direct ReadableStream UAF
test/js/bun/http/serve-direct-readable-stream.test.ts
Adds a test.skipIf(!isASAN) test that spawns a subprocess creating a direct ReadableStream where cancel() calls drainMicrotasks() to trigger the exact sink teardown race, then calls capturedController.end() after pull() resolution. Asserts the subprocess exits with code 0 and prints ok.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#32120: Modifies the same src/codegen/generate-jssink.ts controller lifecycle paths, adding the __controllerDetached hook and changing the ordering of controller->detach() and native close/end calls.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix use-after-free in JSReadable*Controller end()/close() when onClose re-enters the event loop' accurately and specifically describes the main bug fix, making the primary change immediately clear.
Description check ✅ Passed The PR description exceeds template requirements with detailed cause analysis, fix explanation, and verification details, though it doesn't explicitly follow the template's 'What does this PR do?' and 'How did you verify your code works?' section headings.
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.

Comment thread src/codegen/generate-jssink.ts
Comment thread test/js/bun/http/serve-direct-readable-stream.test.ts Outdated
FileSink::end_from_js returns Err when flush() fails (e.g. ENOSPC), which
js_end_with_sink converts to a thrown JS exception. The previous revision
returned early on that exception and never called controller->detach(), so
the stored onClose callback (which transitions the direct ReadableStream to
closed/errored and calls underlyingSource.cancel()) never fired. Before the
reorder onClose always ran regardless of whether endWithSink threw.

Stash the sink's exception, run detach() (skipping only on a termination
exception), discard any secondary onClose error, and rethrow the original.

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

Thanks — the stash-and-rethrow in 2c0cd3d resolves my earlier concern about detach() being skipped on a throwing endWithSink. Not auto-approving since this reorders the native/JS teardown sequence across all six sink types and adds exception-handling at the boundary; worth a human pass on the lifecycle semantics.

Extended reasoning...

Overview

This PR fixes a use-after-free in the generated JSReadable*Controller end()/close() host functions (src/codegen/generate-jssink.ts). Previously, controller->detach() ran first (firing the onClose JS callback, which can re-enter the event loop and free the native sink), and only afterward was the stashed m_sinkPtr dereferenced via endWithSink() / close(). The fix reorders this: it calls __controllerDetached and nulls m_sinkPtr up front, runs the native end/close, and only then calls detach() to fire onClose. A follow-up commit (2c0cd3d) added stash-and-rethrow handling so detach() still runs if the native end/close throws a non-termination exception, addressing my earlier inline comment. An ASAN-gated regression test reproduces the original UAF deterministically.

Security risks

The bug being fixed is itself a memory-safety issue (heap UAF triggerable from user JS via cancel() re-entering the event loop). The fix reduces attack surface. I don't see new security risks introduced — the reordering is defensive and the exception-handling path correctly bails on termination exceptions without running more JS.

Level of scrutiny

High. This is generated C++ that runs for every direct ReadableStream controller across six sink types (ArrayBufferSink, FileSink, HTTP/HTTPS/H3 response sinks, NetworkSink), on a hot teardown path in Bun.serve. The change is small in line count but semantically subtle: it moves __controllerDetached (which clears the native-side signal) to before endWithSink/close, changes when m_sinkPtr is nulled relative to the native call, and introduces a stash/clear/rethrow exception dance that silently discards secondary errors from onClose. Each of these is reasonable in isolation and well-commented, but the interaction with each sink type's end_from_js / signal.close() behavior deserves a human reviewer who knows this lifecycle.

Other factors

  • My prior 🔴 inline comment (skipped detach() on throw) was addressed in 2c0cd3d and the thread is resolved; the 🟡 timeout nit was reasonably declined.
  • The bug-hunting system found no issues on the latest revision.
  • CI shows musl build failures on an earlier commit (31f471a); status on the latest commit is not yet reflected.
  • The PR description and inline comments are thorough, and the ASAN test gives good regression coverage for the specific crash, but it doesn't exercise the new exception-path (endWithSink throwing) or the non-HTTP sink types.

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

Thanks for the stash-and-rethrow fix in 2c0cd3d — that addresses my earlier concern. I'm not auto-approving since this reorders native sink teardown vs. JS re-entry across all six sink types and adds non-trivial JSC exception juggling; worth a human pass on the lifecycle.

Extended reasoning...

Overview

This PR changes the generated C++ for JSReadable*Controller::end() / close() (via src/codegen/generate-jssink.ts) so that the native endWithSink / close call runs before controller->detach() fires the JS onClose callback, fixing a Sentry-reported heap-use-after-free where onClose re-entered the event loop and freed the sink while a stale ptr was still on the C++ stack. A follow-up commit (2c0cd3d) added stash-and-rethrow around detach() so it still fires when the native end/close throws, addressing the regression I flagged in my first review. An ASAN-gated regression test reproduces the UAF deterministically.

Security risks

No new attack surface — this is a memory-safety hardening fix in an existing code path. The pre-fix behaviour was the dangerous one (UAF reachable from user cancel() callbacks).

Level of scrutiny

High. The change is small in line count but sits at the intersection of:

  • Native pointer lifetime across a JS re-entry boundary, applied to 6 sink types (HTTP/HTTPS/H3 response, FileSink, NetworkSink, ArrayBufferSink) on the HTTP-serve hot path.
  • JSC exception-scope semantics: the new stash-and-rethrow uses tryClearException() (twice), distinguishes termination exceptions, deliberately swallows a secondary onClose error in favour of the original sink error, and re-throws into the same scope. These are easy to get subtly wrong.
  • A behavioural ordering change: controllerDetached now runs before endWithSink, and onClose fires after the native end instead of before. The PR description argues this preserves the previous signal.close() no-op behaviour, but verifying that across all sink implementations needs domain knowledge.

Other factors

  • My earlier 🔴 finding (early-return skipping detach() on throw) was fixed; the 🟡 timeout nit was reasonably justified and resolved.
  • The bug-hunting system found nothing on the latest revision.
  • The fix is well-motivated (290 Sentry events) and comes with a deterministic ASAN repro plus claims of clean runs on the broader stream/server suites.
  • coderabbit suggests cirospaciari, who owns much of this sink/RequestContext code — that's the right reviewer for the lifecycle invariants here.

Given the subtlety of the teardown ordering and the new exception-handling block, I'm deferring rather than auto-approving.

@robobun

robobun commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator Author

The diff itself is green locally and on every lane that touches it. Both CI failures are single unrelated tests on the debian 13 x64-asan lane, a different one each run:

  • #63910: test/js/bun/shell/commands/rm.test.ts leaks 476 bytes in ShellRmTask::create (shell rm builtin, src/runtime/shell/builtin/rm.rs)
  • #63915: test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js hits ASSERTION FAILED: !scope.exception() || !hasSlot in JSValue::get during worker termination

Neither touches stream controllers, JSSink, or the HTTP server. The new test (serve-direct-readable-stream.test.ts) passes on every lane including x64-asan. Ready for a maintainer to look at.

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