CVE-2026-9880
Overview
Background
- WebGL element array buffer
- GPU buffer holding vertex indices that a draw call dereferences, so its contents must stay within the bounds of the bound vertex arrays.
- Client/renderer-shared memory
- the
datapointer passed toBufferGL::setData/setSubDatapoints into memory the untrusted renderer can still write, so its bytes can change out from under the GPU process. - Double-read / TOCTOU
- a time-of-check-to-time-of-use flaw where the same untrusted source is read twice and can be mutated between reads, so the checked value and the used value differ.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: Double-read fix matches diff, but winning the race requires a compromised renderer (as the analysis itself says), so 'default' preconditions overstate. Fix is limited to the GL backend (BufferGL). GPU-process memory corruption is asserted, not shown by the diff.
BufferGL::setData and BufferGL::setSubData each read the client-supplied data buffer twice from independent code paths. The original setData first called functions->bufferData(target, size, data, usage), which makes the driver read data to populate the GPU buffer, and then separately performed a memcpy of data into mShadowCopy for later index-range validation. Because data points into renderer-shared memory, a concurrent renderer thread can rewrite those bytes in the window between the driver’s read and ANGLE’s memcpy.
The result is that the driver’s GPU copy of the index data and ANGLE’s mShadowCopy are populated from different byte values, so the shadow copy no longer reflects what the GPU will actually draw with. Since draw-call bounds checking validates index ranges against mShadowCopy, an attacker can arrange for the shadow to pass validation while the GPU draws with larger, out-of-bounds indices.
The fix reads the untrusted data exactly once into the shadow copy and then uploads to the driver from that shadow copy, guaranteeing the validated bytes and the drawn bytes are identical.
Attack Path
- Prime a shadowed index buffer
The renderer creates a WebGL element array buffer that ANGLE mirrors into
mShadowCopyand issuesbufferData/bufferSubDatawithdatain shared memory. - Race the two reads
A second renderer-controlled thread rewrites the bytes of
datain the interval between the driver’sbufferDataread and ANGLE’smemcpyintomShadowCopy. - Diverge validation from reality
The
mShadowCopyends up holding small in-bounds indices while the driver’s GPU buffer holds large out-of-bounds indices (or vice versa). - Issue a draw call
Index-range validation checks the benign
mShadowCopyand passes, but the GPU dereferences vertex arrays using the malicious out-of-bounds indices. - Trigger OOB access The draw reads vertex data outside the bound arrays, yielding out-of-bounds memory access on the GPU/driver side driven entirely by untrusted input.
Impact Assessment
Files Changed
src/libANGLE/renderer/gl/BufferGL.cpp
Audit Directions
- Double-read of shared memoryFlag any code that passes a client/renderer-shared pointer to both a driver upload and a separate
memcpy/validation read; require a single read into a trusted buffer used by both. - Validate-then-use divergenceWherever a shadow or cache backs security validation, confirm the validated bytes are the exact bytes handed to the lower layer, not a re-read of mutable input.
- Shared-memory TOCTOU in GPU commandsAudit other
BufferGL/*GLupload paths and anymShadowCopyconsumers for reads of untrusted memory that can be mutated concurrently between check and use.
Patch
From 557d67e3f66d99c231aeffcb5bfed3e85c5ecda6 Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Wed, 22 Apr 2026 17:20:26 -0700
Subject: [PATCH] Read element array data exactly once for WebGL.
If the buffer's shadow copy is present, copy the client data to it, and
upload from the shadow copy, to avoid potential double-read races.
Co-authored with jetski-cli and geofflang@.
Bug: chromium:503615025
Change-Id: I7c9d77f04a0d0677143b10449df6dc528a8e5ba5
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7788210
Auto-Submit: Kenneth Russell <kbr@chromium.org>
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Kenneth Russell <kbr@chromium.org>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/src/libANGLE/renderer/gl/BufferGL.cpp b/src/libANGLE/renderer/gl/BufferGL.cpp
index 53760da..28f2da5 100644
--- a/src/libANGLE/renderer/gl/BufferGL.cpp
+++ b/src/libANGLE/renderer/gl/BufferGL.cpp
@@ -95,10 +95,6 @@
const FunctionsGL *functions = GetFunctionsGL(context);
StateManagerGL *stateManager = GetStateManagerGL(context);
- stateManager->bindBuffer(DestBufferOperationTarget, mBufferID);
- ANGLE_GL_TRY(context, functions->bufferData(gl::ToGLenum(DestBufferOperationTarget), size, data,
- ToGLenum(usage)));
-
// Initialize the shadow buffer if needed. Don't delete existing shadow data. WebGL allows users
// to bind as an element array buffer first and then copy source/dest later (but not the other
// way around).
@@ -107,6 +103,10 @@
mShadowCopy = angle::MemoryBuffer();
}
+ stateManager->bindBuffer(DestBufferOperationTarget, mBufferID);
+
+ const void *uploadData = data;
+
if (mShadowCopy.has_value())
{
ANGLE_CHECK_GL_ALLOC(contextGL, mShadowCopy->resize(size));
@@ -114,9 +114,13 @@
if (size > 0 && data != nullptr)
{
memcpy(mShadowCopy->data(), data, size);
+ uploadData = mShadowCopy->data();
}
}
+ ANGLE_GL_TRY(context, functions->bufferData(gl::ToGLenum(DestBufferOperationTarget), size,
+ uploadData, ToGLenum(usage)));
+
mBufferSize = size;
contextGL->markWorkSubmitted();
@@ -136,14 +140,21 @@
StateManagerGL *stateManager = GetStateManagerGL(context);
stateManager->bindBuffer(DestBufferOperationTarget, mBufferID);
- ANGLE_GL_TRY(context, functions->bufferSubData(gl::ToGLenum(DestBufferOperationTarget), offset,
- size, data));
- if (mShadowCopy.has_value() && size > 0)
+ const void *uploadData = data;
+
+ if (mShadowCopy.has_value())
{
- memcpy(mShadowCopy->data() + offset, data, size);
+ if (size > 0 && data != nullptr)
+ {
+ memcpy(mShadowCopy->data() + offset, data, size);
+ uploadData = mShadowCopy->data() + offset;
+ }
}
+ ANGLE_GL_TRY(context, functions->bufferSubData(gl::ToGLenum(DestBufferOperationTarget), offset,
+ size, uploadData));
+
contextGL->markWorkSubmitted();
return angle::Result::Continue;
Original Bug Report
Potential double-fetch in GPU command buffer allows WebGL index bounds validation bypass
Flapjack, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: The GPU process’s validating command buffer contains a double-fetch vulnerability when processing buffer updates like glBufferSubData. It reads from client-controlled shared memory twice: once to update a CPU-side shadow buffer used for validation, and again to send data to the GL driver. A compromised renderer can concurrently modify this shared memory to bypass index bounds validation, potentially causing an out-of-bounds access in the GPU hardware.
Affected files:
gpu/command_buffer/service/buffer_manager.ccgpu/command_buffer/service/gles2_cmd_decoder.cc
Estimated timestamp from git blame: 2025-11-28
Vulnerability Details
To prevent malicious WebGL content from causing out-of-bounds (OOB) memory accesses in the GPU driver, the validating command buffer maintains a CPU-side “shadow” copy of GL_ELEMENT_ARRAY_BUFFER (index buffer) data. During a glDrawElements call, the service scans this shadow buffer to find the maximum index accessed, ensuring it fits within the bounds of the currently bound vertex attributes.
However, several buffer update functions exhibit a time-of-check to time-of-use (TOCTOU) / double-fetch vulnerability when reading from shared memory. For example, in BufferManager::DoBufferSubData:
void BufferManager::DoBufferSubData(
Buffer* buffer, GLenum target, GLintptr offset, GLsizeiptr size,
const GLvoid* data) {
// FETCH 1: Copies data from shared memory `data` to the CPU shadow buffer
buffer->SetRange(offset, size, data);
if (!buffer->IsClientSideArray()) {
// FETCH 2: Driver reads directly from the shared memory `data` to GPU memory
glBufferSubData(target, offset, size, data);
}
}
The data pointer originates from a transfer buffer (base::UnsafeSharedMemoryRegion) mapped by both the renderer and the GPU process. Because there are no cross-process locks, the renderer can modify the memory between SetRange and glBufferSubData. Similar vulnerable patterns exist in GLES2DecoderImpl::DoFlushMappedBufferRange and GLES2DecoderImpl::UnmapBufferHelper.
Potential Exploitation Steps
(Note: These are suggested/potential steps, as our tooling agent does not have the ability to run code or verify a live Proof of Concept.)
- Renderer Compromise: An attacker gains code execution in a sandboxed renderer process.
- Setup: The attacker initializes a WebGL context, creating a small vertex buffer and an element array buffer.
- Race Preparation: The attacker maps a shared memory transfer buffer and issues a
BufferSubDatacommand pointing to it. - Concurrent Modification: A worker thread in the renderer rapidly alternates the contents of the shared memory between “safe” indices (within vertex buffer bounds) and “malicious” indices (large, out-of-bounds values).
- Winning the Race: If the GPU process executes
buffer->SetRangewhile the memory contains safe indices, the shadow buffer is populated safely. Microseconds later, the renderer thread overwrites the shared memory with malicious indices. The GPU process then executesglBufferSubData, uploading the malicious indices to GPU VRAM. - Triggering the OOB: The attacker issues a
glDrawElementscommand. The GPU service validates the draw call using the safe indices in the shadow buffer, allowing the call to proceed. The underlying GPU hardware executes the draw using the malicious VRAM buffer, resulting in an OOB memory access.
Because the validating command decoder is the default on Android, and the GPU process is unsandboxed on that platform, an OOB access in the GPU driver can potentially lead to a full sandbox escape.
Suggested Fix
Do not pass the raw shared memory pointer to the underlying GL driver if the buffer is shadowed. Instead, copy the data once into service-side memory (which the SetRange call already does by populating shadow_), and pass the safe, service-side pointer to the GL driver.
For example, in BufferManager::DoBufferSubData:
buffer->SetRange(offset, size, data);
if (!buffer->IsClientSideArray()) {
// Pass the pointer from the shadow buffer, not the shared memory
glBufferSubData(target, offset, size, buffer->GetRange(offset, size));
}
Similar refactoring should be applied to DoFlushMappedBufferRange and UnmapBufferHelper to ensure that data written to the GPU strictly matches the data validated in the shadow buffer.
Evaluated with Chrome root at commit: c0eb5541aebfa4ea08806eaf6e94bcc69f87ab2f
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.