← WebKit — 2026-W37

870a585cde030428dfc6ed37e06637b11ecc5d68  'bc1-rgba-unorm';dimension='3d' test cases are failing

severity high class OOB-read confidence 0.6 signal score 8.84 Source/WebGPU/ patch-now AI review CONFIRMED
Primitive: copyBufferToTexture/copyTextureToBuffer split-copy loop assumed blockHeight==1, so for block-compressed formats the per-row offset (y*bytesPerRow) over-advanced into out-of-bounds buffer memory.
Triage note: CommandEncoder.mm/.swift now fetch Texture::texelBlockHeight(aspectSpecificFormat) (returning on 0), stride the y loop by blockHeight, compute offsets as (y/blockHeight)*bytesPerRow, and copy height=blockHeight instead of 1. Fixes incorrect block-compressed copy geometry that mis-sized source/destination offsets in a web-reachable WebGPU path.
Structural signals: checked_int (+5.39), null_guard (+1.95), area:Source/WebGPU/ (+1.5)

The bug at a glance

WebGPU copyBufferToTexture/copyTextureToBuffer split-copy in the GPU process. The split loop that walks a copy region row-by-row assumed a texel block height of 1, so for block-compressed formats (blockHeight 4, etc.) it advanced the source/destination byte offset by y*bytesPerRow for every texel row instead of (y/blockHeight)*bytesPerRow per block-row, over-advancing roughly blockHeight-fold past the intended buffer region and copying height=1 instead of height=blockHeight. The result is an out-of-bounds read (and mis-sized copy) of the buffer backing the copy. High is defensible as a web-reachable GPU-process OOB read; it is a read/mis-copy primitive, no write escalation is shown.

A loop stride and offset formula silently baked in blockHeight==1; block-compressed formats make the per-row stride walk the buffer blockHeight times too fast.

Root cause

The invariant is that when a copy is split into per-row sub-copies, the byte offset for texel row y must index block-row (y/blockHeight), advancing by bytesPerRow once per block-row, and each sub-copy must cover blockHeight texel rows (one block tall). Pre-patch, both the C++ (CommandEncoder.mm) and Swift (CommandEncoder.swift) split loops iterated for (y = 0; y < copySize.height; ++y), computed the offset as y*bytesPerRow (via checkedProduct<uint32_t>(y, sourceBytesPerRow) / multipliedReportingOverflow), and issued each sub-copy with height = 1.

Pre-patch sequence: 1. Script issues a copyBufferToTexture or copyTextureToBuffer on a block-compressed texture with copySize.height > blockHeight (so the 3D fast-path short-circuit height <= 1 is skipped). 2. The loop runs for every texel row y in [0, height). 3. For y >= 1 the offset ybytesPerRow already points one full row-stride into the buffer, but for a BC format each stride of bytesPerRow spans a whole 4-row block; multiplying by the texel row index rather than the block-row index over-advances the offset by about blockHeight x. 4. tripleSum = zTimesBytesPerImage + ybytesPerRow + offset addresses bytes well past the data the buffer actually holds for that region -> OOB read of the source buffer (copyBufferToTexture) or OOB-addressed destination (copyTextureToBuffer); the height=1 sub-copy also mis-sizes the transfer.

The fix fetches blockHeight = Texture::texelBlockHeight(aspectSpecificFormat), returns immediately if it is 0 (guarding an unknown/invalid format), loosens the fast-path guard to copySize.height <= blockHeight, strides the loop y += blockHeight, computes the offset from y / blockHeight (block-row index) times bytesPerRow, and issues each sub-copy with height = blockHeight. The Swift side mirrors this with stride(from:0,to:height,by:Int(blockHeight)) and blockRowTimesSourceBytesPerRow = y / blockHeight. This matches the triage bugclass (OOB-read / incorrect block-compressed copy geometry).

Key code

Stride by blockHeight and index by block-row instead of texel row

auto blockHeight = Texture::texelBlockHeight(aspectSpecificFormat);
if (!blockHeight)
    return;
...
for (uint32_t y = 0; y < copySize.height; y += blockHeight) {
    auto blockRowTimesSourceBytesPerRow = checkedProduct<uint32_t>(y / blockHeight, sourceBytesPerRow);
    if (blockRowTimesSourceBytesPerRow.hasOverflowed())
        return;
    auto tripleSum = checkedSum<uint64_t>(zTimesSourceBytesPerImage.value(), blockRowTimesSourceBytesPerRow.value(), source.layout.offset);
    ...
    copyBufferToTexture(newSource, newDestination, {
        .width = copySize.width,
        .height = blockHeight,
        .depthOrArrayLayers = 1
    });
}

Fix approach

The patch introduces the real texel block height from Texture::texelBlockHeight(aspectSpecificFormat) and threads it through both split loops. It (a) bails on blockHeight == 0, (b) changes the single-row fast-path guard from height <= 1 to height <= blockHeight, (c) strides the loop by blockHeight so one iteration = one block-row, (d) computes the row-stride multiplier from the block-row index y/blockHeight rather than the texel row index y, and (e) emits each sub-copy with copySize.height = blockHeight. The identical correction is applied in the Swift reimplementation. After the fix a BC copy advances exactly one bytesPerRow per block-row and transfers full blocks, so the offsets stay inside the buffer region the copy was validated against.

Patch walkthrough

  • Source/WebGPU/WebGPU/CommandEncoder.mm — In both the copyBufferToTexture and copyTextureToBuffer split loops, fetches texelBlockHeight (return on 0), relaxes the 3D fast-path height guard to blockHeight, strides y by blockHeight, uses (y/blockHeight)*bytesPerRow for the offset, and copies height=blockHeight instead of 1.
  • Source/WebGPU/WebGPU/CommandEncoder.swift — Applies the same blockHeight-aware stride/offset/height correction in the Swift copy implementations using stride(from:to:by:) and y/blockHeight, with a guard that returns when blockHeight == 0 (plus an unrelated formatting change to a createInvalid call).

Background

texelBlockHeight — Returns the pixel height of a format’s compression block (1 for uncompressed, typically 4 for BC/ETC/ASTC); byte addressing of such textures must be done per block-row, not per texel row.

Split per-row copy — Large/3D buffer<->texture copies are decomposed into sub-copies (one per row/block-row) to respect Metal’s per-copy constraints; the decomposition must reproduce the format’s byte layout exactly.

bytesPerRow / bytesPerImage — bytesPerRow is the stride between consecutive block-rows (not texel rows) of a compressed image; using it per texel row over-counts by blockHeight.

aspectSpecificFormat — The format resolved for the specific texture aspect (color/depth/stencil) being copied; block dimensions are a property of this resolved format.

checkedProduct / addingReportingOverflow — Overflow-checked multiply/add (C++ WTF and Swift) used on the offset arithmetic; they catch wraps but cannot catch a semantically wrong multiplier like y instead of y/blockHeight.

Vulnerability window

  1. Setup — Script creates a block-compressed texture and a buffer sized for its actual block-row layout.
  2. Trigger — Issues copyBufferToTexture/copyTextureToBuffer with copySize.height a multiple of blockHeight and > blockHeight so the split loop runs.
  3. Over-advance — Pre-patch the loop advances the offset by y*bytesPerRow per texel row, over-shooting the buffer by ~blockHeight x within a few iterations.
  4. Trigger OOB — tripleSum addresses bytes beyond the buffer’s valid region -> OOB read (source) / mis-addressed copy; height=1 also mis-sizes the transfer.
  5. Mitigated — Post-patch, block-row striding keeps every offset inside the region and copies full blocks.

Proof of concept

No regression test ships. Conceptual trigger (web path inferred): via WebGPU JS create a block-compressed GPUTexture (e.g. a BC format, blockHeight 4) and a GPUBuffer tightly sized to its block-row layout, then encoder.copyBufferToTexture / copyTextureToBuffer with copySize.height a multiple of 4 and larger than 4 so the per-row split loop executes several iterations. Pre-patch the y*bytesPerRow offset over-advances and the copy reads/addresses past the buffer; observable as a crash or as texture contents sourced from out-of-bounds buffer bytes (potential info leak via subsequent readback).

Exploitation

  1. Reach — copyBufferToTexture/copyTextureToBuffer are web-exposed WebGPU commands executed in the GPU process.
  2. Trigger — A block-compressed copy with height > blockHeight drives the split loop into over-advanced offsets.
  3. Primitive — Out-of-bounds read of the copy’s source buffer (or an out-of-bounds-addressed destination write target), with each sub-copy mis-sized to a single texel row.
  4. Leak path (inferred) — Escalation to info disclosure depends on steering which adjacent buffer bytes are read into the texture and reading them back (e.g. via a following copyTextureToBuffer or canvas sample); the diff proves the geometry fault, not the full leak chain.
  5. Scope — GPU-process memory; no cross-process primitive is demonstrated.

Detection & hunting

For defenders and detection engineers:

  • BC copy with height>blockHeight on legacy builds —
  • Buffer over-read reports —
  • texelBlockHeight==0 early return —

Remaining risk in the patch

  • Non-multiple heights — The stride y += blockHeight with copySize.height assumed a block multiple; if a caller can present a height that is not a multiple of blockHeight the final partial block-row handling is not shown in the diff (likely validated upstream, inferred).
  • Dual implementations — The logic lives in both CommandEncoder.mm and CommandEncoder.swift; the two must stay in lockstep or one path could regress independently.
  • blockWidth symmetry — The fix corrects the vertical (blockHeight) stride only; the diff does not show the horizontal/width handling, so any analogous blockWidth assumption elsewhere is unverified here.

Audit directions

  • Other block-dimension loops — Audit every buffer<->texture split loop and mip/layer iterator for implicit blockHeight==1 / blockWidth==1 assumptions on compressed formats.
  • texelBlockHeight callers — Confirm all consumers return/validate on blockHeight 0 and divide indices by blockHeight before multiplying by bytesPerRow.
  • Swift/C++ parity — Cross-check the Swift and C++ copy implementations for any remaining divergence in stride/offset/height computation.

Before / after

Loading diff…