Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in ANGLE
DescriptionHeap buffer overflow in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker496503799
Fix commita96c6e3e7e3a (angle/angle) +69/-2
CISA KEVNot listed
CreditedAnonymous
Disclosed2026-05-05

Files Changed

  • src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
  • src/tests/angle_end2end_tests_expectations.txt
  • src/tests/gl_tests/BufferDataTest.cpp
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

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.

[0] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=850

[1] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=866

[2] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=867

[3] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=236

[4] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=370

[5] https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp;drc=216f1f0264f2b729c45c6aa7a038dc592c4551cc;l=1323

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.

  1. Apply the renderer patch and build Chromium with ASan.
  2. Host poc.html on an HTTP server.
  3. 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

View on issue tracker