Skip to content

Bugfix - Fix WorkerEntrypoint use-after-free on subrequest cancellation#6867

Open
jutaz wants to merge 1 commit into
cloudflare:mainfrom
jutaz:fix/queue-send-redirect-hang
Open

Bugfix - Fix WorkerEntrypoint use-after-free on subrequest cancellation#6867
jutaz wants to merge 1 commit into
cloudflare:mainfrom
jutaz:fix/queue-send-redirect-hang

Conversation

@jutaz

@jutaz jutaz commented Jul 7, 2026

Copy link
Copy Markdown

WorkerEntrypoint::requestImpl is a coroutine whose KJ_DEFER at scope exit runs incomingRequest->drain(waitUntilTasks, ...). The DEFER is invoked either at natural scope exit (with this alive) or from the coroutine's destructor. On some subrequest paths the coroutine's destructor fires after the WorkerEntrypoint has already been freed by its owning Own<WorkerInterface>: the promise chain that owns the coroutine can outlive that Own via the response body's attached task.

The trigger observed in practice is a fire-and-forget env.QUEUE.send() followed by a 3xx response — the queue producer's loopback subrequest lands its WorkerEntrypoint::requestImpl coroutine in IoContext::tasks, and when the outer request unwinds that TaskSet the coroutine's DEFER reads this->waitUntilTasks from freed memory, gets a null TaskSet&, and faults inside TaskSet::add. Repro binaries either segfault (bazel test harness) or spin at 100% CPU indefinitely (standalone workerd / miniflare).

Bind waitUntilTasks to a coroutine-frame local so the DEFER's implicit [&] capture pins the reference against the coroutine frame instead of this. WorkerService::waitUntilTasks itself lives for the service's lifetime, so the underlying TaskSet is always valid.

The DEFER still reads other this->* members (failOpenService, proxyTask, loggedExceptionEarlier, abortController, cfBlobJson); those are latent UAFs on the same path but haven't been observed to crash — zeroed freed memory happens to steer the DEFER's control flow past them. A proper fix would tie the entrypoint's lifetime to the coroutine's; leaving a TODO(cleanup). Happy to refactor this to address the TODO as well.

Includes a wd_test regression that exercises the exact combination (fire-and-forget env.QUEUE.send() + 302) via a self-service binding, so it reproduces inside a single worker with no miniflare (where this was originally observed).

`WorkerEntrypoint::requestImpl` is a coroutine whose KJ_DEFER at scope
exit runs `incomingRequest->drain(waitUntilTasks, ...)`. The DEFER is
invoked either at natural scope exit (with `this` alive) or from the
coroutine's destructor. On some subrequest paths the coroutine's
destructor fires after the `WorkerEntrypoint` has already been freed
by its owning `Own<WorkerInterface>`: the promise chain that owns the
coroutine can outlive that Own via the response body's attached task.

The trigger observed in practice is a fire-and-forget
`env.QUEUE.send()` followed by a 3xx response — the queue producer's
loopback subrequest lands its `WorkerEntrypoint::requestImpl`
coroutine in `IoContext::tasks`, and when the outer request unwinds
that TaskSet the coroutine's DEFER reads `this->waitUntilTasks` from
freed memory, gets a null `TaskSet&`, and faults inside
`TaskSet::add`. Repro binaries either segfault (bazel test harness)
or spin at 100% CPU indefinitely (standalone workerd / miniflare).

Bind `waitUntilTasks` to a coroutine-frame local so the DEFER's
implicit `[&]` capture pins the reference against the coroutine frame
instead of `this`. `WorkerService::waitUntilTasks` itself lives for
the service's lifetime, so the underlying `TaskSet` is always valid.

The DEFER still reads other `this->*` members (`failOpenService`,
`proxyTask`, `loggedExceptionEarlier`, `abortController`,
`cfBlobJson`); those are latent UAFs on the same path but haven't
been observed to crash — zeroed freed memory happens to steer the
DEFER's control flow past them. A proper fix would tie the
entrypoint's lifetime to the coroutine's; leaving a `TODO(cleanup)`.

Includes a `wd_test` regression that exercises the exact combination
(fire-and-forget `env.QUEUE.send()` + `302`) via a self-service
binding, so it reproduces inside a single worker with no miniflare.
@jutaz jutaz requested review from a team as code owners July 7, 2026 08:36
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@jutaz

jutaz commented Jul 7, 2026

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant