Bun.password: fix UAF when worker.terminate() lands mid argon2/bcrypt job - #35156
Bun.password: fix UAF when worker.terminate() lands mid argon2/bcrypt job#35156robobun wants to merge 4 commits into
Conversation
…nate PasswordJob::run_owned runs argon2/bcrypt on the shared work pool and enqueues the completion back through a raw *mut EventLoop captured at schedule time. worker.terminate() frees the VM box mid-hash, then the pool thread dereferences the freed event loop (heap-use-after-free in EventLoop::enqueue_task_concurrent, stock SIGSEGV on release). Add a per-VM ShutdownGate (futex-backed counted gate, Arc'd so guests may outlive the VM box). PasswordJob clones the Arc on the JS thread and brackets the event_loop dereference with enter()/leave() on the pool thread; worker shutdown close_and_wait()s the gate before the VM dealloc. Because the gate is entered only for the enqueue itself (not held across the whole hash), terminate latency is bounded by the microsecond enqueue critical section rather than the argon2 runtime. A pool thread that loses the race drops its completion and leaks the promise's HandleSlot (points into the dead JSC VM; Drop would UAF).
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (16)
Comment |
|
Found 4 issues this PR may fix:
🤖 Generated with Claude Code |
|
Related but not auto-closable by this PR:
Leaving these open. |
|
Updated 1:30 PM PT - Jul 22nd, 2026
❌ @robobun, your commit a23e86f has 3 failures in
🧪 To try this PR locally: bunx bun-pr 35156That installs a local version of the PR into your bun-35156 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
On the duplicate-PR suggestions:
|
There was a problem hiding this comment.
No bugs found; the earlier test-wiring nit is addressed in 61bab23. Deferring to a human because this introduces a new cross-thread synchronization primitive (ShutdownGate) wired into VirtualMachine init/destroy and worker teardown ordering — memory-safety-critical paths that warrant maintainer eyes, and the primitive/placement is a design commitment that #34154 / #35154 will rebase onto.
What was reviewed:
ShutdownGateenter/leave/close ordering and the optimistic-add-then-undo race againstclose_and_wait's re-read loop — looks sound; futex wake covers all closers.run_ownedgate-closed path:JSPromiseStrongisManuallyDrop'd (not dropped) so the dead HandleSet slot isn't dereferenced;KeepAlivehas noDrop; theBox<Self>drop still zero-wipes password/prev_hash.destroy()backstop: main-VM usesclose_without_waiting(box never freed), worker path re-closes-and-waits idempotently; theOption::take()drops the VM'sArcref explicitly since worker boxes bypassDrop.- Gate placement in
WebWorker::shutdownsits aftermarkShuttingDownand beforerelease_queued_tasks_for_shutdown, so a gated enqueue that lands is still reclaimed.
Extended reasoning...
Overview
The PR fixes a heap-use-after-free where PasswordJob::run_owned (running argon2/bcrypt on the shared work pool) posts its completion through a raw *mut EventLoop after worker.terminate() has freed the worker's VirtualMachine box. It introduces a new bun_threading::ShutdownGate primitive (futex-backed counted guest gate in an Arc), stores one on every VirtualMachine, closes-and-waits on it in WebWorker::shutdown before the VM dealloc, and has PasswordJob bracket its event_loop dereference with enter()/leave(). Six files touched: the new primitive + export, VM field/init/destroy wiring, the web_worker.rs close call, the PasswordObject.rs producer, and a new spawned-subprocess regression test.
Security risks
None introduced. The change is defensive (prevents UAF). No new user-facing surface, no parsing of untrusted input, no auth/crypto-algorithm changes — the argon2/bcrypt compute path itself is unchanged.
Level of scrutiny
High. This is exactly the category REVIEW.md's memory-safety section is about: cross-thread lifetime, a new hand-rolled synchronization primitive with atomics + futex, and an ordering-sensitive insertion into the worker VM teardown sequence. A subtle bug here would be a UAF or a terminate() hang. The ShutdownGate primitive itself is small and unit-tested, and the enter/leave/close_and_wait interaction looks correct (optimistic fetch_add with undo-on-closed, the closer's while state != CLOSED loop tolerating the transient count, wake(u32::MAX) covering multiple closers). The gate-closed path in run_owned correctly leaks the JSPromiseStrong slot via ManuallyDrop rather than letting Drop deref the dead JSC HandleSet, and KeepAlive having no Drop is stated and matches the codebase. But this is the kind of change where a maintainer who knows the full worker-teardown ordering and the competing #32071 / #34154 / #35154 designs should sign off.
Other factors
- The PR is explicitly a slice of #34154's design and will merge-conflict with #35154 (which adds a different
Arc<RwLock<bool>>gate for theAnyTaskJobsite). A human should decide sequencing and confirmShutdownGateis the primitive both should converge on. - My earlier nit (wire
error/exitto reject the readiness promise) was addressed in 61bab23. - Verification in the PR body is thorough (USE_SYSTEM_BUN fails, unfixed debug+ASAN reproduces the UAF, fixed build passes, existing password tests pass, cargo unit tests pass,
rust:check-allclean across 10 targets). CI build #77974 is in progress. - The
close_and_wait()inshutdown()runs on the worker thread while the pool thread holds the gate only for the microsecond enqueue — soterminate()latency is not extended by the argon2 runtime, as the PR body claims. I did not find a path where a guest could hold the gate across something that blocks on the worker thread (which would deadlock).
…ed PasswordResults A completion that makes it into the queue under the gate is re-queued (AnyTask was not consumable at shutdown) and leaks once the worker VM box is dealloc'd. Give AnyTask an optional dispose fn and have release_queued_tasks_for_shutdown call it (JS thread, VM alive, so JSPromiseStrong::Drop is safe there); PasswordResult's dispose is a plain box drop.
|
CI on a23e86f: the
Diff is ready for review. |
|
Generalised into #35767 using the enqueue-by-identifier route (same serialization point as |
|
superseded by #37075, password jobs now go through the per-vm handle that teardown closes. repro no longer crashes on main |
Repro
Stock release: panic / SIGSEGV on round 0 or 1. Debug+ASAN:
Cause
PasswordJob::run_ownedruns argon2/bcrypt on the shared work pool and posts the completion back through a raw*mut EventLoopcaptured at schedule time. argon2/bcrypt jobs take hundreds of ms, soworker.terminate()almost always lands mid-compute: the worker'sVirtualMachinebox (with its embeddedEventLoop) is freed, then the pool thread dereferences the freed pointer inenqueue_task_concurrent.Bun.password.verify()shares the same site. Workerprocess.exit()and an uncaught throw are equal triggers.Fix
Add a per-VM
ShutdownGate(futex-backed counted guest gate,Arc'd so guests may outlive the VM box).PasswordJobclones theArcon the JS thread and brackets theevent_loopdereference on the pool thread withenter()/leave();WebWorker::shutdowncallsclose_and_wait()on the gate before the VM dealloc.Because the gate is entered only for the enqueue itself (not held across the whole hash),
terminate()latency is bounded by the microsecond enqueue critical section rather than the argon2 runtime. A pool thread that has not reached the enqueue yet observes the closed gate, drops its completion, and returns; the promise'sStrongRefslot (which points into the dead JSC VM's HandleSet) is leaked rather thanDrop-dereferenced.A completion that makes it into the queue under the gate (the microsecond race where a pool thread is mid-enqueue when
close_and_wait()is called) would previously be re-queued by the shutdown drain and leak once the worker VM box is dealloc'd.AnyTaskgains an optionaldisposeslot thatrelease_queued_tasks_for_shutdowncalls (JS thread, VM still alive, soJSPromiseStrong::Dropis safe);PasswordResult's dispose is a plain box drop.The gate primitive, its VM wiring, and the
AnyTaskdispose slot are the minimal subset of the design in #34154; this PR applies it to theBun.passwordsite only. The other cross-thread producers (fetch, node:fs async, zlib, S3, napi async work, etc.) remain as on main and are covered by #34154 / #32071.Verification
New test in
test/js/web/workers/worker-terminate-lifetime.test.ts:USE_SYSTEM_BUN=1: child crashes on round 0 (release panic)bun bdwithout src/ fix: child aborts with the ASAN heap-use-after-free abovebun bdwith fix: passes (4 rounds under ASAN, ~9s)bun bd test test/js/bun/util/password.test.ts: 68 pass / 8 skipcargo test -p bun_threading shutdown_gate: 2 passbun run rust:check-all: 10/10 targets okThe pre-existing
dns.lookuptest in the same file fails on current main (4104-bytenode_fs_binding::Bindingleak) with or without this change.no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/workers/worker-terminate-lifetime.test.ts