High watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read and write in Angle
DescriptionOut of bounds read and write in Angle
ComponentAngle
Sub-componentVulkan
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker498746519
Fix commit0d9ddc506ea7 (angle/angle) +108/-1
CWECWE-125, CWE-787 (Out-of-bounds read, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-01 (per vendor advisory)
Fix landed2026-04-21 (public git)
Disclosed2026-04-28
Patch gap7 days — fix public in source before the advisory

Background

ANGLE
Chrome’s GL-on-Vulkan translation layer; VertexArrayVk is its Vulkan-backend vertex-array object implementation.
8-bit index emulation
When the Vulkan device lacks Feature::SupportsIndexTypeUint8, ANGLE converts GL_UNSIGNED_BYTE element-array data into a wider index type on the GPU via UtilsVk::convertIndexBuffer.
`ConvertIndexParameters`
The struct passed to the GPU conversion, carrying srcOffset (where in the source buffer to start) and maxIndex (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 offsetIntoSrcData onward.

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.

Key insight
The maximum-index bound was derived from the whole buffer’s size rather than the space left after the source offset, so a non-zero offset made the bound overshoot; the fix uses the previously computed remaining size srcDataSize instead of bufferVk->getSize().

Attack Path

  1. Force the emulation path Web content issues WebGL draws on a configuration where GL_UNSIGNED_BYTE indices are not natively supported, routing index data through ANGLE’s GPU 8-bit-index conversion.
  2. 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’s kOffset = 65533).
  3. Overshoot the bound The conversion is launched with maxIndex equal to the full buffer size instead of the remaining srcDataSize, so its computed range extends past the end of the source buffer region.
  4. 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

An attacker running WebGL in a renderer can trigger an out-of-bounds read and write during GPU index-buffer conversion in the GPU process on devices that emulate 8-bit indices, which the vendor advisory rates high severity. The preconditions are a Vulkan backend lacking 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

FunctionChangeNotes
VertexAttributeUint8Test
src/tests/gl_tests/VertexAttributeTest.cpp
modified

Files Changed

  • src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
  • src/tests/gl_tests/VertexAttributeTest.cpp

Audit Directions

  • Offset vs. size confusion
    Audit 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 bounds
    Review other ANGLE format/index emulation paths (UtilsVk conversions, index-range computation) for bounds derived from allocation size rather than the remaining accessible region.
  • End-of-buffer edge cases
    Add 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.
From 0d9ddc506ea7593138ef8e80640f7a70a01a9cad Mon Sep 17 00:00:00 2001
From: Ken Russell <[email protected]>
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 <[email protected]>
Reviewed-by: Shahbaz Youssefi <[email protected]>
Auto-Submit: Kenneth Russell <[email protected]>
Reviewed-by: Charlie Lao <[email protected]>
---

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),
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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.cpp
  • src/libANGLE/renderer/vulkan/shaders/src/ConvertIndex.comp
  • src/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.

  1. Environment: Target a device lacking VK_EXT_index_type_uint8 (common on many Android Vulkan drivers).
  2. Buffer Creation: In WebGL, create a large ELEMENT_ARRAY_BUFFER (e.g., 65,536 bytes).
  3. 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.
  4. 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.
  5. Trigger Bug: Call gl.drawElements(gl.TRIANGLES, 3, gl.UNSIGNED_BYTE, 65533), where 65533 is the offset.
  6. 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.

View on issue tracker