← WebKit — 2026-W39

e854405c800373ec28da9e31c712d8b68c9459a8  [WebGPU] Reused render bundle loses dynamic offsets on the replay path

severity low class LogicError confidence 0.6 signal score 6.37 Source/WebGPU/ AI review PLAUSIBLE
Primitive: use-after-move of captured dynamicOffsets Vector across RenderBundle replay in WebGPU RenderBundleEncoder setBindGroup
Triage note: The recorded setBindGroup command captured dynamicOffsets and WTF::move'd it out of a mutable lambda; WebGPU render bundles are replayed on each executeBundles, so the second+ replay passed a moved-from (empty) offsets vector to setBindGroup, and the reset was changed from std::nullopt to a valid BindGroupDynamicOffsetsContainer(). Fix copies the offsets per invocation; attacker-reachable from JS and bind-group dynamic offsets govern GPU buffer binding ranges, making an empty offsets vector indexed per dynamic binding an out-of-bounds/incorrect-offset hazard.
Structural signals: uaf_lifetime (+3.57), area:Source/WebGPU/ (+1.5), surgical (+1.3)

The bug at a glance

In WebGPU’s RenderBundleEncoder (Source/WebGPU/WebGPU/RenderBundleEncoder.mm, GPU process) a recorded setBindGroup command captured dynamicOffsets and WTF::move’d them out of a mutable replay lambda. WebGPU render bundles are replayed on every executeBundles, so the second and later replays passed a moved-from (empty) dynamicOffsets vector to setBindGroup. The diff proves a use-after-move logic defect that delivers wrong/empty dynamic offsets on bundle reuse; whether that empties-then-indexes into an out-of-bounds buffer binding range (the triage OOB-read) depends on setBindGroup’s body, which is not in this diff, so the OOB escalation is (inferred). Honest read: the proven defect is a use-after-move producing incorrect bind-group dynamic offsets on replay; the high/OOB-read severity is plausible but not fully grounded by the supplied diff (flagged).

A replay lambda that consumed its own captured state with WTF::move - correct exactly once, silently wrong (empty) on every subsequent bundle execution.

Root cause

The violated invariant is that a recorded render-bundle command must be replayable idempotently: each executeBundles replay must see the same captured arguments. In RenderBundleEncoder.mm the setBindGroup recording captured dynamicOffsets = WTF::move(dynamicOffsets) into a mutable lambda and, inside, called protectedThis->setBindGroup(groupIndex, group.get(), WTF::move(dynamicOffsets)). The pre-patch sequence: 1. the encoder records the setBindGroup command, moving the offsets into the lambda’s capture; 2. the first executeBundles replay runs the lambda and WTF::move’s the captured offsets into setBindGroup, leaving the lambda’s captured vector in a moved-from (empty) state; 3. the second (and every later) executeBundles replays the same lambda, now passing an empty dynamicOffsets vector to setBindGroup; 4. setBindGroup receives zero dynamic offsets where the bind group layout expects one per dynamic binding.

The fix removes mutable and copies the captured offsets per invocation: auto offsetsCopy = dynamicOffsets; protectedThis->setBindGroup(groupIndex, group.get(), WTF::move(offsetsCopy));. Each replay now moves a fresh copy, so the capture is never consumed and every replay sees the full offsets. Separately, the reset in initWithICB changes m_bindGroupDynamicOffsets = std::nullopt; to m_bindGroupDynamicOffsets = BindGroupDynamicOffsetsContainer();, i.e. an empty container rather than a disengaged optional - a semantic change so downstream code that distinguishes ’no dynamic-offset state’ from ‘present but empty’ sees a constructed container after reset.

As to bugclass: the diff definitively shows a use-after-move (a lifetime/logic defect). The triage labels it OOB-read because dynamic offsets govern the byte range of each bound buffer, so a wrong/empty offsets vector that setBindGroup still indexes per dynamic binding would read a binding at the wrong (or default-0) offset, potentially outside the intended sub-range. However setBindGroup’s handling of an undersized offsets vector is not in the supplied diff, so the out-of-bounds consequence is (inferred) rather than proven; it is equally consistent with a validation path that rejects the mismatch.

Key code

Replay lambda stops consuming its captured offsets - copy per invocation instead of WTF::move out of a mutable lambda

- recordCommand([groupIndex, group = protect(groupPtr), protectedThis = protect(*this), dynamicOffsets = WTF::move(dynamicOffsets)]() mutable {
-     protectedThis->setBindGroup(groupIndex, group.get(), WTF::move(dynamicOffsets));
+ recordCommand([groupIndex, group = protect(groupPtr), protectedThis = protect(*this), dynamicOffsets = WTF::move(dynamicOffsets)]() {
+     auto offsetsCopy = dynamicOffsets;
+     protectedThis->setBindGroup(groupIndex, group.get(), WTF::move(offsetsCopy));
     return false;
  });

Fix approach

The patch makes the recorded setBindGroup command replay-safe. The replay lambda is no longer mutable and no longer moves its captured dynamicOffsets into setBindGroup; instead each invocation makes a local offsetsCopy of the still-intact capture and moves that copy, so the captured offsets survive for every subsequent executeBundles replay. The companion change sets m_bindGroupDynamicOffsets to a default-constructed BindGroupDynamicOffsetsContainer() (empty but engaged) rather than std::nullopt on reset, aligning the reset state with ‘present, empty’ so later code does not confuse a reset with an absent container. After the fix every replay delivers the complete dynamic offsets originally recorded.

Patch walkthrough

  • Source/WebGPU/WebGPU/RenderBundleEncoder.mm — In the setBindGroup recording, drops mutable and replaces the move-out-of-capture with a per-invocation copy (offsetsCopy) so replay is idempotent; also changes the reset of m_bindGroupDynamicOffsets from std::nullopt to BindGroupDynamicOffsetsContainer().

Background

WebGPU render bundle — A GPURenderBundle pre-records a sequence of render commands once and replays them cheaply on every executeBundles call. Recorded command closures must therefore be re-executable and must not mutate their own captured arguments.

setBindGroup dynamic offsets — A bind group with dynamic-offset bindings takes a per-binding byte offset array at bind time; these offsets select the live sub-range of each bound buffer, so an empty or wrong offsets array mis-selects buffer ranges.

WTF::move / use-after-move — WTF::move casts to an rvalue so the target is moved-from; a moved-from Vector is left valid-but-empty. Moving out of a lambda capture that will be invoked again leaves the capture empty on the next call - the defect here.

mutable lambda capture — A mutable lambda may modify its by-value captures across invocations. That permitted the WTF::move to empty dynamicOffsets permanently; removing mutable plus copying restores idempotent replay.

BindGroupDynamicOffsetsContainer vs std::nullopt — The offsets state is an optional container; std::nullopt means ’no state’, a default-constructed container means ‘present but empty’. The reset change picks the latter so consumers treat a reset encoder as having an engaged, empty container.

Vulnerability window

  1. Setup — From WebGPU JS, record a render bundle that calls setBindGroup with dynamic offsets on a bind group that has dynamic-offset bindings.
  2. First replay — executeBundles runs the recorded lambda once; it WTF::move’s the captured offsets into setBindGroup, correctly - but leaves the capture moved-from (empty).
  3. Trigger — executeBundles is called again (bundles are reusable), replaying the same lambda with a now-empty dynamicOffsets vector.
  4. Fault — setBindGroup receives zero dynamic offsets where the layout expects one per dynamic binding, mis-binding buffer ranges (OOB/incorrect-offset consequence inferred, as setBindGroup’s handling is not in the diff).
  5. Post-patch — The replay copies the intact captured offsets each time, so every executeBundles delivers the full, correct offsets; reset uses an engaged empty container.

Proof of concept

No test ships in the diff. Conceptually (WebGPU JS): create a bind group layout with one or more dynamic-offset buffer bindings; in a GPURenderBundleEncoder record setBindGroup(index, group, [offset…]) with non-zero dynamic offsets and finish the bundle; then call pass.executeBundles([bundle]) twice (or in two submissions). Pre-patch, the first replay consumes the offsets and the second replay binds with an empty offsets array, mis-selecting the buffer sub-range. Whether this reaches an out-of-bounds GPU fetch depends on setBindGroup/validation (not in diff) - (inferred).

Exploitation

  1. Reach — Standard WebGPU surface from JS: GPURenderBundleEncoder.setBindGroup with dynamic offsets, then repeated GPURenderPassEncoder.executeBundles reaching RenderBundleEncoder.mm in the GPU process.
  2. Trigger — Replay the same bundle more than once so the recorded setBindGroup lambda is invoked a second time with its moved-from, empty dynamicOffsets.
  3. Primitive (inferred) — If setBindGroup indexes the (now empty) offsets array per dynamic binding, the primary effect is an incorrect or out-of-bounds buffer-range selection - reading a bound buffer at an unintended offset; this consequence is inferred because setBindGroup’s handling of a short offsets vector is not in the diff.
  4. Escalation (inferred) — Open questions gating anything beyond wrong rendering: whether a short offsets vector bypasses the per-binding bounds check and whether the mis-selected range reads adjacent GPU memory that is observable - unproven here; the diff only establishes the use-after-move.

Detection & hunting

For defenders and detection engineers:

  • Dynamic offsets empty on bundle replay — Instrument RenderBundleEncoder setBindGroup replay to assert the dynamicOffsets size matches the recorded count across repeated executeBundles; pre-patch it drops to zero after the first replay.
  • Metal validation / OOB on re-executed bundles — Look for GPU-process faults or validation errors that appear only on the second+ executeBundles of a bundle using dynamic-offset bind groups.
  • Repeated executeBundles of the same bundle — Flag WebGPU command streams that reuse a render bundle containing dynamic-offset setBindGroup calls, the precondition for the stale empty-offsets path.

Remaining risk in the patch

  • OOB consequence unverified from diff — setBindGroup’s body is not in the supplied diff, so whether an empty dynamicOffsets vector actually causes an out-of-bounds buffer-range read (vs. being rejected by validation or defaulting offsets to 0) is unproven - the triage OOB-read severity is not fully grounded here.
  • Per-copy cost / behavior change — Copying offsets per replay is a behavior/perf change; if any other recorded command still moved shared state the idempotency fix would be incomplete, but only the one lambda is shown changed in the diff.

Audit directions

  • Other recorded-command lambdas — Grep RenderBundleEncoder.mm and sibling encoders for recordCommand lambdas marked mutable that WTF::move captured state into a call - any such lambda is non-idempotent across replay.
  • setBindGroup offset handling — Review setBindGroup (not in this diff) to confirm it validates dynamicOffsets.size() against the bind group’s dynamic-binding count, which would bound the inferred OOB.
  • Replay idempotency generally — Audit all render-bundle replay paths for captured Vectors/RefPtrs consumed by move, which would corrupt the second replay.
  • nullopt vs empty-container assumptions — Check consumers of m_bindGroupDynamicOffsets for logic that depended on the previous std::nullopt reset value and might behave differently with an engaged empty container.

Before / after

Loading diff…