CVE-2026-7354
Overview
Background
- ANGLE
- Chrome’s GL-on-Vulkan translation layer;
VertexArrayVkis its Vulkan-backend vertex-array object implementation. - 8-bit index emulation
- When the Vulkan device lacks
Feature::SupportsIndexTypeUint8, ANGLE convertsGL_UNSIGNED_BYTEelement-array data into a wider index type on the GPU viaUtilsVk::convertIndexBuffer. - `ConvertIndexParameters`
- The struct passed to the GPU conversion, carrying
srcOffset(where in the source buffer to start) andmaxIndex(how many source elements/how much source data the conversion kernel may touch). - `srcDataSize`
- A value computed earlier in the function as the remaining bytes in the source buffer from
offsetIntoSrcDataonward.
Root Cause Analysis
In the index-buffer conversion path (the enclosing function name is not shown in the @@ -529 hunk header; it is the GPU 8-bit-index conversion routine in VertexArrayVk.cpp), params.maxIndex was set to bufferVk->getSize(), the size of the entire element-array buffer, even though conversion begins at a non-zero srcOffset (offsetIntoSrcData). When a draw call uses an offset near the end of the buffer, the conversion bound therefore exceeded the actual remaining data: offset + maxIndex ran past the end of the source allocation. The violated invariant is that the conversion’s maximum reach must be the space remaining after the offset, not the full buffer size. The regression test ConvertUint8IndexAtEndOfBuffer encodes exactly this: a 65536-byte index buffer drawn with kOffset = 65533 and kCount = 3 at the very end.
The fix sets params.maxIndex = static_cast<uint32_t>(srcDataSize) — the already-computed remaining size — so the conversion kernel can no longer read or write beyond the buffer when the offset is non-zero.
srcDataSize instead of bufferVk->getSize().Attack Path
- Force the emulation path
Web content issues WebGL draws on a configuration where
GL_UNSIGNED_BYTEindices are not natively supported, routing index data through ANGLE’s GPU 8-bit-index conversion. - Draw near the buffer end
Issue
glDrawElements(..., GL_UNSIGNED_BYTE, offset)with an offset close to the end of a large element-array buffer (mirroring the test’skOffset = 65533). - Overshoot the bound
The conversion is launched with
maxIndexequal to the full buffer size instead of the remainingsrcDataSize, so its computed range extends past the end of the source buffer region. - Out-of-bounds read/write The GPU conversion accesses memory beyond the index buffer’s remaining space, the out-of-bounds read and write reported by the advisory.
Impact Assessment
SupportsIndexTypeUint8 and a draw call whose offset lands near the end of the element-array buffer. The advisory does not report in-the-wild exploitation, and escalation of the OOB to full memory corruption or sandbox compromise is (inferred) beyond what the patch demonstrates.Changed Functions
| Function | Change | Notes |
|---|---|---|
VertexAttributeUint8Testsrc/tests/gl_tests/VertexAttributeTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/vulkan/VertexArrayVk.cppsrc/tests/gl_tests/VertexAttributeTest.cpp
Audit Directions
- Offset vs. size confusionAudit buffer-processing code that accepts a non-zero offset but passes the whole-object size (not
size - offset) as a length/limit to a copy, conversion, or GPU kernel. - GPU-side emulation/conversion boundsReview other ANGLE format/index emulation paths (
UtilsVkconversions, index-range computation) for bounds derived from allocation size rather than the remaining accessible region. - End-of-buffer edge casesAdd tests exercising draws/reads at the final elements of a buffer with large offsets, where off-by-remaining-size errors surface that mid-buffer accesses hide.
Patch
From 0d9ddc506ea7593138ef8e80640f7a70a01a9cad Mon Sep 17 00:00:00 2001
From: Ken Russell <kbr@chromium.org>
Date: Fri, 17 Apr 2026 16:56:55 -0700
Subject: [PATCH] Vulkan: fix maximum index when converting index buffers on GPU.
Correct computation of the remaining size in the buffer when the
offset is non-zero.
Verified the new test case catches the bug via the manual code changes
in the bug report.
Fixed: chromium:498746519
Change-Id: I8ee9f2fe2830a3d30e4d19863fbc8a97e1518322
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7774827
Commit-Queue: Kenneth Russell <kbr@chromium.org>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Auto-Submit: Kenneth Russell <kbr@chromium.org>
Reviewed-by: Charlie Lao <cclao@google.com>
---
diff --git a/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp b/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
index f36d02c..620822e 100644
--- a/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
+++ b/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
@@ -529,7 +529,8 @@
UtilsVk::ConvertIndexParameters params = {};
params.srcOffset = static_cast<uint32_t>(offsetIntoSrcData);
params.dstOffset = 0;
- params.maxIndex = static_cast<uint32_t>(bufferVk->getSize());
+ // Remaining space in buffer was already computed above.
+ params.maxIndex = static_cast<uint32_t>(srcDataSize);
ANGLE_TRY(contextVk->getUtils().convertIndexBuffer(contextVk, dst, src, params));
mTranslatedByteIndexData.clearDirty();
diff --git a/src/tests/gl_tests/VertexAttributeTest.cpp b/src/tests/gl_tests/VertexAttributeTest.cpp
index 098713a..ebaaf85 100644
--- a/src/tests/gl_tests/VertexAttributeTest.cpp
+++ b/src/tests/gl_tests/VertexAttributeTest.cpp
@@ -8,6 +8,7 @@
# pragma allow_unsafe_buffers
#endif
+#include <cmath>
#include "anglebase/numerics/safe_conversions.h"
#include "common/mathutil.h"
#include "test_utils/ANGLETest.h"
@@ -5857,6 +5858,111 @@
EXPECT_GL_NO_ERROR();
}
+class VertexAttributeUint8Test : public VertexAttributeTestES3
+{};
+
+// Regression test for a bug in emulation of 8-bit indices, when the end of
+// the index buffer is used.
+TEST_P(VertexAttributeUint8Test, ConvertUint8IndexAtEndOfBuffer)
+{
+ ANGLE_GL_PROGRAM(prog, essl3_shaders::vs::Simple(), essl3_shaders::fs::Red());
+ ANGLE_GL_PROGRAM(prog2, essl3_shaders::vs::Simple(), essl3_shaders::fs::Blue());
+ glUseProgram(prog);
+
+ GLVertexArray vao;
+ glBindVertexArray(vao);
+
+ // Vertex buffer: 256 vertices so any uint8 index value is valid for
+ // robust-access vertex fetch (avoids unrelated OOB on the draw side).
+ GLBuffer vbo;
+ glBindBuffer(GL_ARRAY_BUFFER, vbo);
+ std::vector<float> verts(256 * 2);
+ for (int i = 0; i < 256; i++)
+ {
+ float x, y;
+ // Vertices 0, 1, 2: cover the right half of the framebuffer
+ // Vertices 3, 4, 5: cover the left half of the framebuffer
+ switch (i % 6)
+ {
+ case 0:
+ x = 0;
+ y = -2;
+ break;
+ case 1:
+ x = 2;
+ y = 0;
+ break;
+ case 2:
+ x = 0;
+ y = 2;
+ break;
+ case 3:
+ x = 0;
+ y = -2;
+ break;
+ case 4:
+ x = 0;
+ y = 2;
+ break;
+ case 5:
+ x = -2;
+ y = 0;
+ break;
+ }
+ verts[i * 2] = x;
+ verts[i * 2 + 1] = y;
+ }
+ glBufferData(GL_ARRAY_BUFFER, verts.size() * sizeof(float), verts.data(), GL_STATIC_DRAW);
+
+ GLint aLoc = glGetAttribLocation(prog, "a_position");
+ glEnableVertexAttribArray(aLoc);
+ glVertexAttribPointer(aLoc, 2, GL_FLOAT, GL_FALSE, 0, 0);
+
+ // Create a large index buffer, contents of which don't really matter.
+ // Filled with low values so any vertex fetch is in-range.
+ const size_t kEboSize = 65536;
+ GLBuffer ebo;
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
+ std::vector<uint8_t> indices(kEboSize);
+ for (size_t i = 0; i < kEboSize - 3; i++)
+ {
+ indices[i] = i % 3;
+ }
+ // Set indices 65533, 65534, 65535 to 3, 4, 5 to cover the left
+ // half of the framebuffer.
+ indices[kEboSize - 3] = 3;
+ indices[kEboSize - 2] = 4;
+ indices[kEboSize - 1] = 5;
+ glBufferData(GL_ELEMENT_ARRAY_BUFFER, kEboSize, indices.data(), GL_STATIC_DRAW);
+
+ // Draw red to the right half of the framebuffer. This draw call ensures that the
+ // index buffer is in use by the GPU, so the emulation, if any, would prefer the GPU path.
+ glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_BYTE, 0);
+
+ // Draw blue to the left half of the framebuffer. The draw call uses an offset to the
+ // end of the index buffer. This is a regression test for a bug where the 8-bit index
+ // emulation path miscalculated the range to emulate.
+ const size_t kOffset = 65533;
+ const size_t kCount = 3;
+ glUseProgram(prog2);
+ GLint aLoc2 = glGetAttribLocation(prog, "a_position");
+ glEnableVertexAttribArray(aLoc2);
+ glVertexAttribPointer(aLoc2, 2, GL_FLOAT, GL_FALSE, 0, 0);
+ glDrawElements(GL_TRIANGLES, kCount, GL_UNSIGNED_BYTE, reinterpret_cast<void *>(kOffset));
+
+ const int w = getWindowWidth();
+ const int h = getWindowHeight();
+
+ EXPECT_PIXEL_RECT_EQ(0, 0, w / 2 - 1, h, GLColor::blue);
+ EXPECT_PIXEL_RECT_EQ(w / 2 + 1, 0, w / 2 - 2, h, GLColor::red);
+ ASSERT_GL_NO_ERROR();
+}
+
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(VertexAttributeUint8Test);
+ANGLE_INSTANTIATE_TEST_ES3_AND(VertexAttributeUint8Test,
+ ES3_VULKAN().disable(Feature::SupportsIndexTypeUint8),
+ ES3_VULKAN_SWIFTSHADER().disable(Feature::SupportsIndexTypeUint8));
+
ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND(
VertexAttributeShiftInstancedArrayDataWithOffsetTest,
ES2_OPENGL().enable(Feature::ShiftInstancedArrayDataWithOffset),
Regression Test / PoC
diff --git a/src/tests/gl_tests/VertexAttributeTest.cpp b/src/tests/gl_tests/VertexAttributeTest.cpp
index 098713a..ebaaf85 100644
--- a/src/tests/gl_tests/VertexAttributeTest.cpp
+++ b/src/tests/gl_tests/VertexAttributeTest.cpp
@@ -8,6 +8,7 @@
# pragma allow_unsafe_buffers
#endif
+#include <cmath>
#include "anglebase/numerics/safe_conversions.h"
#include "common/mathutil.h"
#include "test_utils/ANGLETest.h"
@@ -5857,6 +5858,111 @@
EXPECT_GL_NO_ERROR();
}
+class VertexAttributeUint8Test : public VertexAttributeTestES3
+{};
+
+// Regression test for a bug in emulation of 8-bit indices, when the end of
+// the index buffer is used.
+TEST_P(VertexAttributeUint8Test, ConvertUint8IndexAtEndOfBuffer)
+{
+ ANGLE_GL_PROGRAM(prog, essl3_shaders::vs::Simple(), essl3_shaders::fs::Red());
+ ANGLE_GL_PROGRAM(prog2, essl3_shaders::vs::Simple(), essl3_shaders::fs::Blue());
+ glUseProgram(prog);
+
+ GLVertexArray vao;
+ glBindVertexArray(vao);
+
+ // Vertex buffer: 256 vertices so any uint8 index value is valid for
+ // robust-access vertex fetch (avoids unrelated OOB on the draw side).
+ GLBuffer vbo;
+ glBindBuffer(GL_ARRAY_BUFFER, vbo);
+ std::vector<float> verts(256 * 2);
+ for (int i = 0; i < 256; i++)
+ {
+ float x, y;
+ // Vertices 0, 1, 2: cover the right half of the framebuffer
+ // Vertices 3, 4, 5: cover the left half of the framebuffer
+ switch (i % 6)
+ {
+ case 0:
+ x = 0;
+ y = -2;
+ break;
+ case 1:
+ x = 2;
+ y = 0;
+ break;
+ case 2:
+ x = 0;
+ y = 2;
+ break;
+ case 3:
+ x = 0;
+ y = -2;
+ break;
+ case 4:
+ x = 0;
+ y = 2;
+ break;
+ case 5:
+ x = -2;
+ y = 0;
+ break;
+ }
+ verts[i * 2] = x;
+ verts[i * 2 + 1] = y;
+ }
+ glBufferData(GL_ARRAY_BUFFER, verts.size() * sizeof(float), verts.data(), GL_STATIC_DRAW);
+
+ GLint aLoc = glGetAttribLocation(prog, "a_position");
+ glEnableVertexAttribArray(aLoc);
+ glVertexAttribPointer(aLoc, 2, GL_FLOAT, GL_FALSE, 0, 0);
+
+ // Create a large index buffer, contents of which don't really matter.
+ // Filled with low values so any vertex fetch is in-range.
+ const size_t kEboSize = 65536;
+ GLBuffer ebo;
+ glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
+ std::vector<uint8_t> indices(kEboSize);
+ for (size_t i = 0; i < kEboSize - 3; i++)
+ {
+ indices[i] = i % 3;
+ }
+ // Set indices 65533, 65534, 65535 to 3, 4, 5 to cover the left
+ // half of the framebuffer.
+ indices[kEboSize - 3] = 3;
+ indices[kEboSize - 2] = 4;
+ indices[kEboSize - 1] = 5;
+ glBufferData(GL_ELEMENT_ARRAY_BUFFER, kEboSize, indices.data(), GL_STATIC_DRAW);
+
+ // Draw red to the right half of the framebuffer. This draw call ensures that the
+ // index buffer is in use by the GPU, so the emulation, if any, would prefer the GPU path.
+ glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_BYTE, 0);
+
+ // Draw blue to the left half of the framebuffer. The draw call uses an offset to the
+ // end of the index buffer. This is a regression test for a bug where the 8-bit index
+ // emulation path miscalculated the range to emulate.
+ const size_t kOffset = 65533;
+ const size_t kCount = 3;
+ glUseProgram(prog2);
+ GLint aLoc2 = glGetAttribLocation(prog, "a_position");
+ glEnableVertexAttribArray(aLoc2);
+ glVertexAttribPointer(aLoc2, 2, GL_FLOAT, GL_FALSE, 0, 0);
+ glDrawElements(GL_TRIANGLES, kCount, GL_UNSIGNED_BYTE, reinterpret_cast<void *>(kOffset));
+
+ const int w = getWindowWidth();
+ const int h = getWindowHeight();
+
+ EXPECT_PIXEL_RECT_EQ(0, 0, w / 2 - 1, h, GLColor::blue);
+ EXPECT_PIXEL_RECT_EQ(w / 2 + 1, 0, w / 2 - 2, h, GLColor::red);
+ ASSERT_GL_NO_ERROR();
+}
+
+GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(VertexAttributeUint8Test);
+ANGLE_INSTANTIATE_TEST_ES3_AND(VertexAttributeUint8Test,
+ ES3_VULKAN().disable(Feature::SupportsIndexTypeUint8),
+ ES3_VULKAN_SWIFTSHADER().disable(Feature::SupportsIndexTypeUint8));
+
ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND(
VertexAttributeShiftInstancedArrayDataWithOffsetTest,
ES2_OPENGL().enable(Feature::ShiftInstancedArrayDataWithOffset),
Original Bug Report
OOB Read/Write in ANGLE Vulkan Index Buffer Conversion
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 security team.
Overview: A potential out-of-bounds read and write vulnerability exists in ANGLE’s Vulkan backend when emulating uint8 index buffers. The convertIndexBufferGPU function incorrectly uses the full source buffer size to set the compute shader’s workload, rather than the remaining size after the draw offset. Because ANGLE uses memory suballocations, this causes the shader to massively overrun the destination suballocation, potentially leading to arbitrary GPU memory corruption.
Affected files:
src/libANGLE/renderer/vulkan/VertexArrayVk.cppsrc/libANGLE/renderer/vulkan/shaders/src/ConvertIndex.compsrc/libANGLE/renderer/vulkan/UtilsVk.cpp
Estimated timestamp from git blame: 2022-01-06
Summary
There is a potential out-of-bounds (OOB) read and write vulnerability in VertexArrayVk::convertIndexBufferGPU within ANGLE’s Vulkan backend. On Vulkan implementations that do not support the VK_EXT_index_type_uint8 extension, ANGLE emulates uint8 indices by converting them to uint16 indices using a compute shader (ConvertIndex.comp).
When calculating the shader parameters, the code incorrectly uses the total size of the original buffer as the maxIndex limit, instead of calculating the limit based on the size of the data actually being converted (total size minus the draw offset). This mismatch results in the shader writing far beyond its allocated destination buffer.
Technical Details
In third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp, the convertIndexBufferGPU function correctly calculates the size of the data that needs to be converted and allocates the destination suballocation accordingly:
// srcDataSize is correctly bounded by the offset
size_t srcDataSize = static_cast<size_t>(bufferVk->getSize()) - offsetIntoSrcData;
// Allocate buffer for results (e.g. if srcDataSize is 3, allocates ~6 bytes)
ANGLE_TRY(contextVk->initBufferForVertexConversion(&mTranslatedByteIndexData,
sizeof(GLushort) * srcDataSize,
vk::MemoryHostVisibility::NonVisible));
However, a few lines later, when preparing the parameters for the compute shader, maxIndex is set to the full size of the original buffer, disregarding the offset:
params.maxIndex = static_cast<uint32_t>(bufferVk->getSize()); // Flawed!
This maxIndex determines how many workgroups are dispatched and dictates the bounds check inside the ConvertIndex.comp compute shader. If an attacker supplies a large buffer but issues a draw call with an offset near the end of the buffer, srcDataSize will be very small, resulting in a tiny destination suballocation. However, maxIndex will remain extremely large.
The compute shader will consequently write maxIndex * 2 bytes to the destination array. Because ANGLE pools allocations into large (e.g., 8MB) BufferBlocks backing a single VkBuffer/VkDeviceMemory, standard Vulkan robustBufferAccess hardware checks (which only validate against the underlying memory object bounds) are bypassed. The out-of-bounds write corrupts adjacent suballocations within the shared buffer pool.
Potential Exploitation Steps
Note: These are suggested steps to trigger the vulnerability based on code analysis. Our tooling agent does not currently have the capability to run a working Proof of Concept (PoC) to verify execution natively.
- Environment: Target a device lacking
VK_EXT_index_type_uint8(common on many Android Vulkan drivers). - Buffer Creation: In WebGL, create a large
ELEMENT_ARRAY_BUFFER(e.g., 65,536 bytes). - Pool Grooming: Carefully create and delete other WebGL buffers to groom the Vulkan
BufferPool. The goal is to position a sensitive object—like an indirect draw command buffer (VkDrawIndexedIndirectCommand) or a Uniform Buffer Object (UBO)—immediately after the destination suballocation for the upcoming conversion. - Force GPU Path: Issue a benign draw call using the source buffer to make it “GPU-busy.” This forces ANGLE to take the asynchronous GPU compute shader conversion path instead of the CPU mapping path.
- Trigger Bug: Call
gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_BYTE, 65533), where65533is the offset. - Result: ANGLE allocates a tiny destination suballocation (~6 bytes) but commands the shader to process 65,536 indices. The shader writes ~131KB out-of-bounds, overwriting the groomed target object. This could allow the attacker to execute arbitrary indirect commands on the GPU, leading to a Renderer-to-GPU Sandbox Escape.
Suggested Fix
Update VertexArrayVk::convertIndexBufferGPU to calculate maxIndex based on srcDataSize rather than the total buffer size.
// Fix in src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
params.srcOffset = static_cast<uint32_t>(offsetIntoSrcData);
params.dstOffset = 0;
- params.maxIndex = static_cast<uint32_t>(bufferVk->getSize());
+ params.maxIndex = static_cast<uint32_t>(srcDataSize);
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.