e854405c800373ec28da9e31c712d8b68c9459a8 [WebGPU] Reused render bundle loses dynamic offsets on the replay path
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.
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, dropsmutableand 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
- Setup — From WebGPU JS, record a render bundle that calls setBindGroup with dynamic offsets on a bind group that has dynamic-offset bindings.
- 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).
- Trigger — executeBundles is called again (bundles are reusable), replaying the same lambda with a now-empty dynamicOffsets vector.
- 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).
- 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
- Reach — Standard WebGPU surface from JS: GPURenderBundleEncoder.setBindGroup with dynamic offsets, then repeated GPURenderPassEncoder.executeBundles reaching RenderBundleEncoder.mm in the GPU process.
- Trigger — Replay the same bundle more than once so the recorded setBindGroup lambda is invoked a second time with its moved-from, empty dynamicOffsets.
- 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.
- 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
mutablethat 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.