← WebKit — 2026-W39

3e7224ef1acde060c63f985027e622e54e7b44b4  WebGPU encode-time setPreviouslyCleared on never-submitted command buffers elides lazy clear, disclosing GPU memory

severity high class InfoDisclosure confidence 0.85 signal score 8.56 Source/WebGPU/ patch-now AI review CONFIRMED
Primitive: texture marked previouslyCleared from a command buffer that is discarded/never committed, so lazy-clear is skipped and uninitialized GPU memory is later read
Triage note: Under RC_REMOVE_SEC_ACCEL_3 replaces immediate setPreviouslyCleared() in render-pass/copy paths with setProvisionallyCleared() tracked per command-encoder id (m_provisionalClears + Queue::add/remove/discard/resolveProvisionalTextureClears); a clear is only promoted to durable on commit and dropped/encoded-for-real on discard. This fixes a texture being treated as initialized when its clear never executed, which would expose uninitialized GPU texture memory. RC_REMOVE_SEC_ACCEL_3 bracketing is a strong silent-security signal and WebGPU is renderer-reachable.
Structural signals: smartptr_member_added (+6.47), null_guard (+2.59), area:Source/WebGPU/ (+1.5), large_churn (-2.0)

The bug at a glance

In the WebGPU GPU-process backend a texture could be marked previouslyCleared() from a command buffer that was later discarded or never committed, so its lazy zero-clear never actually executed while the engine believed the texture was initialized. A subsequent read of that texture then exposes uninitialized GPU memory. The patch replaces immediate setPreviouslyCleared() with a provisional, per-command-encoder tracking (setProvisionallyCleared / Queue::add/remove/discard/resolveProvisionalTextureClears) that only promotes a clear to durable on commit and discards (or really encodes) it on discard. This is an information-disclosure defect, renderer-reachable via WebGPU; info-leak/high is well supported (uninitialized cross-allocation GPU memory), with no memory-corruption claim.

A commit/rollback mismatch in lazy-initialization bookkeeping: the ’this texture is already cleared’ flag was set eagerly at encode time but the clear work lived in a command buffer that might be thrown away, leaving the flag true but the bytes uninitialized.

Root cause

The invariant WebGPU relies on is that a texture is marked cleared only once its zero-clear has actually executed on the GPU, so lazy-clear can be safely skipped on later use. Pre-patch (RC_REMOVE_SEC_ACCEL_3) the render-pass, copy, and clearTextureIfNeeded paths called texture.setPreviouslyCleared(…) immediately when they scheduled a clear into the current command encoder/buffer. The flawed sequence: 1. a render pass or copy decides a texture will be cleared and calls setPreviouslyCleared() right away; 2. the clear is recorded into the command encoder’s command buffer; 3. the command buffer is discarded (discardCommandBuffer / makeSubmitInvalid / error path) or simply never committed; 4. the clear never runs, yet previouslyCleared() now returns true, so clearTextureIfNeeded() early-returns and a later read/copy/sample observes uninitialized memory.

The patch makes the mark provisional and tied to a specific command encoder. setPreviouslyCleared(…) is replaced by setProvisionallyCleared(*this) / setProvisionallyCleared(mipLevel, slice, *this), and clearTextureIfNeeded gains an owningCommandEncoder parameter so it records the provisional clear against that encoder (falling back to durable setPreviouslyCleared only when no owner is supplied). Queue gains m_provisionalClears with addProvisionalTextureClear(Texture&, mip, slice, commandEncoderId), removeProvisionalTextureClears, discardProvisionalTextureClears(commandEncoderId), and resolveProvisionalTextureClears(optional committingId). CommandBuffer::preCommitHandler now calls resolveProvisionalTextureClears(encoder.uniqueId()) so provisional marks become durable only at commit; CommandEncoder::discardCommandBuffer, the destructor, and makeSubmitInvalid-style error paths call discardProvisionalTextureClears(m_uniqueId) so an abandoned encoder’s provisional clears are dropped (and the texture stays un-cleared, forcing a real clear later). CommandBuffer destruction also discards the encoder’s provisional clears. PresentationContextIOSurface resolves outstanding provisional clears before its own direct command buffer runs.

The triage bugclass (InfoDisclosure) is exactly what the diff supports: no lifetime or bounds change, only correctness of the cleared/uninitialized bookkeeping. The leak is of uninitialized GPU texture memory, which may contain prior allocations’ contents.

Key code

Clears become provisional per command encoder, resolved on commit and discarded on abandon

// render pass attachment (non-RC path)
texture.setProvisionallyCleared(*this);

// CommandBuffer::preCommitHandler: promote to durable only at commit
m_device->getQueue()->resolveProvisionalTextureClears(commandEncoderRef.uniqueId());

// CommandEncoder::discardCommandBuffer / dtor / error path: drop provisional marks
m_device->getQueue()->discardProvisionalTextureClears(m_uniqueId);

Fix approach

The fix splits the single ‘previouslyCleared’ truth into a provisional state keyed by the owning command encoder’s uniqueId and a durable state. All encode-time call sites now call setProvisionallyCleared(… , *this/owningCommandEncoder) instead of setPreviouslyCleared(), registering the intended clear in Queue::m_provisionalClears. A provisional clear is promoted to durable only through resolveProvisionalTextureClears() in CommandBuffer::preCommitHandler (i.e. when the command buffer actually commits), and is dropped via discardProvisionalTextureClears(m_uniqueId) whenever the encoder/command buffer is discarded, destroyed, or made submit-invalid. Semantically, marking a texture cleared is now contingent on the clear’s command buffer actually executing; an abandoned encoder leaves the texture un-cleared so a real lazy-clear runs on next use. clearTextureIfNeeded gained an owningCommandEncoder argument to thread the right encoder identity (nullptr keeps the old durable behavior for ownerless callers). PresentationContextIOSurface forces resolveProvisionalTextureClears() before running its own command buffer.

Patch walkthrough

  • Source/WebGPU/WebGPU/CommandBuffer.mm — On teardown discards the encoder’s provisional clears; preCommitHandler resolves them (promote to durable) at commit, or resolves globally when there is no encoder.
  • Source/WebGPU/WebGPU/CommandEncoder.h — clearTextureIfNeeded overloads gain a trailing const CommandEncoder* owningCommandEncoder = nullptr parameter to carry encoder identity.
  • Source/WebGPU/WebGPU/CommandEncoder.mm — Render-pass attachment clears, copy-will-completely-clear paths, and clearTextureIfNeeded switch from setPreviouslyCleared() to setProvisionallyCleared(…, *this); destructor, discardCommandBuffer, and the error/submit-invalid path call discardProvisionalTextureClears(m_uniqueId).
  • Source/WebGPU/WebGPU/CommandEncoder.swift — The Swift mirror of the encoder uses setProvisionallyCleared(self) and passes self as owningCommandEncoder (guarded by ENABLE_RC_REMOVE_SEC_ACCEL_3_FOR_SWIFT), matching the C++ behavior.
  • Source/WebGPU/WebGPU/PresentationContextIOSurface.mm — Calls resolveProvisionalTextureClears() before building/running its luminance-clamp command buffer so pending provisional clears are made real first.
  • Source/WebGPU/WebGPU/Queue.h — Declares the provisional-clear bookkeeping API: addProvisionalTextureClear, removeProvisionalTextureClears, discardProvisionalTextureClears(commandEncoderId), resolveProvisionalTextureClears(optional committingId), backed by an m_provisionalClears map.

Background

Lazy (deferred) texture clear — WebGPU guarantees textures read as zero before first write; rather than eagerly zeroing, the backend tracks a previouslyCleared flag and only runs a clear when a texture is used uninitialized.

previouslyCleared flag — Per mip-level/slice state saying a texture subresource is already initialized; if true, clearTextureIfNeeded skips the clear, so a wrong true value means uninitialized memory is used as-is.

Command encoder vs command buffer vs commit — Commands are recorded into a CommandEncoder/command buffer and only take effect when the buffer is committed to the Queue; a discarded or never-committed buffer executes nothing it recorded, including clears.

Provisional vs durable clear (new) — The patch’s provisional clear is contingent on its command buffer committing; resolveProvisionalTextureClears promotes it to durable at commit, discardProvisionalTextureClears drops it on abandon.

Uninitialized GPU memory disclosure — Reading a texture whose clear never ran returns whatever was previously in that GPU allocation, potentially another context’s or process’s data, i.e. a cross-allocation information leak.

Vulnerability window

  1. Setup — Renderer allocates a texture and begins a render pass or copy that schedules a lazy clear for it.
  2. Mark — Pre-patch the texture is immediately marked previouslyCleared even though the clear is only recorded, not executed.
  3. Abandon — The command buffer/encoder is discarded, errored, or never committed, so the recorded clear never runs.
  4. Leak — A later read/copy/sample sees previouslyCleared==true, skips clearing, and returns uninitialized GPU memory.
  5. Post-patch — The clear is only durable on commit; an abandoned encoder’s provisional marks are discarded so the texture is really cleared on next use.

Proof of concept

No regression test ships in the diff. Conceptual trigger (inferred, WebGPU/JS): create a texture, begin a render pass or a copy that would lazily clear it (so the backend records the clear and pre-patch sets previouslyCleared), then cause that command encoder/buffer to be discarded or made invalid before commit (e.g. an error on the same encoder) rather than submitted; afterwards read the texture back (copyTextureToBuffer / sample) and observe non-zero, previously-resident bytes. Exact JS mapping to the discard path is (inferred).

Exploitation

  1. Reach — Renderer WebGPU: allocate a texture and drive a clear-scheduling render pass or copy on a command encoder.
  2. Trigger — Discard/invalidate the command buffer before commit so the recorded clear never executes while the texture is marked cleared (pre-patch).
  3. Primitive — Read uninitialized GPU texture memory via copyTextureToBuffer or shader sampling.
  4. Escalation (inferred) — Open question is how controllably the uninitialized region can be made to contain sensitive prior-allocation data (another origin’s pixels, GPU-process heap); this is an info leak, not a corruption primitive.

Detection & hunting

For defenders and detection engineers:

  • setPreviouslyCleared promoted without a corresponding committed clear — Audit that every durable clear mark traces to a committed command buffer; the provisional map now encodes this invariant.
  • Texture read returns non-zero before any write — GPU validation / test harness comparing freshly allocated textures against zero after discarded-encoder sequences.
  • discardProvisionalTextureClears vs resolveProvisionalTextureClears imbalance — Instrument Queue to flag provisional clears that are neither resolved nor discarded (leaked encoder ids).

Remaining risk in the patch

  • Swift path gated behind a separate flag — The Swift CommandEncoder changes are under ENABLE_RC_REMOVE_SEC_ACCEL_3_FOR_SWIFT; if that flag is not enabled in a build that uses the Swift encoder, those call sites still setPreviouslyCleared eagerly.
  • Queue bookkeeping bodies not in the diff — The implementations of addProvisionalTextureClear/resolve/discard in Queue.mm are only declared here, not shown; correctness of promotion-on-commit and full discard-on-abandon cannot be fully verified from the supplied diff.

Audit directions

  • Other setPreviouslyCleared call sites — Find any remaining callers that mark cleared at encode time rather than commit time, including platform/Swift mirrors.
  • Direct command buffers bypassing the encoder path — Like PresentationContextIOSurface, audit other places that build command buffers directly to ensure provisional clears are resolved first.
  • Other lazy-init state (buffers, bind-group init) — Check whether buffer zero-init or other ‘already initialized’ flags have the same encode-time-vs-commit-time mismatch.
  • Error/invalidation paths — Ensure every makeInvalid/makeSubmitInvalid/discard path discards provisional clears for its encoder id.

Before / after

Loading diff…