← Chrome — 2026-W05

b2f80b3e2b3580f211b3697375955a32a25c59df  Add CHECKs to assure we're not re-entering V8 with a pending exception in ReadableStream::LockAndDisturb()

severity low class LogicError CVSS 4.3 confidence 0.4 signal score 3.8 third_party/blink/renderer AI review CONFIRMED
Primitive: Adds CHECKs around BodyStreamBuffer close/LockAndDisturb to catch re-entering V8 while an exception is pending, a state that can lead to inconsistent/unsafe script execution.
Triage note: Defensive/diagnostic hardening in a fetch ReadableStream path known to hit a pending-exception invariant violation. It does not itself fix the root cause and only aborts (DoS) instead of proceeding into potential UB; security relevance is indirect.

The bug at a glance

This commit does not fix a vulnerability - it adds three diagnostic CHECKs to localize one. The commit message and inline comments are explicit: the CHECKs exist ’to pinpoint a cause for a CHECK(!isolate->HasPendingException()) being hit within LockAndDisturb() and can be removed once the bug is fixed.’ So the real defect (some path leaves a V8 pending exception set on the isolate before ReadableStream::LockAndDisturb() re-enters V8) is still open under bug 477424489; this change only narrows where it originates by asserting the isolate has no pending exception before Close(), that exception_state did not record an exception, and that no pending exception exists after Close(). The security relevance is that re-entering V8 with a pending exception violates a core engine invariant: V8 APIs called in that state have undefined behaviour and, in the general class, this pattern has historically underpinned type-confusion and use-after-free bugs when a throwing callback runs while C++ assumes success. But here nothing is being made safe - the added CHECKs convert a hard-to-attribute crash into a precisely-attributed crash. Because it is instrumentation of a low-frequency invariant break with no demonstrated exploit and no behaviour change on the success path, it rates low / CVSS 4.3.

The instructive part is the shape of the invariant being defended: BodyStreamBuffer, after clearing its cached-metadata handler and conditionally Close()-ing a readable stream, hands control to stream_->LockAndDisturb(script_state_), which enters V8. V8 requires that no exception be pending when you call into it; if Close() (which takes an ExceptionState) can throw or if an exception was already pending on entry, the subsequent LockAndDisturb re-enters V8 in a forbidden state. The three CHECKs bracket exactly the two candidate sources - a pre-existing pending exception vs. one introduced by Close() - so a field crash tells the developer which. For an auditor the takeaway is not the diff but the target: any C++ that calls a script-throwing helper and then re-enters V8 without checking exception_state is a candidate for the same class of bug.

Root cause

In BodyStreamBuffer::CloseAndLockAndDisturb (the function ending in the shown hunk), the sequence is: null out cached_metadata_handler_; if IsStreamReadable(), call Close(exception_state); then call stream_->LockAndDisturb(script_state_). LockAndDisturb internally has a CHECK(!isolate->HasPendingException()) that was firing in the wild. The patch inserts (1) CHECK(!script_state_->GetIsolate()->HasPendingException()) immediately before the IsStreamReadable()/Close block, to prove the exception is not already pending on entry; (2) CHECK(!exception_state.HadException()) after Close, to prove Close did not record an exception into the ExceptionState; and (3) CHECK(!script_state_->GetIsolate()->HasPendingException()) after Close, to prove Close did not leave a raw pending exception on the isolate. Whichever CHECK trips first isolates the source. Functionally, on the normal (no-exception) path all three CHECKs pass and behaviour is unchanged; the only new behaviour is a deterministic, well-labeled abort at the earliest point the invariant is observed to break, instead of a later, less-attributable abort inside LockAndDisturb.

Key code

Diagnostic CHECKs bracketing Close() before re-entering V8 via LockAndDisturb

cached_metadata_handler_ = nullptr;

// TODO(477424489): see comment below.
CHECK(!script_state_->GetIsolate()->HasPendingException());

if (IsStreamReadable()) {
  // Note that the stream cannot be "draining", because it doesn't have
  // the internal buffer.
  Close(exception_state);
}
// TODO(477424489): These CHECKs are to pinpoint a cause for a
// CHECK(!isolate->HasPendingException()) being hit within LockAndDisturb()
// and can be removed once the bug is fixed.
CHECK(!exception_state.HadException());
CHECK(!script_state_->GetIsolate()->HasPendingException());
stream_->LockAndDisturb(script_state_);

Patch walkthrough

  • third_party/blink/renderer/core/fetch/body_stream_buffer.cc — Three CHECKs are added around the Close()/LockAndDisturb sequence. The first, after cached_metadata_handler_ is cleared and before the readable-stream Close, asserts no V8 exception is already pending on entry. The second and third, after Close(exception_state), assert respectively that the ExceptionState recorded no exception and that the isolate holds no raw pending exception, before stream_->LockAndDisturb(script_state_) re-enters V8. All are TODO(477424489) diagnostics intended for removal once root cause is found; none alters the success-path control flow.

Background

pending exception (V8) — An exception thrown in script but not yet propagated/cleared, tracked on the Isolate. Calling most V8 APIs while one is pending is undefined behaviour; Blink enforces !HasPendingException() at re-entry points.

ExceptionState — Blink’s C++ representation of a to-be-thrown exception. HadException() reports whether a called helper recorded one; it is distinct from whether the isolate has a raw pending exception.

ReadableStream::LockAndDisturb / BodyStreamBuffer — Fetch body plumbing: LockAndDisturb locks a ReadableStream and marks it disturbed, calling into V8/streams internals. BodyStreamBuffer wraps a body’s stream and must reach a clean V8 state before delegating to it.

CHECK vs DCHECK — CHECK is retained in release/official builds and aborts the process. Using CHECK (not DCHECK) here means the diagnostics fire in the field, which is necessary to catch a low-repro invariant break that debug builds rarely reproduce.

Vulnerability window

  1. symptom — A pre-existing CHECK(!isolate->HasPendingException()) inside LockAndDisturb() is reported firing in the field under bug 477424489, without a clear originating call site.
  2. instrumentation — 2026-01-29: Andrey Kosyakov lands three bracketing CHECKs in BodyStreamBuffer to attribute the pending exception to entry-state vs Close(). Merged as #1576760.
  3. pending — Root cause remains open; the comments mark the CHECKs for removal once the underlying bug is fixed.

Proof of concept

There is no PoC and none can be honestly produced from this diff. The change adds assertions, not a fix, and the triggering condition (an unknown path that leaves a pending V8 exception before BodyStreamBuffer re-enters V8 via LockAndDisturb) is precisely what the Chromium team had not yet reproduced deterministically at commit time. Any ‘PoC’ here would be invented. The realistic security concern - re-entering V8 with a pending exception as a substrate for type confusion or UAF - is a general class statement, not something demonstrated by this commit.

Exploitation

  1. class — Re-entering V8 with a pending exception is a known dangerous state: subsequent API calls may operate on partially-initialized or torn-down objects, a substrate that in other bugs has yielded type confusion or use-after-free.
  2. reachability — The path runs through Fetch body handling (BodyStreamBuffer close + stream lock), reachable from script that consumes/aborts response or request bodies - but the exact input that leaves the exception pending is unidentified as of this commit.
  3. status — No exploit is enabled or disabled by this change; it only makes the invalid state abort earlier and more legibly. Exploitability of the underlying bug is undetermined.

Detection & hunting

For defenders and detection engineers:

  • CHECK failures in BodyStreamBuffer with TODO(477424489) — Post-patch crash reports keyed to these CHECKs directly attribute the pending exception to before-Close vs Close-introduced; triage by which of the three fired first.
  • LockAndDisturb HasPendingException aborts — Correlate legacy LockAndDisturb CHECK crashes with fetch body consumption/abort patterns in the same session to reconstruct the triggering script sequence.

Audit directions

  • V8 re-entry after throwing helpers — Grep core/fetch and streams for sequences that call an ExceptionState-taking helper and then re-enter V8 (LockAndDisturb, promise resolution, ScriptFunction calls) without checking HadException()/HasPendingException() in between.
  • BodyStreamBuffer lifecycle — Examine all callers of CloseAndLockAndDisturb and Close() to find a path where Close() can throw (e.g. detached context, controller in error state) or where an exception is inherited from a prior operation.
  • ReadableStream Close/error under abort — Review interactions between request/response body abort, controller error state, and stream locking, which are the most likely producers of a lingering pending exception.

Before / after

Loading diff…