Skip to content

worker: fence in-flight AnyTaskJob work against terminate() freeing the VM - #36817

Closed
robobun wants to merge 3 commits into
mainfrom
farm/eb906c6f/crypto-kdf-worker-terminate-uaf
Closed

worker: fence in-flight AnyTaskJob work against terminate() freeing the VM#36817
robobun wants to merge 3 commits into
mainfrom
farm/eb906c6f/crypto-kdf-worker-terminate-uaf

Conversation

@robobun

@robobun robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

worker.terminate() while an async crypto.pbkdf2()/crypto.scrypt() (or any other AnyTaskJob: HKDF, prime/keypair gen, sign, DH, Bun.secrets, Bun.zstd*) is running on the shared work pool frees the worker VM and its JSC heap out from under the pool thread. AnyTaskJob::run_task both

  • hands the caller's ArrayBuffer-backed password/salt to BoringSSL, whose backing store Heap::lastChanceToFinalize frees regardless of protect(), and
  • dereferences the VM box to post the completion (vm.event_loop_shared() on the raw BackRef<VirtualMachine>).

Release builds segfault the whole process; debug+ASAN reports the UAF on a "Bun Pool" thread:

heap-use-after-free  READ of size 8  thread T4 (Bun Pool 0)
  #0 VirtualMachine::event_loop_shared   src/jsc/VirtualMachine.rs:752
  #1 AnyTaskJob<Pbkdf2Ctx>::run_task     src/jsc/any_task_job.rs:146
freed by thread (Worker):
  WebWorker::shutdown                    src/jsc/web_worker.rs:1383

The ArrayBuffer-read face shows as PKCS5_PBKDF2_HMAC / EVP_PBE_scrypt reading freed bytes inside BoringSSL, but only when the salt length is not a multiple of the SHA-256 block size: the block function is uninstrumented assembly, so ASAN only observes the <64-byte memcpy tail. Worker process.exit() and an uncaught throw take the same shutdown path.

Fix

A per-VM Arc<RwLock<bool>> gate. AnyTaskJob::create clones it; run_task runs ctx.run() and the enqueue_task_concurrent push under a read lock. WebWorker::shutdown (and global_exit for the destruct-on-exit path) close the gate right next to the existing markTerminating/markShuttingDown fences, before the concurrent-queue drain, before JSC teardown, and before deallocating the VM. The write lock waits out every in-flight reader so the work body finishes and its push is visible to the drain; a job the pool picks up after close() observes it and skips its body.

Skipped jobs intentionally leak their ctx (it holds Strong/JSPromiseStrong handles into the dead VM's heap, and poll.unref would touch the freed event loop); bounded per terminated worker, same fate as tasks already queued on a terminated worker's never-drained concurrent queue. The gate Arc itself is released on both paths so it does not accumulate.

close() blocking on the longest in-flight KDF matches Node's behaviour of draining in-flight libuv work on env teardown.

Scope

This is #35154 rebased onto current main with a Buffer-input test for the ArrayBuffer-read face. The other cross-thread producers (FetchTasklet, WorkTask, ConcurrentPromiseTask, AsyncFSTask, PasswordJob, napi, watchers) still have the analogous defect and stay with #35767 / #34154; the AnyTaskGate here is reusable by those call sites.

Verification

New test in test/js/web/workers/worker-terminate-lifetime.test.ts: a subprocess arms 2x pbkdf2 + 2x scrypt with a 4 MiB Buffer salt in each of rounds workers and terminates once armed.

  • USE_SYSTEM_BUN=1: child segfaults at 0xFFFFFFFF00000000 (rc 139, round 6-7)
  • bun bd without the src/ change: ASAN heap-use-after-free at AnyTaskJob<Pbkdf2Ctx>::run_task (round 0)
  • bun bd with the fix: 4/4 rounds clean, ~10s under ASAN
  • test/js/node/crypto/{pbkdf2,scrypt}.test.ts, node-crypto.test.js (203), zstd.test.ts (83) all pass
  • bun run rust:check-all: 10/10 targets ok

The pre-existing dns.lookup() test in the same file fails on current main (4104-byte leak) with or without this change.


no test proof · iteration 0 · 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

Towards #32073

…he VM

worker.terminate() while an async crypto.pbkdf2()/scrypt() (or any other
AnyTaskJob: HKDF, prime/keypair gen, sign, DH, Bun.secrets, Bun.zstd*) is
running on the shared work pool freed the worker VM and its JSC heap out from
under the pool thread. AnyTaskJob::run_task both (a) handed the caller's
ArrayBuffer-backed password/salt to BoringSSL (freed by
Heap::lastChanceToFinalize, which ignores protect()) and (b) dereferenced the
VM box to post the completion. Release builds segfaulted the whole process;
ASAN reported the UAF on a Bun Pool thread inside PKCS5_PBKDF2_HMAC /
EVP_PBE_scrypt or VirtualMachine::event_loop_shared.

Add a per-VM Arc<RwLock<bool>> gate. AnyTaskJob::create clones it; run_task
runs ctx.run() and the enqueue_task_concurrent push under a read lock.
WebWorker::shutdown (and global_exit for the destruct-on-exit path) close the
gate before the concurrent-queue drain, before JSC teardown, and before
deallocating the VM. The write lock waits out every in-flight reader so the
work body finishes and its push is visible to the drain; a job the pool picks
up after close() observes it and skips its body entirely. Matches Node's
behaviour of draining in-flight libuv work on env teardown.

Skipped jobs intentionally leak their ctx (it holds Strong/JSPromiseStrong
handles into the dead VM's heap, and poll.unref would touch the freed event
loop); bounded per terminated worker, same fate as tasks already queued on a
terminated worker's never-drained concurrent queue. The gate Arc itself is
released on both paths so it does not accumulate.
@coderabbitai

coderabbitai Bot commented Aug 3, 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: 88398e02-57ca-4edd-8f92-08a56f0b77bd

📥 Commits

Reviewing files that changed from the base of the PR and between 074656d and 50704e4.

📒 Files selected for processing (4)
  • src/jsc/VirtualMachine.rs
  • src/jsc/any_task_job.rs
  • src/jsc/web_worker.rs
  • test/js/web/workers/worker-terminate-lifetime.test.ts

Walkthrough

Changes

AnyTask shutdown fencing

Layer / File(s) Summary
AnyTaskGate contract
src/jsc/any_task_job.rs
Adds the shared gate, closed-state handling, shutdown method, and read-locked execution helper.
VirtualMachine gate lifecycle
src/jsc/VirtualMachine.rs
Creates and exposes the gate, closes it during VM shutdown, and releases it during destruction.
Job and worker shutdown integration
src/jsc/any_task_job.rs, src/jsc/web_worker.rs, test/js/web/workers/worker-terminate-lifetime.test.ts
Coordinates AnyTaskJob execution with the gate, closes the gate before queued task release, and tests worker termination during asynchronous crypto operations.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#36571 — Directly modifies any_task_job.rs and the AnyTask lifecycle model.
  • oven-sh/bun#36575 — Adds VM-scoped shutdown fencing for worker lifetime handling.
  • oven-sh/bun#36808 — Prevents VM-related work during worker shutdown through a related mechanism.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the worker termination fix for in-flight AnyTaskJob work.
Description check ✅ Passed The description clearly explains the problem, fix, scope, verification steps, test coverage, and known limitations.
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.

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

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Worker lifetime: carry a generation token with cross-thread VM handles (follow-up to #32071) #32073 - Lists AnyTaskJob among the producers that still carry a bare *mut VirtualMachine across threads for completion delivery; the new AnyTaskGate closes that window for this producer (the job body and the enqueue_task_concurrent push are both fenced, and skipped once the gate closes in global_exit/WebWorker::shutdown) — though it does so via a close-once fence rather than the VmHandle { addr, gen } token the issue proposes, and the other producers listed there are untouched, so this likely narrows the issue rather than closing it outright.

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

Fixes #32073

Also worth cross-linking (not fixed here): #33936 asks for exactly this fence shape — "have WebWorker::shutdown() wait for in-flight jobs" — but for TranspilerJob, which AnyTaskGate does not cover. No open user-filed issue reports the async node:crypto KDF / Bun.secrets / Bun.zstd*-vs-terminate() UAF directly; the other worker-terminate crash reports (#31880, #30421, #34690, #34095, #31224) each point at producers this PR explicitly leaves out of scope.

🤖 Generated with Claude Code

Comment thread test/js/web/workers/worker-terminate-lifetime.test.ts Outdated
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:31 AM PT - Aug 3rd, 2026

@robobun, your commit 50704e49553ad4607f2d1f3952c97898f322e592 passed in Build #88084! 🎉


🧪   To try this PR locally:

bunx bun-pr 36817

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

bun-36817 --bun

Match the ready() pattern the sibling tests use so a worker startup failure
surfaces the actual error instead of an opaque exitCode 13.
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/any_task_job.rs
Comment thread src/jsc/any_task_job.rs
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/any_task_job.rs
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/any_task_job.rs
Comment thread src/jsc/any_task_job.rs
Comment thread src/jsc/any_task_job.rs

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

No bugs found; the earlier readiness-wait nit was addressed in a66675a. Deferring to human review — this is cross-thread memory-safety code in the VM/worker-shutdown path with design trade-offs a maintainer should sign off on (new RwLock fence in the shutdown ordering, terminate() now blocks on the longest in-flight KDF, intentional ctx/poll leak on the closed-gate path with LSan opt-out in the test). Four comment-cop flags on the new doc comments are also still open.

What was reviewed:

  • Gate ordering: close() sits after markTerminating/markShuttingDown and before the concurrent-queue drain / teardownJSCVM in both WebWorker::shutdown and global_exit, so an in-flight reader's enqueue is visible to the drain.
  • run_task clones the Arc before taking the read lock, so the gate outlives the &mut *this reborrow inside the closure; on the closed path only gate is drop_in_place'd and the box is leaked (no double-drop).
  • any_task_gate field: Option for alloc_zeroed validity, written in init(), take()'d in destroy(); any_task_gate() accessor is only reached between the two.
Extended reasoning...

Overview

Adds a per-VM Arc<RwLock<bool>> fence (AnyTaskGate) so pool-thread AnyTaskJob bodies (node:crypto KDFs/HKDF/primes/keypairs/sign/DH, Bun.secrets, Bun.zstd*) either complete entirely before worker.terminate() frees the VM/JSC heap, or observe the closed gate and skip. Touches VirtualMachine (new field + init/destroy + global_exit), any_task_job.rs (gate type + run_task rewrite), web_worker.rs (shutdown() fence), and adds a subprocess regression test.

Security risks

None user-facing. The change hardens against a process-wide crash/UAF; it does not touch auth, input parsing, or trust boundaries. The only new attack-surface consideration is that terminate() now waits on in-flight work-pool jobs, so a worker that armed a very expensive scrypt could delay its own teardown — but that's the worker's own code, and matches Node's libuv-drain behaviour on env teardown.

Level of scrutiny

High. This is exactly the category REVIEW.md calls out as most-blocked: cross-thread lifetime, new synchronization primitive, ordering against JSC heap teardown, an intentional bounded leak, and a behavioural change to terminate() latency. It's also one slice of a tracked multi-producer effort (#32073 / #35767 / #34154); the fence shape here (Arc<RwLock> vs. the generational VmHandle token proposed in #32073) is a design choice the other producers will inherit if they reuse AnyTaskGate. A maintainer should confirm they're happy with that direction.

Other factors

  • My prior inline nit (readiness promise not wired to error/exit) was applied in a66675a and is resolved.
  • Four comment-cop bot flags remain unresolved on the new multi-line doc/SAFETY comments — they read to me as legitimate cross-thread invariant documentation rather than workaround justification, but a human should make that call.
  • The test opts the subprocess out of LSan (detect_leaks=0) to accommodate the by-design leak of stranded jobs; that's consistent with the PR's stated rationale but is the kind of safety-net opt-out REVIEW.md asks reviewers to scrutinise.
  • Verification in the PR description is thorough (fails-before on both system bun and unpatched debug build, passes-after, sibling crypto/zstd suites green, rust:check-all clean).

@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 wiring error/exit into the readiness promise (a66675a). No new issues found, but this is cross-thread lifetime fencing on the VM shutdown path with an intentional-leak tradeoff and competing designs in flight (#32073 / #35767), so it warrants a maintainer look.

What was reviewed:

  • run_gated read-lock ordering vs close() write-lock — the enqueue is inside the guard, so it's visible to the post-close() drain in both WebWorker::shutdown and global_exit.
  • Gate-closed path: drop_in_place on (*this).gate releases the job's Arc clone; the outer gate local's Arc is dropped just before, so no double-drop and no Arc leak.
  • any_task_gate init/take covers all VM lifecycle exits (init writes it, global_exit closes it, destroy takes it; worker path closes in shutdown before the concurrent-queue drain).
Extended reasoning...

Overview

Adds a per-VM Arc<RwLock<bool>> gate (AnyTaskGate) so work-pool AnyTaskJob bodies and their completion enqueue run under a read lock, and worker/process shutdown takes the write lock (waiting out in-flight readers) before JSC teardown and the VM dealloc. Touches VirtualMachine init/destroy/global_exit, any_task_job.rs (new gate type + run_task rewrite), a one-line close() call in WebWorker::shutdown, and a new subprocess regression test.

Security risks

None in the injection/auth sense. This is memory-safety code: the risk surface is the cross-thread ordering itself — a mis-placed close() (after the drain instead of before) or a read-lock scope that doesn't cover the enqueue would leave the original UAF window open. Both look correct: close() precedes release_queued_tasks_for_shutdown in both call sites, and enqueue_task_concurrent is inside run_gated's closure.

Level of scrutiny

High. This is exactly the category REVIEW.md calls out as most-blocked: cross-thread lifetime, refcounts, intentional leaks with SAFETY annotations, and new unsafe raw-pointer manipulation in run_task. The intentional leak of ctx/poll on the gate-closed path is a design tradeoff (bounded per terminated worker, same fate as the never-drained concurrent queue) that a maintainer should sign off on — the PR description itself frames this as one approach among several (#35154 rebase; #35767/#34154 track the sibling producers; #32073 proposes a generation-token alternative).

Other factors

  • My prior feedback (test readiness-wait error/exit wiring) was applied in a66675a.
  • The comment-cop bot's paragraph-length flags were addressed in 50704e4; remaining comments are doc/SAFETY on a sync primitive, which the file's existing style matches.
  • close() blocking on the longest in-flight KDF means terminate() latency is now bounded by user-controlled crypto params — the PR notes this matches Node's libuv-drain behavior, but it's a user-observable change worth a maintainer nod.
  • CI build #88084 was still running at last timeline update; no green signal yet.
  • The test disables LSan for the subprocess (documented, matches the intentional-leak design), and uses the file-local rounds/timeout scaling.

robobun added a commit that referenced this pull request Aug 3, 2026
#36817)

The assert is compiled in for both debug and release+ASAN, but the
release+ASAN lane hits the orthogonal AnyTaskJob pool-thread UAF (#36817)
that this assert was masking. Widen to !isDebug && !isASAN once that lands.
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #37075 (9d519e8), which replaced AnyTaskJob and src/jsc/any_task_job.rs with the bun_jsc::Job carrier and the per-VM handle that VirtualMachine::teardown() closes before the JSC heap is destroyed. In-flight pool work is now waited for or released during teardown, so the fence this PR adds has nothing left to guard.

Verified on a debug + ASAN build of current main (f426a8e): the test from this PR (Buffer-backed pbkdf2/scrypt inputs) and the one from #35154 both pass, two runs each, with no sanitizer output. Main also carries direct coverage for pbkdf2/scrypt jobs being refused after terminate() in test/js/web/workers/worker-refused-completion.test.ts. #35154, the earlier PR for the same race, is closed for the same reason.

@robobun robobun closed this Aug 13, 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.

2 participants