← WebKit — 2026-W36

0d62770163df01132e6dcfcae0219266e7165acd  Open-encoder guard in BindGroup::updateExternalTextures keyed on caller-supplied ExternalTexture parameter

severity high class UAF confidence 0.6 signal score 8.75 Source/WebGPU/ patch-now AI review CONFIRMED
Primitive: Updating a WebGPU external texture while bound/in-use by an open command encoder
Triage note: BindGroup::updateExternalTextures (shipping #else of RC_REMOVE_SEC_ACCEL_3) now iterates m_resources to confirm the texture is actually bound and that no bound external texture has openCommandEncoderCount() before updating; the old path only checked the passed texture's own encoder count, allowing an in-flight GPU resource to be mutated.
Structural signals: smartptr_member_added (+3.0), null_guard (+1.95), msg_soft (+1.0), area:Source/WebGPU/ (+1.5), surgical (+1.3)

The bug at a glance

BindGroup::updateExternalTextures (Source/WebGPU/WebGPU/BindGroup.mm) in the shipping (#else of RC_REMOVE_SEC_ACCEL_3) path previously gated the update only on !m_bindGroupLayout || externalTexture.openCommandEncoderCount(), without verifying the external texture is actually a resource bound in this bind group, and without checking every bound external texture for an open command encoder. The patch iterates m_resources and refuses the update unless the passed texture is found bound and no bound external texture has an open command encoder. This runs in the GPU process WebGPU backend; the proven defect is a missing in-use/bound-resource validation that lets an external texture be reconstructed while an encoder still references it. Escalation to a use-after-free of GPU-referenced backing is plausible but not directly shown by the diff (inferred), so I treat the firmly-proven issue as a validation/lifetime gap rather than a confirmed UAF; triage severity high is defensible but the UAF primitive is an inference.

The security-gating difference is hidden in a build flag: the hardened #else path replaces a single self-check with a full scan of the bind group’s bound resources, revealing that the old check could update a texture that was in-flight (or not even bound) in this group.

Root cause

The invariant is that a WebGPU external texture must not have its device backing recreated while it is bound into a bind group that an open command encoder may still reference. BindGroup::updateExternalTextures(ExternalTexture& externalTexture) then calls device->createExternalTextureFromPixelBuffer(externalTexture.pixelBuffer(), externalTexture.colorSpace()), which produces fresh textureData for the resource — mutating what the GPU may consume.

Pre-patch (shipping) sequence: 1. Check !m_bindGroupLayout or externalTexture.openCommandEncoderCount() on just the passed-in texture. 2. If that single texture reports no open encoder, proceed. 3. Rebuild the external texture backing via createExternalTextureFromPixelBuffer. The gap: the code never confirmed the texture is actually one of this bind group’s bound resources, and it inspected only the argument’s own encoder count rather than the encoder counts of the external textures recorded in m_resources. The patch instead walks for (auto& resource : m_resources) for (auto& resourceUsage : resource.resourceUsages), pulls std::get_if<RefPtr<const ExternalTexture>>(&resourceUsage.resource), and returns false unless it finds the matching bound external texture AND that bound texture’s openCommandEncoderCount() is zero, additionally requiring foundBoundExternalTexture to be true.

Concrete types: m_resources holds BindGroupEntryUsage-style records whose resource is a std::variant that may contain RefPtr<const ExternalTexture>; openCommandEncoderCount() tracks how many command encoders currently reference the texture. Because the pre-patch path could update a texture whose bound instance still had open encoders (or update based on a non-bound argument), the backing could be swapped under an in-flight encoder. The diff proves the missing validation/ordering; whether the swapped-out backing is actually freed and then read by the GPU (a true UAF) is not shown in this hunk (inferred), consistent with the triage’s 0.6 confidence.

Key code

Hardened path requires the texture to be bound and no bound external texture in-use

bool foundBoundExternalTexture = false;
for (auto& resource : m_resources) {
    for (auto& resourceUsage : resource.resourceUsages) {
        auto* boundExternalTexture = std::get_if<RefPtr<const ExternalTexture>>(&resourceUsage.resource);
        if (!boundExternalTexture || !*boundExternalTexture)
            continue;
        if (boundExternalTexture->get() != &externalTexture || (*boundExternalTexture)->openCommandEncoderCount())
            return false;
        foundBoundExternalTexture = true;
    }
}
if (!foundBoundExternalTexture)
    return false;

Fix approach

The fix replaces the single argument-only guard with an exhaustive scan of the bind group’s bound resources: the update is allowed only if the passed external texture is actually found bound in m_resources and the bound instance has zero open command encoders. This both (a) rejects updates to textures not bound in this group and (b) ensures no in-flight encoder references the resource before its backing is recreated. Only after this does the code proceed to device->createExternalTextureFromPixelBuffer. Semantically the in-use check now applies to the bound resource view of the texture, not merely to the caller’s handle.

Patch walkthrough

  • Source/WebGPU/WebGPU/BindGroup.mm — updateExternalTextures gains an #else (shipping) branch that iterates m_resources/resourceUsages, extracts bound RefPtr entries, and returns false unless the argument texture is found bound with no open command encoders; the old branch only checked the argument’s own openCommandEncoderCount.

Background

WebGPU external texture — An ExternalTexture wraps an imported pixel buffer (e.g. from video); its device-side backing can be refreshed via createExternalTextureFromPixelBuffer when the source frame changes.

Bind group / m_resources — A GPUBindGroup records the concrete GPU resources bound at each slot; m_resources tracks those bindings and their per-encoder usages.

openCommandEncoderCount() — Per-resource counter of how many command encoders currently reference the resource; nonzero means an encoder may still read it, so its backing must not be mutated.

RC_REMOVE_SEC_ACCEL_3 gating — A build macro under which the hardened validation lives in the #else branch; the #ifdef branch keeps the old weaker check, so the security fix ships only in the #else configuration.

std::variant resourceUsage.resource — Each bound resource usage stores a variant over possible resource types; get_if<RefPtr> selects only external-texture bindings for the in-use test.

Vulnerability window

  1. Setup — Create a GPUExternalTexture from a pixel buffer and bind it into a bind group used by a render/compute pass.
  2. Trigger — Begin a command encoder that references the bind group so the bound external texture’s openCommandEncoderCount is nonzero (or arrange an argument not actually bound).
  3. Mutate — Invoke the external-texture update path so updateExternalTextures recreates the backing while the pre-patch check only inspected the argument’s own counter.
  4. Consume — The in-flight encoder commits and the GPU consumes the resource whose backing was swapped mid-flight (impact beyond inconsistency is inferred).

Proof of concept

No test ships in the diff. Conceptual trigger (web path inferred): via WebGPU (navigator.gpu), import an external texture, bind it in a bind group, open a command encoder that uses it, then drive the external-texture update (e.g. re-import/refresh of the pixel buffer) so updateExternalTextures recreates the backing while an encoder still holds the resource; the pre-patch guard permits it. Exact JS entrypoint to updateExternalTextures is not in the diff.

Exploitation

  1. Reach — WebGPU is reachable from web content; external textures and bind groups are ordinary GPU API objects usable from script, executed in the GPU process backend.
  2. Trigger — Hold an open command encoder referencing the bound external texture, then invoke the update so the backing is recreated under it.
  3. Primitive — Primary effect is mutation of a GPU resource that an in-flight encoder references — a state/lifetime violation; the recreated backing may dangle versus what the encoder recorded.
  4. Escalation (open) — Whether the old backing is freed and subsequently read/written by the committed encoder (a GPU-side UAF) and whether its contents are attacker-steerable gates escalation beyond corruption/crash (inferred).

Detection & hunting

For defenders and detection engineers:

  • updateExternalTextures invoked while a bound external texture has open encoders — Instrument the WebGPU backend to log external-texture updates against openCommandEncoderCount of all bound instances, not just the argument.
  • GPU-process crashes during WebGPU video/external-texture workloads — Fuzz external-texture update interleaved with encoder recording under GPU validation/ASan.

Remaining risk in the patch

  • Downstream UAF unverified in diff — createExternalTextureFromPixelBuffer and the actual free of the old backing are not in the supplied hunk, so the write/read-after-free consequence remains inferred rather than proven here.
  • Correctness of the bound-resource scan — The scan relies on m_resources/resourceUsages accurately reflecting every live binding; a path that uses an external texture without a corresponding m_resources entry would not be caught (unverified from this diff).

Audit directions

  • Other in-use checks keyed on a single handle — Audit WebGPU backend for resource-mutation paths gated only on the argument’s own openCommandEncoderCount rather than all bound instances in m_resources.
  • External/imported resource refresh — Review every createExternalTextureFromPixelBuffer / import-refresh path for in-flight-encoder guards.
  • RC_REMOVE_SEC_ACCEL_3-gated hunks — Cross-check other #ifdef RC_REMOVE_SEC_ACCEL_3 sites in WebGPU for weaker shipping-vs-hardened validation divergences.

Before / after

Loading diff…