← WebKit — 2026-W35

ff78e4fceef7783f665b130f1c01766da3a2c137  Cache invalidation bypass via monotonic max-index tracking leads to GPU OOB read

severity high class OOB-read confidence 0.75 signal score 8.72 Source/WebGPU/ patch-now AI review CONFIRMED
Primitive: Index-validation high-water mark advanced before the clamp-shader validation pass actually ran, letting later draws skip validation in WebGPU Buffer
Triage note: needsIndexValidation() previously raised m_maxUnsignedIndex/m_maxUshortIndex immediately, so a subsequent writeBuffer with an index at or below that value returned needsUpdate=false and skipped validation even though no clamp pass had succeeded. The fix adds separate m_maxValidated* marks updated only after a successful validation (DrawIndexValidationCacheEntry set) and reset on invalidation, closing an index-buffer validation bypass that permits out-of-bounds index/vertex access in the GPU process.
Structural signals: clamp_saturate (+2.92), msg_strong (+3.0), area:Source/WebGPU/ (+1.5), surgical (+1.3)

The bug at a glance

This is an index-validation bypass in the WebGPU implementation (GPU process): Buffer::needsIndexValidation advanced the m_maxUnsignedIndex/m_maxUshortIndex high-water marks at the moment it was asked whether validation was needed, rather than after a clamp-shader validation pass actually succeeded, so a later writeBuffer with an index at or below that mark returned needsUpdate=false and skipped validation that had never run. The result is that draws can use index values that were never bounds-validated against the vertex buffer, giving out-of-bounds index/vertex access during GPU drawing. It runs in the GPU process and is reachable from WebContent through the WebGPU API, and an index-validation bypass is a credible OOB-read (and potentially OOB-write) primitive, so high severity is reasonable; the diff proves the validation-skip logic error, while the concrete out-of-bounds consequence and any escalation beyond an OOB read is (inferred).

The hook is a cache high-water mark that conflated ‘we asked whether validation is needed’ with ‘validation succeeded’ — raising the threshold inside needsIndexValidation() meant merely querying it immunized all equal-or-smaller indices from ever being validated.

Root cause

The violated invariant is that the ’these index values are known safe’ threshold may only advance after a clamp-shader validation pass has actually succeeded for those values; otherwise a cheap writeBuffer fast path will skip validation for indices that were never checked. In Source/WebGPU/WebGPU/Buffer.mm the vulnerable (RC_REMOVE_SEC_ACCEL_3) Buffer::needsIndexValidation computed needsUpdate = maxUnsignedIndex > m_maxUnsignedIndex || maxUshortIndex > m_maxUshortIndex and then immediately raised the marks with m_maxUnsignedIndex = std::max(m_maxUnsignedIndex, maxUnsignedIndex) / m_maxUshortIndex = std::max(...). The pre-patch sequence is: 1. A writeBuffer/draw path calls needsIndexValidation(maxUnsignedIndex, maxUshortIndex) to decide whether to run the clamp/validation shader. 2. needsUpdate is computed against the current marks, but the marks are bumped up to the queried maxima right there, before any validation shader has been dispatched or confirmed. 3. The validation pass may not actually run to completion (or may be skipped/dropped), yet the high-water mark now claims those indices are covered. 4. A subsequent writeBuffer with an index at or below the (prematurely raised) mark computes needsUpdate=false and skips validation entirely. 5. A draw then consumes indices that were never bounds-checked against the vertex count, reading vertices out of range.

The fix introduces a second pair of marks, m_maxValidatedUnsignedIndex / m_maxValidatedUshortIndex (declared in Source/WebGPU/WebGPU/Buffer.h), that represent values proven safe. needsIndexValidation now compares the request against the validated marks (maxUnsignedIndex > m_maxValidatedUnsignedIndex || ...) and only records the current write’s maxima into m_maxUnsignedIndex/m_maxUshortIndex (overwriting, not max-accumulating) so the validated threshold can be updated after the pass. The validated marks are raised only in the routine that records a DrawIndexValidationCacheEntry — i.e. after a successful clamp-shader validation — via m_maxValidatedUnsignedIndex = std::max(m_maxValidatedUnsignedIndex, m_maxUnsignedIndex); and they are reset to 0 on the invalidation path (alongside ++m_indexContentsGeneration; m_indexValueUpperBoundUint/Ushort = UINT*_MAX).

This matches the triage bugclass (OOB-read via a validation bypass) and is a check-ordering (TOCTOU-flavored) logic error: the ‘validated’ state transition was performed at check time instead of at validation-success time. The diff proves the skip; whether a skipped draw yields a read-only out-of-bounds (info leak) or can be pushed to an out-of-bounds write depends on the pipeline and is (inferred).

Key code

needsIndexValidation now compares against separate validated marks that advance only after a successful pass

// vulnerable (RC_REMOVE_SEC_ACCEL_3):
const bool needsUpdate = maxUnsignedIndex > m_maxUnsignedIndex || maxUshortIndex > m_maxUshortIndex;
m_maxUnsignedIndex = std::max(m_maxUnsignedIndex, maxUnsignedIndex);
m_maxUshortIndex = std::max(m_maxUshortIndex, maxUshortIndex);
// fix (#else):
const bool needsUpdate = maxUnsignedIndex > m_maxValidatedUnsignedIndex || maxUshortIndex > m_maxValidatedUshortIndex;
m_maxUnsignedIndex = maxUnsignedIndex;
m_maxUshortIndex = maxUshortIndex;
// after a successful clamp pass (set DrawIndexValidationCacheEntry):
m_maxValidatedUnsignedIndex = std::max(m_maxValidatedUnsignedIndex, m_maxUnsignedIndex);
m_maxValidatedUshortIndex = std::max(m_maxValidatedUshortIndex, m_maxUshortIndex);

Fix approach

The patch decouples ‘queried’ from ‘validated’ by adding m_maxValidatedUnsignedIndex / m_maxValidatedUshortIndex. needsIndexValidation() compares the incoming maxima against those validated marks (not the raw marks) to decide needsUpdate, and merely records the current write’s maxima into m_maxUnsignedIndex/m_maxUshortIndex. The validated marks are advanced only in the code path that records a DrawIndexValidationCacheEntry — after the clamp-shader validation actually succeeds — and are reset to 0 on the invalidation path that bumps m_indexContentsGeneration and restores the UINT/UShort upper bounds. The new guard is ‘skip validation only for indices at or below a value a prior pass actually validated’; the semantic change is that a validated high-water mark now reflects proven-safe coverage rather than merely requested coverage, closing the window where a mere query immunized future writes.

Patch walkthrough

  • Source/WebGPU/WebGPU/Buffer.h — Adds the members m_maxValidatedUnsignedIndex (uint32_t) and m_maxValidatedUshortIndex (uint16_t) alongside the existing m_maxUnsignedIndex/m_maxUshortIndex to track values proven safe by a validation pass.
  • Source/WebGPU/WebGPU/Buffer.mm — needsIndexValidation() compares against the validated marks and only records the current write’s maxima; the routine that sets DrawIndexValidationCacheEntry raises the validated marks after a successful clamp pass; and the invalidation path resets both validated marks to 0 so stale coverage is dropped when index contents change.

Background

WebGPU (GPU process) — WebKit’s WebGPU backend (Source/WebGPU) runs in the GPU process and translates web GPU commands to the platform API (Metal here, per the .mm files); OOB access here corrupts/leaks GPU-process memory.

Index buffer / index validation — Indexed draws read vertex indices from an index buffer; those indices must be bounds-checked (clamped) against the vertex count, or a draw can fetch vertices outside the bound vertex buffer.

Clamp-shader validation pass — A GPU pass that validates/clamps index values so indexed draws stay in bounds; DrawIndexValidationCacheEntry records that a (firstIndex,indexCount,indexType,…) draw was validated, with a vertexCount and validationGeneration.

High-water mark cache — m_maxUnsignedIndex/m_maxUshortIndex record the largest index values seen; the intent is to skip revalidation when a new write’s indices do not exceed what was already validated.

Index contents generation / invalidation — m_indexContentsGeneration and the m_indexValueUpperBound* fields track when index data changes; on such change the validated coverage must be reset so previously-validated maxima no longer suppress validation.

Vulnerability window

  1. Setup — Web content creates a WebGPU buffer used as an index buffer and a pipeline that issues indexed draws.
  2. Prime the mark — A writeBuffer/draw path calls needsIndexValidation with some maxima, which (pre-patch) immediately raises m_maxUnsignedIndex/m_maxUshortIndex regardless of whether the clamp pass actually validated those values.
  3. Bypass — A later writeBuffer with indices at or below the raised mark returns needsUpdate=false, so no validation pass runs.
  4. OOB draw — An indexed draw consumes those never-validated indices, fetching vertices outside the vertex buffer in the GPU process.
  5. Fallout — Out-of-bounds vertex/index access yields a GPU-process memory read (info leak) or worse, depending on the draw pipeline (inferred).

Proof of concept

No regression test ships in the diff. Conceptual trigger (WebGPU JS): create a vertex buffer and an index buffer, run a draw whose indices cause needsIndexValidation to raise the mark without a completed clamp pass, then writeBuffer new index data whose max index is at or below that mark so validation is skipped, and issue an indexed draw whose indices exceed the vertex count. The precise sequence that leaves the mark raised without a successful validation, and whether the OOB is read-only, is (inferred).

Exploitation

  1. Reach — WebContent uses the WebGPU API; commands cross into the GPU process where Buffer validation runs.
  2. Trigger — Raise the (pre-patch) high-water mark via one query/draw, then submit index data within that range so validation is skipped.
  3. Primitive — Primary: indexed draw with unvalidated, out-of-range indices -> OOB vertex fetch in the GPU process. Secondary: an OOB index read feeds the vertex stage, potentially reading adjacent GPU buffer memory.
  4. Open questions — Escalation depends on whether the OOB fetch can be observed (rendered/read back) as an info leak, how far past the buffer the indices can reach, and whether any write-back path turns it into an OOB write — none of which the diff establishes.

Detection & hunting

For defenders and detection engineers:

  • needsIndexValidation returning false for indices never validated — Instrument Buffer.mm to compare the validated marks against actual DrawIndexValidationCacheEntry generations; flag skips with no matching successful pass.
  • Indexed draws with indices exceeding vertex count — Add GPU-process assertions/telemetry on index-vs-vertexCount at draw submission.
  • Builds defining RC_REMOVE_SEC_ACCEL_3 — The premature-mark behavior is selected by that macro; flag configs enabling it.

Remaining risk in the patch

  • Validated-mark reset coverage — The fix resets m_maxValidated* only on the one invalidation path shown (the m_indexContentsGeneration bump); if index contents or vertex count can change through another path not in this diff, a stale validated mark could still suppress a needed validation (one path unverified from the supplied diff).
  • m_maxUnsignedIndex now overwrites rather than accumulates — needsIndexValidation sets m_max*Index = current write’s max; correctness relies on the validation-success routine reading that value before the next needsIndexValidation overwrites it, which the diff does not fully show the ordering for.

Audit directions

  • Other validation caches in WebGPU Buffer — Review m_drawIndexedCache / DrawIndexValidationCacheEntry generation handling for other spots where a cache mark advances before the validating work completes.
  • Invalidation completeness — Enumerate every path that changes index-buffer contents or vertex count and confirm each resets the validated marks and the generation.
  • Analogous clamp/bounds caches — Look for the same ‘advance threshold at check time’ pattern in other GPU-process validators (vertex buffers, storage buffers, bind groups).

Before / after

Loading diff…