CVE-2026-9943
Overview
Background
- Validating command decoder
- The GLES2 command decoder in
gpu/command_buffer/servicethat re-validates every GL command a renderer issues before forwarding it to the driver (ANGLE). It is relied upon as the bounds-safety layer on platforms where it is the default decoder (notably Android). - `VertexAttrib`
- Per-attribute state in
vertex_attrib_manager.cc. It records the bound buffer, elementsize_/type_, byteoffset_, andreal_stride_(the effective byte stride between consecutive vertices), and answersCanAccess(index)— whether vertexindexcan be fetched entirely from inside the bound buffer. - `real_stride_` vs group size
real_stride_is the byte distance between successive vertex elements; group size (GLES2Util::GetGroupSizeForBufferType(size_, type_)) is the number of bytes one element occupies (e.g.size=4, GL_FLOAT= 16 bytes). GLES permits a non-zero stride smaller than the group size, producing overlapping fetches.- `CanAccess`
- The guard a
drawArrays/drawElementspath consults to decide whether a draw’s maximum vertex index is in range. If it over-reports the count of accessible elements, the decoder admits a draw whose final vertex fetch reads past the buffer.
Root Cause Analysis
The violated invariant is: a vertex index i is accessible only if the attribute’s entire footprint fits inside the buffer, i.e. i * real_stride_ + group_size <= usable_size. In VertexAttrib::CanAccess (gpu/command_buffer/service/vertex_attrib_manager.cc) the old code computed num_elements = usable_size / real_stride_ + ((usable_size % real_stride_) >= group_size ? 1 : 0). This formula only checks the start offset of each element (i * real_stride_) against the buffer and uses the remainder term to grant one extra element when the trailing slack is at least one group. It silently assumes real_stride_ >= group_size: when the stride is smaller than the group size, usable_size % real_stride_ is always strictly less than real_stride_ and therefore always less than group_size, so the remainder term is always 0 and the formula collapses to floor(usable_size / real_stride_). That counts elements whose start fits but whose tail runs off the end of the buffer.
The patch rewrites the computation to validate the full element footprint. It first rejects outright when even one element cannot fit (if (usable_size < group_size) return false;), then computes num_elements = (usable_size - group_size) / real_stride_ + 1, which is the exact count of indices i satisfying i * real_stride_ + group_size <= usable_size. The accompanying regression tests (CanAccessStrideSmallerThanGroup, CanAccessRemainderAndEdgeCases) pin the overlapping-stride case (20-byte buffer, stride 4, group 16 accepts only indices 0 and 1) and the buffer-smaller-than-one-element case. The diff also upgrades DCHECK_GT(real_stride, 0) to CHECK_GT, hardening the real_stride_ > 0 precondition the new division relies on in release builds. The advisory’s class (OOB read) is fully substantiated by the diff.
Attack Path
- Web content
On a platform using the validating decoder (Android per the advisory; the gating file is
ui/gl/features.gni, not shown in the diff — (inferred)), script obtains a WebGL context via standard page APIs. - Configure overlapping attribute
Allocate a small
GL_ARRAY_BUFFER(e.g. 20 bytes) andvertexAttribPointeran attribute withsize=4, type=FLOAT(group size 16) andstride=4, producing overlapping element fetches legal under GLES. - Issue an over-counted draw
Call
drawArrayswith a vertexcountthat includes an index the buggyCanAccesswrongly accepts (index 2–4 in the 20-byte example).CanAccessreturns true, so the decoder forwards the draw instead of raisingGL_INVALID_OPERATION. - Driver reads OOB
The driver fetches the full 16-byte element starting at
index * 4, reading up to 12 bytes beyond the buffer from adjacent GPU memory. - Exfiltrate (inferred)
Route the fetched attribute through a shader to a render target and read it back via
glReadPixels, leaking adjacent GPU-memory bytes. The leak-to-pixels chain is plausible but not demonstrated by the diff.
Impact Assessment
group_size - 1 bytes past the buffer end (up to 12 bytes in the example). It is a read primitive, not memory corruption or RCE; the pixel-exfiltration step that turns the OOB fetch into an observable leak is inferred and not proven by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifgpu/command_buffer/service/vertex_attrib_manager.cc |
modified | |
TEST_Fgpu/command_buffer/service/vertex_attrib_manager_unittest.cc |
modified |
Files Changed
gpu/command_buffer/service/vertex_attrib_manager.ccgpu/command_buffer/service/vertex_attrib_manager_unittest.cc
Audit Directions
- Other stride/footprint mathSearch the command decoder and buffer managers for element-count or max-vertex computations that divide
usable_sizeby stride without subtracting the element size (e.g. index-buffer range checks,DoDrawElementsmax-index validation), where the same “start fits implies element fits” assumption may recur. - Overlapping-attribute assumptionsAudit any code that assumes
stride >= element_size; GLES explicitly permits smaller non-zero strides, and such assumptions elsewhere (transform feedback, instanced attribute divisors) may hide equivalent off-by-footprint reads. - Validation vs. driver robustnessConfirm whether the validating decoder requests robust buffer access from ANGLE; where it relies solely on its own bounds checks, any gap in those checks is directly exploitable since the driver will not re-validate.
- DCHECK-only guards on release pathsLook for other
DCHECK-guarded invariants feeding divisions or indexing in the GPU service that should beCHECKgiven renderer-controlled inputs.
Patch
From 4b1eea7e0aa97e1b69b8b0202548b6a0fa68defa Mon Sep 17 00:00:00 2001 From: Ken Russell <[email protected]> Date: Thu, 23 Apr 2026 16:27:55 -0700 Subject: [PATCH] Fix VertexAttribManager's stride computation. It was incorrect when the stride was less than the group size. Added unit test from the bug as well as one authored by jetski-cli specifically covering some corner cases. Fixed: 503464551 Change-Id: Id7a7ac39a823bc2cbb46c09922965ef4c57a3634 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7787505 Reviewed-by: Geoff Lang <[email protected]> Commit-Queue: Geoff Lang <[email protected]> Auto-Submit: Kenneth Russell <[email protected]> Cr-Commit-Position: refs/heads/main@{#1619827} --- diff --git a/gpu/command_buffer/service/vertex_attrib_manager.cc b/gpu/command_buffer/service/vertex_attrib_manager.cc index e1f4f50..8508bd5 100644 --- a/gpu/command_buffer/service/vertex_attrib_manager.cc +++ b/gpu/command_buffer/service/vertex_attrib_manager.cc @@ -53,7 +53,7 @@ GLsizei real_stride, GLsizei offset, GLboolean integer) { - DCHECK_GT(real_stride, 0); + CHECK_GT(real_stride, 0); buffer_ = buffer; size_ = size; type_ = type; @@ -77,9 +77,11 @@ } uint32_t usable_size = buffer_size - offset_; - GLuint num_elements = usable_size / real_stride_ + - ((usable_size % real_stride_) >= - (GLES2Util::GetGroupSizeForBufferType(size_, type_)) ? 1 : 0); + uint32_t group_size = GLES2Util::GetGroupSizeForBufferType(size_, type_); + if (usable_size < group_size) { + return false; + } + GLuint num_elements = (usable_size - group_size) / real_stride_ + 1; return index < num_elements; } diff --git a/gpu/command_buffer/service/vertex_attrib_manager_unittest.cc b/gpu/command_buffer/service/vertex_attrib_manager_unittest.cc index 10f362c9..4574ff1f 100644 --- a/gpu/command_buffer/service/vertex_attrib_manager_unittest.cc +++ b/gpu/command_buffer/service/vertex_attrib_manager_unittest.cc @@ -179,6 +179,107 @@ buffer_manager.Destroy(); } +TEST_F(VertexAttribManagerTest, CanAccessStrideSmallerThanGroup) { + // Regression test for bug: CanAccess() over-counts elements when + // real_stride < group_size, allowing the validating decoder to + // accept draw calls whose last vertex fetch reads past the end of + // the bound GL_ARRAY_BUFFER. + const GLenum kTarget = GL_ARRAY_BUFFER; + MockErrorState error_state; + BufferManager buffer_manager(nullptr, nullptr); + buffer_manager.CreateBuffer(1, 2); + Buffer* buffer = buffer_manager.GetBuffer(1); + ASSERT_TRUE(buffer != nullptr); + + VertexAttrib* attrib = manager_->GetVertexAttrib(1); + manager_->Enable(1, true); + + // size=4, type=GL_FLOAT -> group_size = 16 bytes per vertex fetch. + // gl_stride=4, real_stride=4 -> overlapping fetches (legal per GLES). + manager_->SetAttribInfo(1, buffer, 4, GL_FLOAT, GL_FALSE, 4, 4, 0, GL_FALSE); + + EXPECT_TRUE(buffer_manager.SetTarget(buffer, kTarget)); + // 20-byte buffer. + TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer, + kTarget, 20, GL_STATIC_DRAW, nullptr, GL_NO_ERROR); + + // Vertex i fetches bytes [i*4, i*4 + 16). With a 20-byte buffer: + // i=0 -> [0,16) in bounds + // i=1 -> [4,20) in bounds + // i=2 -> [8,24) 4 bytes OOB + // i=3 -> [12,28) 8 bytes OOB + // i=4 -> [16,32) 12 bytes OOB + EXPECT_TRUE(attrib->CanAccess(0)); + EXPECT_TRUE(attrib->CanAccess(1)); + // *** These three assertions FAIL on unpatched code: CanAccess() incorrectly + // returns true because num_elements is computed as 20/4 + (0>=16?1:0) = 5. + // *** + EXPECT_FALSE(attrib->CanAccess(2)); + EXPECT_FALSE(attrib->CanAccess(3)); + EXPECT_FALSE(attrib->CanAccess(4)); + // Index 5 is rejected even by the buggy formula. + EXPECT_FALSE(attrib->CanAccess(5)); + + // Edge case: buffer smaller than one element. group_size=16, buffer=12. + // No vertex can be fetched at all, but the buggy formula computes + // 12/4 + (0>=16?1:0) = 3. + TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer, + kTarget, 12, GL_STATIC_DRAW, nullptr, GL_NO_ERROR); + EXPECT_FALSE(attrib->CanAccess(0)); + EXPECT_FALSE(attrib->CanAccess(1)); + EXPECT_FALSE(attrib->CanAccess(2)); + EXPECT_FALSE(attrib->CanAccess(3)); + + manager_ = nullptr; + buffer_manager.MarkContextLost(); + buffer_manager.Destroy(); +} + +TEST_F(VertexAttribManagerTest, CanAccessRemainderAndEdgeCases) { + const GLenum kTarget = GL_ARRAY_BUFFER; + MockErrorState error_state; + BufferManager buffer_manager(nullptr, nullptr); + buffer_manager.CreateBuffer(1, 2); + Buffer* buffer = buffer_manager.GetBuffer(1); + ASSERT_TRUE(buffer != nullptr); + + VertexAttrib* attrib = manager_->GetVertexAttrib(1); + manager_->Enable(1, true); + + EXPECT_TRUE(buffer_manager.SetTarget(buffer, kTarget)); + + // Case 1: offset > buffer_size + manager_->SetAttribInfo(1, buffer, 4, GL_FLOAT, GL_FALSE, 16, 16, 20, + GL_FALSE); + TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer, + kTarget, 16, GL_STATIC_DRAW, nullptr, GL_NO_ERROR); + EXPECT_FALSE(attrib->CanAccess(0)); + + // Case 2: Remainder logic (usable_size % real_stride_ >= group_size) + // real_stride = 16, group_size = 8 (size=2, GL_FLOAT). + manager_->SetAttribInfo(1, buffer, 2, GL_FLOAT, GL_FALSE, 16, 16, 0, + GL_FALSE); + + // Subcase 2a: Remainder is enough. + // usable_size = 24. 24 % 16 = 8 >= 8. Should allow 2 elements. + TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer, + kTarget, 24, GL_STATIC_DRAW, nullptr, GL_NO_ERROR); + EXPECT_TRUE(attrib->CanAccess(0)); + EXPECT_TRUE(attrib->CanAccess(1)); + EXPECT_FALSE(attrib->CanAccess(2)); + + // Subcase 2b: Remainder is NOT enough. + // usable_size = 20. 20 % 16 = 4 < 8. Should allow 1 element. + TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer, + kTarget, 20, GL_STATIC_DRAW, nullptr, GL_NO_ERROR); + EXPECT_TRUE(attrib->CanAccess(0)); + EXPECT_FALSE(attrib->CanAccess(1)); + + manager_ = nullptr; + buffer_manager.MarkContextLost(); + buffer_manager.Destroy(); +} + TEST_F(VertexAttribManagerTest, Unbind) { BufferManager buffer_manager(nullptr, nullptr); buffer_manager.CreateBuffer(1, 2);
Regression Test / PoC
diff --git a/gpu/command_buffer/service/vertex_attrib_manager_unittest.cc b/gpu/command_buffer/service/vertex_attrib_manager_unittest.cc
index 10f362c9..4574ff1f 100644
--- a/gpu/command_buffer/service/vertex_attrib_manager_unittest.cc
+++ b/gpu/command_buffer/service/vertex_attrib_manager_unittest.cc
@@ -179,6 +179,107 @@
buffer_manager.Destroy();
}
+TEST_F(VertexAttribManagerTest, CanAccessStrideSmallerThanGroup) {
+ // Regression test for bug: CanAccess() over-counts elements when
+ // real_stride < group_size, allowing the validating decoder to
+ // accept draw calls whose last vertex fetch reads past the end of
+ // the bound GL_ARRAY_BUFFER.
+ const GLenum kTarget = GL_ARRAY_BUFFER;
+ MockErrorState error_state;
+ BufferManager buffer_manager(nullptr, nullptr);
+ buffer_manager.CreateBuffer(1, 2);
+ Buffer* buffer = buffer_manager.GetBuffer(1);
+ ASSERT_TRUE(buffer != nullptr);
+
+ VertexAttrib* attrib = manager_->GetVertexAttrib(1);
+ manager_->Enable(1, true);
+
+ // size=4, type=GL_FLOAT -> group_size = 16 bytes per vertex fetch.
+ // gl_stride=4, real_stride=4 -> overlapping fetches (legal per GLES).
+ manager_->SetAttribInfo(1, buffer, 4, GL_FLOAT, GL_FALSE, 4, 4, 0, GL_FALSE);
+
+ EXPECT_TRUE(buffer_manager.SetTarget(buffer, kTarget));
+ // 20-byte buffer.
+ TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer,
+ kTarget, 20, GL_STATIC_DRAW, nullptr, GL_NO_ERROR);
+
+ // Vertex i fetches bytes [i*4, i*4 + 16). With a 20-byte buffer:
+ // i=0 -> [0,16) in bounds
+ // i=1 -> [4,20) in bounds
+ // i=2 -> [8,24) 4 bytes OOB
+ // i=3 -> [12,28) 8 bytes OOB
+ // i=4 -> [16,32) 12 bytes OOB
+ EXPECT_TRUE(attrib->CanAccess(0));
+ EXPECT_TRUE(attrib->CanAccess(1));
+ // *** These three assertions FAIL on unpatched code: CanAccess() incorrectly
+ // returns true because num_elements is computed as 20/4 + (0>=16?1:0) = 5.
+ // ***
+ EXPECT_FALSE(attrib->CanAccess(2));
+ EXPECT_FALSE(attrib->CanAccess(3));
+ EXPECT_FALSE(attrib->CanAccess(4));
+ // Index 5 is rejected even by the buggy formula.
+ EXPECT_FALSE(attrib->CanAccess(5));
+
+ // Edge case: buffer smaller than one element. group_size=16, buffer=12.
+ // No vertex can be fetched at all, but the buggy formula computes
+ // 12/4 + (0>=16?1:0) = 3.
+ TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer,
+ kTarget, 12, GL_STATIC_DRAW, nullptr, GL_NO_ERROR);
+ EXPECT_FALSE(attrib->CanAccess(0));
+ EXPECT_FALSE(attrib->CanAccess(1));
+ EXPECT_FALSE(attrib->CanAccess(2));
+ EXPECT_FALSE(attrib->CanAccess(3));
+
+ manager_ = nullptr;
+ buffer_manager.MarkContextLost();
+ buffer_manager.Destroy();
+}
+
+TEST_F(VertexAttribManagerTest, CanAccessRemainderAndEdgeCases) {
+ const GLenum kTarget = GL_ARRAY_BUFFER;
+ MockErrorState error_state;
+ BufferManager buffer_manager(nullptr, nullptr);
+ buffer_manager.CreateBuffer(1, 2);
+ Buffer* buffer = buffer_manager.GetBuffer(1);
+ ASSERT_TRUE(buffer != nullptr);
+
+ VertexAttrib* attrib = manager_->GetVertexAttrib(1);
+ manager_->Enable(1, true);
+
+ EXPECT_TRUE(buffer_manager.SetTarget(buffer, kTarget));
+
+ // Case 1: offset > buffer_size
+ manager_->SetAttribInfo(1, buffer, 4, GL_FLOAT, GL_FALSE, 16, 16, 20,
+ GL_FALSE);
+ TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer,
+ kTarget, 16, GL_STATIC_DRAW, nullptr, GL_NO_ERROR);
+ EXPECT_FALSE(attrib->CanAccess(0));
+
+ // Case 2: Remainder logic (usable_size % real_stride_ >= group_size)
+ // real_stride = 16, group_size = 8 (size=2, GL_FLOAT).
+ manager_->SetAttribInfo(1, buffer, 2, GL_FLOAT, GL_FALSE, 16, 16, 0,
+ GL_FALSE);
+
+ // Subcase 2a: Remainder is enough.
+ // usable_size = 24. 24 % 16 = 8 >= 8. Should allow 2 elements.
+ TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer,
+ kTarget, 24, GL_STATIC_DRAW, nullptr, GL_NO_ERROR);
+ EXPECT_TRUE(attrib->CanAccess(0));
+ EXPECT_TRUE(attrib->CanAccess(1));
+ EXPECT_FALSE(attrib->CanAccess(2));
+
+ // Subcase 2b: Remainder is NOT enough.
+ // usable_size = 20. 20 % 16 = 4 < 8. Should allow 1 element.
+ TestHelper::DoBufferData(gl_.get(), &error_state, &buffer_manager, buffer,
+ kTarget, 20, GL_STATIC_DRAW, nullptr, GL_NO_ERROR);
+ EXPECT_TRUE(attrib->CanAccess(0));
+ EXPECT_FALSE(attrib->CanAccess(1));
+
+ manager_ = nullptr;
+ buffer_manager.MarkContextLost();
+ buffer_manager.Destroy();
+}
+
TEST_F(VertexAttribManagerTest, Unbind) {
BufferManager buffer_manager(nullptr, nullptr);
buffer_manager.CreateBuffer(1, 2);
Original Bug Report
Potential out-of-bounds read in validating command decoder due to flawed CanAccess logic
Project Fortify, 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: A logic error in VertexAttrib::CanAccess within the GLES2 validating command decoder allows WebGL on Android to over-count accessible vertex elements when attribute stride is smaller than attribute size. This can lead to out-of-bounds reads from GPU memory during draw calls. An attacker could potentially leak cross-origin GPU data on platforms where the GPU process is shared.
Affected files:
gpu/command_buffer/service/vertex_attrib_manager.ccgpu/command_buffer/service/gles2_cmd_decoder.cc
Estimated timestamp from git blame: 2016-02-26
Summary
A logic error in the GLES2 validating command decoder’s vertex attribute validation allows a renderer to potentially trigger out-of-bounds reads from GPU memory. When a vertex attribute is configured with overlapping elements (a stride smaller than its total component size), the VertexAttrib::CanAccess function over-counts the number of elements that can safely be accessed within the buffer. This allows a compromised or malicious renderer to perform out-of-bounds reads of adjacent GPU memory during draw calls, leading to a potential cross-origin information leak on platforms where the validating decoder is still active (primarily Android).
Technical Details
In gpu/command_buffer/service/vertex_attrib_manager.cc, the VertexAttrib::CanAccess function validates if a specific vertex index is within the bounds of a bound buffer. The calculation is:
uint32_t usable_size = buffer_size - offset_;
GLuint num_elements = usable_size / real_stride_ +
((usable_size % real_stride_) >= (GLES2Util::GetGroupSizeForBufferType(size_, type_)) ? 1 : 0);
return index < num_elements;
This formula incorrectly assumes that the stride (real_stride_) is always greater than or equal to the attribute’s element size (group_size).
The OpenGL ES specification allows a non-zero stride to be smaller than the element size, which creates overlapping attributes. When real_stride_ < group_size, the term usable_size % real_stride_ will mathematically always be strictly less than real_stride_, and consequently always less than group_size. This causes the second addition term to always evaluate to 0, simplifying the formula to floor(usable_size / real_stride_).
This is incorrect for overlapping attributes. The last valid index i must satisfy i * stride + group_size <= usable_size. The current logic validates an index i based purely on the start offset of the element (i * stride), without verifying that the entire footprint of that final element (i * stride + group_size) fits within the buffer.
Example Scenario:
Consider a buffer of 20 bytes, an attribute with a group size of 16 bytes (size=4, type=GL_FLOAT), and a stride of 4 bytes.
- Current logic:
num_elements = 20 / 4 + 0 = 5. It considers indices 0, 1, 2, 3, 4 to be valid. - Index 4 access: The driver will fetch 16 bytes starting at offset
4 * 4 = 16. The access range is[16, 32). - Result: Since the buffer size is only 20 bytes, this results in a 12-byte out-of-bounds read from GPU memory.
Furthermore, because the Validating Command Decoder does not explicitly request robust buffer access from the underlying ANGLE driver during initialization (relying instead on its own validation), ANGLE bypasses its internal bounds checks (ValidateDrawArraysAttribs). The driver executes the flawed draw call natively on the GPU hardware.
Impact
This issue is reachable via standard WebGL APIs from a renderer. The validating decoder is still compiled in and used by default on Android (ui/gl/features.gni:23). On Android, the GPU process is typically shared across all origins and is unsandboxed. An attacker could potentially exfiltrate the leaked bytes by passing the out-of-bounds attribute data through a vertex shader and observing the results via glReadPixels, leading to a High-severity cross-origin information leak (S1).
Potential Reproduction Steps
Please note: These are theoretical steps based on code analysis. We do not currently have a live proof-of-concept for this issue.
- Use an Android device where the validating command decoder is active.
- Create a WebGL context and a shader program that captures an attribute’s value (e.g., writing it to
gl_FragColoror transform feedback). - Bind a small buffer (e.g., 20 bytes).
- Configure a vertex attribute with overlapping elements (e.g.,
size=4, type=gl.FLOAT, stride=4). - Perform a draw call (
gl.drawArrays) with a count that includes the over-counted index (e.g.,count=5). - Read back the rendered pixels to observe data fetched from adjacent memory beyond the buffer’s boundaries.
Suggested Fix
The calculation for accessible elements in VertexAttrib::CanAccess should be updated to correctly handle overlapping attributes.
A robust calculation (similar to ANGLE’s VertexAttribute::updateCachedElementLimit) would be:
uint32_t usable_size = buffer_size - offset_;
uint32_t group_size = GLES2Util::GetGroupSizeForBufferType(size_, type_);
if (usable_size < group_size) {
// Even the first element doesn't fit.
return false;
}
// Calculate remaining space after the first full element is accounted for
uint32_t remaining_space = usable_size - group_size;
GLuint num_elements = (remaining_space / real_stride_) + 1;
return index < num_elements;
Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09
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.