c773351fb1c98472f62131f367a131670a736699 WebXR layer: Fail getViewSubImage if layer isn't active
Triage note: WebXR WebGL-binding access-control fix preventing use of a layer outside the active render state (possible stale/invalid shared image). Gated behind WebXR immersive session; not default-reachable. watch.
The bug at a glance
The pre-patch guard on XRWebGLBinding::getViewSubImage / getSubImage was HasSharedImage(), a check about whether a GPU shared-image buffer is currently allocated for the layer, not whether the layer belongs to the session’s current active render state. That is the wrong invariant: a layer can hold (or transiently hold) a shared image while not being part of the active render state, and, symmetrically, a legitimately active layer may not always require one. Passing such a layer let script drive the sub-image machinery for a layer the session is not actually presenting this frame, i.e. operating on state that the surrounding code assumes is live. This is a genuine invalid-state defect rather than pure cleanup, which is why it carries an isSecurity=true tag. It is scored medium (WebXR is gated behind an immersive session and explicit user consent / device presence, no memory corruption is demonstrated, and exploitable is marked false) but it is not noise: the code path immediately downstream performs a static_cast to a WebGL drawing context and consumes per-layer buffer/viewport data, so reasoning about a non-active layer here is exactly the kind of latent-safety hazard that belongs in the watch tier. No PoC, no crash, and no UAF is shown, so it is not rated high.
Both getViewSubImage (projection layers) and getSubImage (non-projection layers) accept a caller-supplied XRLayer. The security question is: what does the method verify about that layer before it starts treating it as a live, session-owned surface? The angle is that HasSharedImage() answered a resource-lifetime question (‘is a buffer allocated?’) and was being (mis)used as an ownership/liveness question (‘is this layer in my active render state?’). Those two properties are not equivalent, so an attacker-controlled JS call could slip a layer that is not in renderState()->layers()/baseLayer past the guard whenever the buffer-allocation state happened to line up, then reach the drawing-context static_cast and sub-image construction with a layer the frame loop never scheduled.
Root cause
In xr_webgl_binding.cc, getViewSubImage originally rejected only when !layer->HasSharedImage(), throwing InvalidStateError with ‘There is no shared buffer for layer.’ The comment above it even admits the method ‘could be called for the layer which is not in an active render state’ — acknowledging the exact gap. The patch replaces that with an explicit liveness check: if (!session()->renderState()->HasLayer(layer)) throw TypeError(‘Layer is not included in the active render state.’). getSubImage receives the same HasLayer guard inserted early (right after the initial null/error checks and before the kProjectionLayer type check), and its later HasSharedImage() block is deleted entirely. HasLayer is a new XRRenderState method: it returns false for null, true when layer==base_layer_, and otherwise base::Contains(*layers_, layer). So the corrected invariant is membership in the session’s committed render state, checked against the authoritative container, instead of an inferred buffer-presence heuristic. The exception type also changes from DOMException(InvalidStateError) to TypeError, matching the WebXR spec’s treatment of a layer that is not part of the active configuration as a bad-argument condition rather than a transient frame-state error.
Key code
getViewSubImage: buffer-presence heuristic replaced by authoritative render-state membership check
- // Method could be called for the layer which is not in an active render
- // state.
- if (!layer->HasSharedImage()) {
- exception_state.ThrowDOMException(
- DOMExceptionCode::kInvalidStateError,
- "Invalid frame state. There is no shared buffer for layer.");
+ if (!session()->renderState()->HasLayer(layer)) {
+ exception_state.ThrowTypeError(
+ "Layer is not included in the active render state.");
return nullptr;
}
+bool XRRenderState::HasLayer(XRLayer* layer) const {
+ if (!layer) { return false; }
+ if (layer == base_layer_) { return true; }
+ if (layers_) { return base::Contains(*layers_, layer); }
+ return false;
+}
Patch walkthrough
third_party/blink/renderer/modules/xr/xr_render_state.cc— Adds XRRenderState::HasLayer(XRLayer*). It is the new authoritative predicate for ‘is this layer part of the currently committed render state.’ It short-circuits null, treats the WebGL/base layer path (base_layer_) as membership, and for the layers API checks base::Contains over the layers_ vector. This centralizes the check that both binding methods now share, so callers no longer reason about buffer allocation as a proxy for liveness.third_party/blink/renderer/modules/xr/xr_render_state.h— Declares the new HasLayer(XRLayer*) const alongside the existing HasActiveLayer(). Note HasActiveLayer already existed but answers a different question (‘does any layer/base layer exist?’), which is why a per-layer membership predicate was needed rather than reusing it.third_party/blink/renderer/modules/xr/xr_webgl_binding.cc— getViewSubImage: the HasSharedImage() gate and its stale explanatory comment are removed and replaced with the HasLayer guard throwing TypeError. getSubImage: the HasLayer guard is inserted early (before the projection-layer type check and the OwnsLayer/static_cast path), and the trailing HasSharedImage() block is deleted. Result: neither method proceeds to derive a drawing context or build a sub-image for a layer that is not in the active render state, and HasSharedImage() is no longer overloaded to mean liveness.
Background
XRWebGLBinding.getViewSubImage / getSubImage — WebXR Layers API methods that, given a layer (and for getViewSubImage a view), return the WebGLSubImage the app must render into for this frame — including the color/depth texture, viewport, and image index. They are called inside the requestAnimationFrame loop and hand back live GPU-backed surfaces.
Active render state — An XRSession commits an XRRenderState (via updateRenderState) whose baseLayer or layers array defines what is actually presented each frame. Only layers in that committed state are scheduled and have their buffers managed by the frame loop.
HasSharedImage() — A per-XRLayer predicate reporting whether a GpuShared image/buffer is currently allocated for the layer. Allocation lifetime is driven by the frame loop and is not a reliable stand-in for ’the layer is in the active render state’ — the crux of this bug.
InvalidStateError vs TypeError — The patch also aligns exception semantics: a layer that is simply not part of the active configuration is an argument/precondition problem (TypeError) per the Layers spec, not a transient frame-state error (InvalidStateError).
Vulnerability window
- introduction — WebXR Layers binding shipped with the HasSharedImage() guard and a comment explicitly noting the method ‘could be called for the layer which is not in an active render state’ — the gap was known/acknowledged in-code.
- report — Tracked as crbug 470685252. Metadata classes it invalid-state-access, medium band, exploitable=false, isSecurity=true.
- fix — Yong Li landed c773351fb1 on 2026-01-16 (main@{#1570515}), reviewed by Alexander Cooper and Brandon Jones, adding HasLayer and rerouting both binding methods to it.
Proof of concept
This commit ships no test or crashtest, and no exploit is demonstrated (exploitable=false). Honest recipe to reach the corrected path: enter an immersive-vr/ar session, create an XRWebGLBinding, create an XRWebGLLayer or projection/quad layer but do NOT include it in the committed render state (or remove it via a subsequent updateRenderState) while its shared image is or was allocated, then call getViewSubImage/getSubImage with that layer inside rAF. Pre-patch this could pass the HasSharedImage() gate and drive the drawing-context static_cast and sub-image construction against a non-active layer; post-patch it deterministically throws TypeError. Whether the pre-patch path yielded a concrete memory-safety fault is NOT established by this diff — no exploit code should be inferred or invented.
Exploitation
- prerequisite — Requires an active immersive WebXR session, which needs a compatible XR device (or emulator) and user consent — a meaningful barrier that bounds real-world reach and supports the medium (not high) rating.
- trigger — Script supplies a layer absent from renderState during a getSubImage/getViewSubImage call, exploiting the buffer-presence heuristic to pass the guard.
- impact-ceiling — Downstream the method static_casts to a WebGL drawing context and consumes per-layer buffer/viewport data. Operating on a non-active layer is undefined w.r.t. the frame loop’s buffer management; the honest ceiling from this diff is an invalid-state access / logic confusion, with no demonstrated corruption or UAF.
Detection & hunting
For defenders and detection engineers:
- getSubImage/getViewSubImage on non-active layers — Post-patch, such calls throw TypeError(‘Layer is not included in the active render state.’). A spike in that TypeError, or fuzzers that stopped hitting the old InvalidStateError, indicates code (or attackers) exercising the path.
- HasSharedImage() used as a liveness proxy — Grep the XR module and any GPU/canvas binding for HasSharedImage() (or analogous allocation predicates) used to gate operations that require render-state membership; those are the same anti-pattern this fix removed.
Audit directions
- third_party/blink/renderer/modules/xr — Audit every other XRWebGLBinding / XRWebGPUBinding / XRLayer entry point that accepts a caller-supplied layer (e.g. other sub-image, texture, or viewport accessors) and confirm each validates renderState()->HasLayer(layer) rather than buffer allocation or type alone, before any static_cast or buffer access.
- cross-session and cross-binding layer handling — Verify a layer created for one session/binding cannot be passed to another’s getSubImage; HasLayer scopes to session()->renderState(), so confirm session() is the correct owning session in all callers and that removed layers cannot be replayed while a stale shared image lingers.