← WebKit — 2026-W37

77acddc2cf457230af9085b81d4453a9326d7af2  [WebGPU] Queue::writeTexture performs unnecessary data copies

severity high class OOB-write confidence 0.65 signal score 14.53 Source/WebGPU/ patch-now AI review PLAUSIBLE
Primitive: Compressed-texture repack in Queue::writeTexture lacked the per-row/per-image bounds checks that the hardened path adds, risking out-of-bounds buffer access during the repack copy.
Triage note: Bracketed by RC_REMOVE_SEC_ACCEL_3. The #else (secure) path replaces the Vector<uint8_t> repack with reserveTemporaryBuffer() staging and adds overflow-checked per-row/per-image offsets (checkedProduct/checkedSum, checkedCopiedBytesPerImage > newBytesPerImage guard, per-image padding memset) when repacking compressed texture data. Hardens a WebGPU texture-upload OOB.
Structural signals: checked_int (+5.39), bounds_guard (+3.25), null_guard (+2.59), clamp_saturate (+1.8), area:Source/WebGPU/ (+1.5)

The bug at a glance

WebGPU Queue::writeTexture compressed-texture repack path in the GPU process. When uploading block-compressed texture data that needs re-packing (row-stride mismatch or non-block-aligned bytesPerRow), the old path copied row-by-row into a Vector<uint8_t> newData whose per-image/per-row offsets were only bounds-checked at the (maxY-1, maxZ-1) corner, with intermediate products computed without per-iteration overflow checks. The hardened path routes through reserveTemporaryBuffer staging and adds overflow-checked per-row/per-image offsets, a checkedCopiedBytesPerImage > newBytesPerImage guard, and a per-image padding memset. The clearly-proven change is defense-in-depth against an out-of-bounds write during repack; a concrete crafted-dimension overflow that reaches OOB is plausible but not fully isolated in the diff, so I flag a possible severity caveat (see summary).

The repack copy trusts geometry (bytesPerRow/bytesPerImage vs newBytesPerRow/newBytesPerImage) that script supplies for a compressed format; the hardened path refuses to proceed unless every intermediate product and the per-image copied size fit.

Root cause

The invariant is that every source offset (ybytesPerRow + zbytesPerImage + dataLayout.offset) and destination offset (ynewBytesPerRow + znewBytesPerImage) used during compressed repack must stay within the source span and the destination allocation for every (y,z), not merely at the far corner. Pre-patch (the RC_REMOVE_SEC_ACCEL_3 / buggy branch) allocated newData = Vector<uint8_t>(newBytesPerImage*maxZ) and copied with size_t loops, validating only checkedNewBytesSum/checkedBytesSum at (maxY-1, maxZ-1).

Pre-patch sequence: 1. Script calls writeTexture with a compressed format and a bytesPerRow/offset that force widthMismatch or a non-multiple-of-blockSize bytesPerRow. 2. maxY/maxZ and newBytesPerRow/newBytesPerImage are derived from the (attacker-influenced) copy and texture geometry. 3. The corner check passes, but intermediate per-row destination writes assume maxY*newBytesPerRow <= newBytesPerImage (i.e. that the copied rows plus padding fit each image slot); nothing enforces copiedBytesPerImage <= newBytesPerImage, and the per-z/per-y products are not individually overflow-checked. 4. memcpySpan writes destBytesSpan at an offset/length that, for crafted geometry where products wrap or where copiedBytesPerImage exceeds newBytesPerImage, lands outside the intended region -> OOB write into the repack buffer.

The fix replaces the Vector<uint8_t> destination with a reserveTemporaryBuffer(newDataSize) staging buffer and recomputes every offset with checkedProduct/checkedSum, bailing on any overflow. It adds the explicit guard checkedCopiedBytesPerImage = checkedProduct(maxY, newBytesPerRow); if (hasOverflowed() || value() > newBytesPerImage) return;, and after each image’s rows writes a padding memset of exactly newBytesPerImage - copiedBytesPerImage zero bytes at the checked padding offset. reserveTemporaryBuffer itself bounds-checks: large sizes get a dedicated safeCreateBuffer, otherwise it grows a shared temporary buffer only when m_temporaryBufferOffset + size > length. Because the triage bugclass (OOB-write) is what the added guards defend, the diff supports classifying this as an OOB-write hardening; what the diff does not isolate is a single unchecked expression that definitely overflowed pre-patch, so the exploitable instance is (inferred) from the shape of the added checks.

Key code

New per-image size guard and padding memset that bound the repack destination

auto checkedCopiedBytesPerImage = checkedProduct<uint32_t>(maxY, newBytesPerRow);
if (checkedCopiedBytesPerImage.hasOverflowed() || checkedCopiedBytesPerImage.value() > newBytesPerImage)
    return;
const auto copiedBytesPerImage = checkedCopiedBytesPerImage.value();
auto reservation = reserveTemporaryBuffer(newDataSize);
if (!reservation.first)
    return;
// ... per-row memcpySpan(destBytesSpan, sourceBytesSpan) with checked offsets ...
auto checkedPaddingOffset = checkedSum<uint32_t>(zTimesNewBytesPerImage.value(), copiedBytesPerImage);
if (checkedPaddingOffset.hasOverflowed())
    return;
memsetSpan(newDataSpan.subspan(checkedPaddingOffset.value(), newBytesPerImage - copiedBytesPerImage), 0);

Fix approach

The patch stages the repacked data in a GPU temporary buffer obtained via reserveTemporaryBuffer(newDataSize) (which itself refuses or safely allocates based on size) instead of a heap Vector, and it re-derives every source/destination offset per (z,y) with checkedProduct/checkedSum, returning on any overflow. It adds the invariant copiedBytesPerImage (= maxY*newBytesPerRow) <= newBytesPerImage before copying, and zero-fills the trailing padding of each image slot with a bounds-checked memset so no stale/overrun bytes reach the GPU. dataLayoutOffset is reset to 0 and data is repointed at the staging span. The upload tail is updated so the repacked buffer is used directly (temporaryBuffer = repackedBuffer) and the noCopy/newBufferWithBytesNoCopy keep-alive logic that existed only for the Vector path is dropped on the secure branch.

Patch walkthrough

  • Source/WebGPU/WebGPU/Queue.h — Declares reserveTemporaryBuffer(size_t) on the secure branch, exposing a bounds-aware staging allocator used by the repack path.
  • Source/WebGPU/WebGPU/Queue.mm — Adds reserveTemporaryBuffer (dedicated safeCreateBuffer for large sizes, else a growing shared temporary buffer) and rewrites the compressed repack in writeTexture to copy into that buffer with fully overflow-checked per-row/per-image offsets, a copiedBytesPerImage <= newBytesPerImage guard, and a per-image padding memset; the upload tail consumes repackedBuffer directly instead of newData.

Background

Block-compressed texture formats — Formats (BC/ASTC/ETC) store pixels in fixed blocks (e.g. 4x4); bytes are organized per block-row, so byte offsets must be computed from block dimensions, not per-texel.

bytesPerRow / bytesPerImage repack — WebGPU lets the page specify a source row stride; if it differs from Metal’s required stride the data is repacked into a tightly-strided temporary before the GPU copy, which is where the offset math lives.

checkedProduct / checkedSum — WTF overflow-checked arithmetic returning a value plus hasOverflowed(); used so that attacker-influenced multiplications/additions cannot silently wrap into a small in-bounds-looking offset.

reserveTemporaryBuffer — A GPU staging allocator that either dedicates a safeCreateBuffer for large requests or sub-allocates from a shared temporary MTLBuffer, replacing the heap Vector<uint8_t> repack destination.

newBufferWithBytesNoCopy — A Metal buffer aliasing existing host bytes without copying; requires the backing storage to stay alive until the GPU consumes it, which the old Vector path had to manage explicitly.

Vulnerability window

  1. Setup — Script creates a block-compressed texture and prepares an upload whose bytesPerRow/offset force a repack (width mismatch or non-block-multiple stride).
  2. Trigger — writeTexture enters the compressed repack branch and derives maxY/maxZ/newBytesPerRow/newBytesPerImage from the supplied geometry.
  3. Copy — Pre-patch: row-by-row memcpy into newData with only corner-level bounds checks and unchecked intermediate products.
  4. Overflow (inferred) — Crafted dimensions where copiedBytesPerImage exceeds newBytesPerImage or where a product wraps let a destination/source offset escape its region -> OOB.
  5. Mitigated — Post-patch: per-iteration checked offsets, a copiedBytesPerImage<=newBytesPerImage guard, and padding memset make the repack reject such geometry.

Proof of concept

No regression test ships. Conceptual trigger (web path inferred): via WebGPU JS, create a block-compressed GPUTexture and call queue.writeTexture with a GPUImageDataLayout whose bytesPerRow is not a multiple of the block byte size (or mismatches the logical width) so the repack branch runs, choosing copy dimensions that make maxY*newBytesPerRow exceed newBytesPerImage. Pre-patch the repack copy writes outside the intended per-image region of the repack destination. A reliable PoC must hit the exact pre-patch unchecked expression, which the diff does not pin down, so this is presented as the conceptual path.

Exploitation

  1. Reach — queue.writeTexture is web-exposed; the repack runs in the GPU process.
  2. Trigger — Supply a compressed-format upload with a mismatched/unaligned bytesPerRow to force the repack branch with crafted maxY/newBytesPerRow/newBytesPerImage.
  3. Primitive — Out-of-bounds write into the repack staging buffer (and OOB read of the source span) when intermediate offsets exceed the allocation.
  4. Shaping (inferred) — Escalation requires controlling what lies adjacent to the repack buffer and how far/with what bytes the overrun reaches; neither the overrun magnitude nor adjacency control is established by the diff.
  5. Scope — Corruption is within the GPU process; no cross-process effect is shown.

Detection & hunting

For defenders and detection engineers:

  • Validation error strings —
  • Compressed repack with odd geometry —
  • Repack-buffer overruns —

Remaining risk in the patch

  • Severity strength — The diff is dominated by added overflow checks and a size guard (defense-in-depth); it does not isolate a single pre-patch expression proven to overflow into an OOB, so the high severity is supported as an OOB-write class but the concrete exploitable instance is (inferred).
  • reserveTemporaryBuffer rounding — m_temporaryBufferOffset += roundUpToMultipleOf(64, size) and the >= largeBufferSize split are not shown to be overflow-checked for size near SIZE_MAX, though newDataSize was previously checked via checkedNewBytesPerImageTimesMaxZ.
  • Branch divergence — Two code paths (RC_REMOVE vs secure) are maintained in parallel; the keep-alive semantics differ (the secure path drops the Vector newBufferWithBytesNoCopy handling), so any build using the RC_REMOVE branch retains the weaker path.

Audit directions

  • Other repack/stride loops — Audit copyBufferToTexture/copyTextureToBuffer and any path computing ybytesPerRow + zbytesPerImage for per-iteration (not just corner) bounds and overflow checks.
  • Compressed-format geometry — Review all code that derives newBytesPerRow/newBytesPerImage from block dimensions for the copiedBytesPerImage <= newBytesPerImage invariant.
  • Temporary buffer suballocation — Check every reserveTemporaryBuffer/newTemporaryBufferWithBytes caller for correct size accounting and keep-alive of host bytes for noCopy buffers.

Before / after

Loading diff…