CVE-2026-14388
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/tests/gl_tests/InstancingTest.cpp |
modified |
Files Changed
src/libANGLE/renderer/vulkan/VertexArrayVk.cppsrc/tests/gl_tests/InstancingTest.cpp
Patch
From 40e31f0ceb0bb6b9d50a784b5cef1351b0c6c521 Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Wed, 06 May 2026 19:21:05 +0000
Subject: [PATCH] Vulkan: Fix out-of-bounds read in divisor emulation
GetVertexCountForRange previously calculated the number of vertices by
ceiling the available buffer bytes divided by the stride:
`(bytes + stride - 1) / stride`. This formula over-counts the number of
available vertices when the remaining buffer space is enough for the
start of a vertex but not its full format size, resulting in the last
vertex's format-size read extending past the end of the buffer.
This over-counted value was then used in StreamVertexDataWithDivisor,
leading to an out-of-bounds read during the last instance's attribute
copy if the buffer ended near a memory allocation boundary.
This CL updates the vertex count calculation to correctly determine the
maximum number of vertices whose format-size reads fully fit within the
given buffer range. It also removes redundant and flawed clamping logic
in CalculateMaxVertexCountForConversion that was attempting to handle
this case.
Bug: b/500476886
Test: angle_end2end_tests --gtest_filter=InstancingTestES3.IncompleteStrideForLastVertex*
Change-Id: Ibc55ec1edb6a6d1268a9badcefc6b20318eab300
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7823123
Reviewed-by: Charlie Lao <cclao@google.com>
Reviewed-by: Shahbaz Youssefi <syoussefi@chromium.org>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
---
diff --git a/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp b/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
index ad7a308..29b73a4 100644
--- a/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
+++ b/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
@@ -304,9 +304,10 @@
return 0;
}
- size_t numVertices =
- static_cast<size_t>(srcBufferBytes + srcVertexStride - 1) / srcVertexStride;
- return numVertices;
+ // A vertex at stride-slot k occupies [k*stride, k*stride + formatSize). The maximum k such that
+ // k*stride + formatSize <= srcBufferBytes is k_max = floor((srcBufferBytes - srcFormatSize) /
+ // srcVertexStride). The number of vertices is k_max + 1.
+ return static_cast<size_t>((srcBufferBytes - srcFormatSize) / srcVertexStride + 1);
}
size_t GetVertexCount(BufferVk *srcBuffer, const gl::VertexBinding &binding, uint32_t srcFormatSize)
@@ -353,16 +354,6 @@
return angle::Result::Continue;
}
- // The intended data size does not include the entire stride from the last vertex, but only the
- // format size. The data size must be within the source buffer's limit.
- VkDeviceSize intendedSrcDataSize =
- static_cast<VkDeviceSize>((maxNumVertices - 1) * srcStride) + srcFormatSize;
- if (intendedSrcDataSize > srcBufferSize)
- {
- maxNumVertices = static_cast<size_t>(srcBufferSize / srcStride);
- ASSERT(maxNumVertices != 0);
- }
-
// Allocate buffer for results
vk::MemoryHostVisibility hostVisible = conversion->getCacheKey().hostVisible
? vk::MemoryHostVisibility::Visible
diff --git a/src/tests/gl_tests/InstancingTest.cpp b/src/tests/gl_tests/InstancingTest.cpp
index 8c886f6..83541b6 100644
--- a/src/tests/gl_tests/InstancingTest.cpp
+++ b/src/tests/gl_tests/InstancingTest.cpp
@@ -888,6 +888,80 @@
}
}
+// Regression test for out-of-bounds read during divisor emulation. http://crbug.com/500476886
+TEST_P(InstancingTestES3, IncompleteStrideForLastVertex)
+{
+ constexpr char kVS[] = R"(#version 300 es
+ layout(location=0) in vec4 a_inst;
+ layout(location=1) in float a_unused;
+ out vec4 v;
+ const vec2 coords[3] = vec2[](
+ vec2(-3.0, -3.0),
+ vec2( 3.0, -3.0),
+ vec2( 0.0, 3.0)
+ );
+ void main() {
+ v = a_inst + vec4(a_unused);
+ if (gl_InstanceID == 0) {
+ gl_Position = vec4(coords[gl_VertexID], 0.0, 1.0);
+ } else {
+ gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
+ }
+ })";
+
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+in vec4 v;
+out vec4 o;
+void main() { o = v; })";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ glUseProgram(program);
+
+ glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ // Unused attrib 1
+ GLBuffer unusedBuf;
+ glBindBuffer(GL_ARRAY_BUFFER, unusedBuf);
+ std::vector<GLfloat> unusedData(1, 0.0f);
+ glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * unusedData.size(), unusedData.data(),
+ GL_STATIC_DRAW);
+ glEnableVertexAttribArray(1);
+ glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 0, nullptr);
+
+ // Instanced attrib 0
+ GLBuffer instBuf;
+ glBindBuffer(GL_ARRAY_BUFFER, instBuf);
+ std::vector<GLfloat> instData(64, 0.0f); // 256 bytes
+ instData[59] = instData[60] = instData[61] = instData[62] = 1.0f;
+ glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * instData.size(), instData.data(),
+ GL_STATIC_DRAW);
+ glEnableVertexAttribArray(0);
+ // Buffer size = 256, offset = 236.
+ // bytes = 256 - 236 = 20. stride = 16, formatSize = 16.
+ glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 16, reinterpret_cast<const void *>(236));
+
+ // Divisor > MaxVertexAttribDivisor will trigger emulation path.
+ // mMaxVertexAttribDivisor is clamped to 255 in the Vulkan backend, so 256 guarantees emulation.
+ // The previous bug resulted in GetVertexCountForRange returning an over-counted
+ // number of vertices, leading to an OOB read in StreamVertexDataWithDivisor.
+ GLuint divisor = 256;
+ glVertexAttribDivisor(0, divisor);
+
+ EXPECT_GL_NO_ERROR();
+
+ // The draw call should not crash. We need divisor + 1 instances to reach the second vertex
+ // and trigger the OOB read.
+ glDrawArraysInstanced(GL_TRIANGLES, 0, 3, divisor + 1);
+
+ // Check the center pixel, which corresponds to gl_InstanceID == 0.
+ EXPECT_PIXEL_COLOR_NEAR(getWindowWidth() / 2, getWindowHeight() / 2,
+ GLColor(255, 255, 255, 255), 1);
+
+ EXPECT_GL_NO_ERROR();
+}
+
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(InstancingTestES3);
ANGLE_INSTANTIATE_TEST_ES3(InstancingTestES3);
Regression Test / PoC
diff --git a/src/tests/gl_tests/InstancingTest.cpp b/src/tests/gl_tests/InstancingTest.cpp
index 8c886f6..83541b6 100644
--- a/src/tests/gl_tests/InstancingTest.cpp
+++ b/src/tests/gl_tests/InstancingTest.cpp
@@ -888,6 +888,80 @@
}
}
+// Regression test for out-of-bounds read during divisor emulation. http://crbug.com/500476886
+TEST_P(InstancingTestES3, IncompleteStrideForLastVertex)
+{
+ constexpr char kVS[] = R"(#version 300 es
+ layout(location=0) in vec4 a_inst;
+ layout(location=1) in float a_unused;
+ out vec4 v;
+ const vec2 coords[3] = vec2[](
+ vec2(-3.0, -3.0),
+ vec2( 3.0, -3.0),
+ vec2( 0.0, 3.0)
+ );
+ void main() {
+ v = a_inst + vec4(a_unused);
+ if (gl_InstanceID == 0) {
+ gl_Position = vec4(coords[gl_VertexID], 0.0, 1.0);
+ } else {
+ gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
+ }
+ })";
+
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+in vec4 v;
+out vec4 o;
+void main() { o = v; })";
+
+ ANGLE_GL_PROGRAM(program, kVS, kFS);
+ glUseProgram(program);
+
+ glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ // Unused attrib 1
+ GLBuffer unusedBuf;
+ glBindBuffer(GL_ARRAY_BUFFER, unusedBuf);
+ std::vector<GLfloat> unusedData(1, 0.0f);
+ glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * unusedData.size(), unusedData.data(),
+ GL_STATIC_DRAW);
+ glEnableVertexAttribArray(1);
+ glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 0, nullptr);
+
+ // Instanced attrib 0
+ GLBuffer instBuf;
+ glBindBuffer(GL_ARRAY_BUFFER, instBuf);
+ std::vector<GLfloat> instData(64, 0.0f); // 256 bytes
+ instData[59] = instData[60] = instData[61] = instData[62] = 1.0f;
+ glBufferData(GL_ARRAY_BUFFER, sizeof(GLfloat) * instData.size(), instData.data(),
+ GL_STATIC_DRAW);
+ glEnableVertexAttribArray(0);
+ // Buffer size = 256, offset = 236.
+ // bytes = 256 - 236 = 20. stride = 16, formatSize = 16.
+ glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 16, reinterpret_cast<const void *>(236));
+
+ // Divisor > MaxVertexAttribDivisor will trigger emulation path.
+ // mMaxVertexAttribDivisor is clamped to 255 in the Vulkan backend, so 256 guarantees emulation.
+ // The previous bug resulted in GetVertexCountForRange returning an over-counted
+ // number of vertices, leading to an OOB read in StreamVertexDataWithDivisor.
+ GLuint divisor = 256;
+ glVertexAttribDivisor(0, divisor);
+
+ EXPECT_GL_NO_ERROR();
+
+ // The draw call should not crash. We need divisor + 1 instances to reach the second vertex
+ // and trigger the OOB read.
+ glDrawArraysInstanced(GL_TRIANGLES, 0, 3, divisor + 1);
+
+ // Check the center pixel, which corresponds to gl_InstanceID == 0.
+ EXPECT_PIXEL_COLOR_NEAR(getWindowWidth() / 2, getWindowHeight() / 2,
+ GLColor(255, 255, 255, 255), 1);
+
+ EXPECT_GL_NO_ERROR();
+}
+
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(InstancingTestES3);
ANGLE_INSTANTIATE_TEST_ES3(InstancingTestES3);
Original Bug Report
GPU Process Heap OOB Read in ANGLE Vulkan via Vertex Divisor Emulation
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 math error in ANGLE’s Vulkan backend over-calculates the number of vertices when a buffer’s remaining bytes do not cover the full format size. During CPU-side vertex divisor emulation, this causes an out-of-bounds read of adjacent GPU heap memory, which is then passed to shaders, potentially leading to a cross-origin information leak.
Affected files:
third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
Estimated timestamp from git blame: 2024-08-22
Summary
A potential heap-based out-of-bounds (OOB) read exists in ANGLE’s Vulkan backend due to an incorrect vertex count calculation in GetVertexCountForRange. When vertex divisors exceed hardware limits (typically > 255), ANGLE performs CPU-side emulation of the divisor. Because the vertex count can be over-calculated by one, the emulation loop reads beyond the allocated bounds of the mapped Vulkan buffer. This OOB data is copied into a new vertex buffer where it can be exfiltrated by attacker-controlled shaders, leading to a potential cross-origin information leak from the GPU process.
Technical Details
The vulnerability originates in third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp within GetVertexCountForRange:
size_t GetVertexCountForRange(GLint64 srcBufferBytes,
uint32_t srcFormatSize,
uint32_t srcVertexStride)
{
// ...
size_t numVertices =
static_cast<size_t>(srcBufferBytes + srcVertexStride - 1) / srcVertexStride;
return numVertices;
}
This calculation is equivalent to ceil(srcBufferBytes / srcVertexStride). However, a vertex starting at the last possible stride-aligned position is only valid if the remaining buffer space is at least the size of the vertex format (srcFormatSize). If the remaining bytes are less than srcFormatSize but non-zero modulo srcVertexStride, the function over-counts the valid vertices by exactly one.
When a WebGL application requests a vertex attribute divisor exceeding ANGLE’s capped hardware maximum (255), the attribute enters the mStreamingVertexAttribsMask path and triggers CPU-side emulation in VertexArrayVk::updateStreamedAttribs.
Front-end software bounds validation (ValidateDrawInstancedAttribs) is intentionally bypassed for these draws because Chrome relies on the Vulkan driver’s robustBufferAccessBehaviorKHR to handle out-of-bounds accesses safely. However, because the divisor emulation is performed on the CPU using raw memory mapping (bufferVk->mapForReadAccessOnly), the hardware protections are entirely bypassed.
The over-calculated vertex count is passed to StreamVertexDataWithDivisor. On the final (over-counted) vertex iteration, the source pointer srcData is advanced past the valid data, and the subsequent memory copy (via CopyNativeVertexData) performs a memcpy that reads up to 15 bytes out-of-bounds from the adjacent heap suballocation.
Potential Attack Steps
These are the suggested steps an attacker would follow to trigger the vulnerability from a malicious website:
- Create a WebGL context and allocate an
ArrayBufferfor vertex data. - Groom the Vulkan heap to position sensitive cross-origin data immediately following the vertex buffer suballocation.
- Configure
gl.vertexAttribPointerwith a specificstrideandformat(e.g.,gl.RGBA32Frequiring 16 bytes) such that the available bytes at the end of the buffer trigger the over-calculation (e.g., leaving exactly 1 byte after the final stride). - Call
gl.vertexAttribDivisorANGLEwith a divisor > 255 to force CPU-side emulation. - Issue an instanced draw call using
gl.drawArraysInstancedANGLE. - The CPU emulation copies the out-of-bounds bytes into a new valid vertex buffer.
- The attacker’s vertex shader receives the leaked bytes as normal attributes and forwards them to a fragment shader.
- The attacker calls
gl.readPixels()to extract the leaked cross-origin memory.
Suggested Remediation
The vertex count calculation in GetVertexCountForRange should be corrected directly to ensure the available bytes can cover the full srcFormatSize of the final vertex.
size_t GetVertexCountForRange(GLint64 srcBufferBytes,
uint32_t srcFormatSize,
uint32_t srcVertexStride)
{
// ...
if (srcBufferBytes < srcFormatSize)
{
return 0;
}
return static_cast<size_t>(srcBufferBytes - srcFormatSize) / srcVertexStride + 1;
}
Applying this fix directly inside GetVertexCountForRange will resolve the issue for all callers, including the divisor emulation path.
Evaluated with Chrome root at commit: 137d451a126685dd5010e6609db9f6d4a78d8234
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.