Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in ANGLE
DescriptionOut of bounds read in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker500530720
Fix commit853856999a54 (angle/angle) +114/-26
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • src/libANGLE/renderer/vulkan/ContextVk.cpp
  • src/tests/gl_tests/IndexBufferOffsetTest.cpp
  • src/tests/gl_tests/StateChangeTest.cpp
From 853856999a543df88a02cc9e36c385ecb798cd48 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <[email protected]>
Date: Tue, 21 Apr 2026 12:09:01 -0400
Subject: [PATCH] Vulkan: Fix emulated-u8 index offset after RP closure

If the render pass is closed via DIRTY_BIT_RENDER_PASS, the index buffer
may not be dirty.  If uint8 indices are emulated, this meant that the
offset into the emulation buffer would not remain 0 by mistake.

Bug: chromium:500530720
Change-Id: Id9d2416f8827232f4e5795b7cb24db70e4f5c67d
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7781935
Auto-Submit: Shahbaz Youssefi <[email protected]>
Reviewed-by: Charlie Lao <[email protected]>
Commit-Queue: Charlie Lao <[email protected]>
---

diff --git a/src/libANGLE/renderer/vulkan/ContextVk.cpp b/src/libANGLE/renderer/vulkan/ContextVk.cpp
index 8cbf2a5..1fcb849 100644
--- a/src/libANGLE/renderer/vulkan/ContextVk.cpp
+++ b/src/libANGLE/renderer/vulkan/ContextVk.cpp
@@ -1653,32 +1653,37 @@
             vertexArrayVk->updateCurrentElementArrayBuffer();
         }
 
-        if (shouldConvertUint8VkIndexType(indexType) && mGraphicsDirtyBits[DIRTY_BIT_INDEX_BUFFER])
+        if (shouldConvertUint8VkIndexType(indexType))
         {
-            ANGLE_VK_PERF_WARNING(this, GL_DEBUG_SEVERITY_LOW,
-                                  "Potential inefficiency emulating uint8 vertex attributes due to "
-                                  "lack of hardware support");
-
-            BufferVk *bufferVk             = vk::GetImpl(elementArrayBuffer);
-            vk::BufferHelper &bufferHelper = bufferVk->getBuffer();
-
-            if (bufferHelper.isHostVisible() &&
-                mRenderer->hasResourceUseFinished(bufferHelper.getResourceUse()))
+            if (mGraphicsDirtyBits[DIRTY_BIT_INDEX_BUFFER])
             {
-                uint8_t *src = nullptr;
-                ANGLE_TRY(bufferVk->mapForReadAccessOnly(this, reinterpret_cast<void **>(&src)));
-                // Note: bufferOffset is not added here because mapImpl already adds it.
-                src += reinterpret_cast<uintptr_t>(indices);
-                const size_t byteCount = static_cast<size_t>(elementArrayBuffer->getSize()) -
-                                         reinterpret_cast<uintptr_t>(indices);
-                BufferBindingDirty bindingDirty;
-                ANGLE_TRY(vertexArrayVk->convertIndexBufferCPU(this, indexType, byteCount, src,
-                                                               &bindingDirty));
-                ANGLE_TRY(bufferVk->unmapReadAccessOnly(this));
-            }
-            else
-            {
-                ANGLE_TRY(vertexArrayVk->convertIndexBufferGPU(this, bufferVk, indices));
+                ANGLE_VK_PERF_WARNING(
+                    this, GL_DEBUG_SEVERITY_LOW,
+                    "Potential inefficiency emulating uint8 vertex attributes due to "
+                    "lack of hardware support");
+
+                BufferVk *bufferVk             = vk::GetImpl(elementArrayBuffer);
+                vk::BufferHelper &bufferHelper = bufferVk->getBuffer();
+
+                if (bufferHelper.isHostVisible() &&
+                    mRenderer->hasResourceUseFinished(bufferHelper.getResourceUse()))
+                {
+                    uint8_t *src = nullptr;
+                    ANGLE_TRY(
+                        bufferVk->mapForReadAccessOnly(this, reinterpret_cast<void **>(&src)));
+                    // Note: bufferOffset is not added here because mapImpl already adds it.
+                    src += reinterpret_cast<uintptr_t>(indices);
+                    const size_t byteCount = static_cast<size_t>(elementArrayBuffer->getSize()) -
+                                             reinterpret_cast<uintptr_t>(indices);
+                    BufferBindingDirty bindingDirty;
+                    ANGLE_TRY(vertexArrayVk->convertIndexBufferCPU(this, indexType, byteCount, src,
+                                                                   &bindingDirty));
+                    ANGLE_TRY(bufferVk->unmapReadAccessOnly(this));
+                }
+                else
+                {
+                    ANGLE_TRY(vertexArrayVk->convertIndexBufferGPU(this, bufferVk, indices));
+                }
             }
 
             mCurrentIndexBufferOffset = 0;
diff --git a/src/tests/gl_tests/IndexBufferOffsetTest.cpp b/src/tests/gl_tests/IndexBufferOffsetTest.cpp
index 075cf30..9d98d81 100644
--- a/src/tests/gl_tests/IndexBufferOffsetTest.cpp
+++ b/src/tests/gl_tests/IndexBufferOffsetTest.cpp
@@ -613,6 +613,8 @@
     EXPECT_GL_NO_ERROR();
 }
 
-ANGLE_INSTANTIATE_TEST_ES2_AND_ES3(IndexBufferOffsetTest);
+ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND(IndexBufferOffsetTest,
+                                       ES3_VULKAN().disable(Feature::SupportsIndexTypeUint8));
 
-ANGLE_INSTANTIATE_TEST_ES3(IndexBufferOffsetTestES3);
+ANGLE_INSTANTIATE_TEST_ES3_AND(IndexBufferOffsetTestES3,
+                               ES3_VULKAN().disable(Feature::SupportsIndexTypeUint8));
diff --git a/src/tests/gl_tests/StateChangeTest.cpp b/src/tests/gl_tests/StateChangeTest.cpp
index 0e8287f..37e710f 100644
--- a/src/tests/gl_tests/StateChangeTest.cpp
+++ b/src/tests/gl_tests/StateChangeTest.cpp
@@ -11634,6 +11634,87 @@
     EXPECT_PIXEL_COLOR_EQ(12, 12, GLColor::blue);
 }
 
+// Test draw after draw with uint8 index type and a non-zero offset.
+TEST_P(StateChangeTestES3, Uint8IndexIdenticalDrawsNonZeroOffset)
+{
+    ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::UniformColor());
+    glUseProgram(program);
+
+    GLint colorLoc = glGetUniformLocation(program, angle::essl1_shaders::ColorUniform());
+    ASSERT_NE(colorLoc, -1);
+
+    GLint posAttrib = glGetAttribLocation(program, essl1_shaders::PositionAttrib());
+    ASSERT_EQ(0, posAttrib);
+
+    // Arrange the vertices as such:
+    //
+    //     1      3      5
+    //      +-----+-----+
+    //      |     |     |
+    //      |     |     |
+    //      |     |     |
+    //      |     |     |
+    //      |     |     |
+    //      |     |     |
+    //      +-----+-----+
+    //     0      2      4
+    //
+    // Drawing a triangle strip with offset 2, the right half of the framebuffer is rendered.
+    std::vector<Vector3> positionData(256, {0, 0, 0});
+
+    positionData[0] = Vector3(-1, -1, 0);
+    positionData[1] = Vector3(-1, 1, 0);
+    positionData[2] = Vector3(0, -1, 0);
+    positionData[3] = Vector3(0, 1, 0);
+    positionData[4] = Vector3(1, -1, 0);
+    positionData[5] = Vector3(1, 1, 0);
+
+    constexpr std::array<GLubyte, 6> indices = {0, 1, 2, 3, 4, 5};
+
+    GLBuffer posBuffer;
+    glBindBuffer(GL_ARRAY_BUFFER, posBuffer);
+    glBufferData(GL_ARRAY_BUFFER, positionData.size() * sizeof(positionData[0]),
+                 positionData.data(), GL_STATIC_DRAW);
+    glVertexAttribPointer(posAttrib, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+    glEnableVertexAttribArray(posAttrib);
+
+    GLBuffer indexBuffer;
+    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
+    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices.data(), GL_STATIC_DRAW);
+
+    const int w = getWindowWidth();
+    const int h = getWindowHeight();
+
+    glClearColor(0, 0, 0, 1);
+    glClear(GL_COLOR_BUFFER_BIT);
+
+    // Draw red to the right half of the framebuffer
+    glUniform4f(colorLoc, 1, 0, 0, 1);
+    glDrawElements(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_BYTE, reinterpret_cast<const void *>(2));
+
+    // Trigger a render pass change.  In the Vulkan backend, the render pass is not actually closed
+    // until some processing is done, including the index buffer emulation.
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    GLTexture color;
+    glBindTexture(GL_TEXTURE_2D, color);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, w, h);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color, 0);
+    ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    // Draw again with the same offset, this time green
+    glUniform4f(colorLoc, 0, 1, 0, 1);
+    glDrawElements(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_BYTE, reinterpret_cast<const void *>(2));
+
+    // Verify results
+    EXPECT_PIXEL_RECT_EQ(w / 2 + 1, 0, w / 2 - 1, h, GLColor::green);
+    glBindFramebuffer(GL_FRAMEBUFFER, 0);
+    EXPECT_PIXEL_RECT_EQ(0, 0, w / 2 - 1, h, GLColor::black);
+    EXPECT_PIXEL_RECT_EQ(w / 2 + 1, 0, w / 2 - 1, h, GLColor::red);
+
+    ASSERT_GL_NO_ERROR();
+}
+
 }  // anonymous namespace
 
 ANGLE_INSTANTIATE_TEST_ES2(StateChangeTest);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/src/tests/gl_tests/IndexBufferOffsetTest.cpp b/src/tests/gl_tests/IndexBufferOffsetTest.cpp
index 075cf30..9d98d81 100644
--- a/src/tests/gl_tests/IndexBufferOffsetTest.cpp
+++ b/src/tests/gl_tests/IndexBufferOffsetTest.cpp
@@ -613,6 +613,8 @@
     EXPECT_GL_NO_ERROR();
 }
 
-ANGLE_INSTANTIATE_TEST_ES2_AND_ES3(IndexBufferOffsetTest);
+ANGLE_INSTANTIATE_TEST_ES2_AND_ES3_AND(IndexBufferOffsetTest,
+                                       ES3_VULKAN().disable(Feature::SupportsIndexTypeUint8));
 
-ANGLE_INSTANTIATE_TEST_ES3(IndexBufferOffsetTestES3);
+ANGLE_INSTANTIATE_TEST_ES3_AND(IndexBufferOffsetTestES3,
+                               ES3_VULKAN().disable(Feature::SupportsIndexTypeUint8));
diff --git a/src/tests/gl_tests/StateChangeTest.cpp b/src/tests/gl_tests/StateChangeTest.cpp
index 0e8287f..37e710f 100644
--- a/src/tests/gl_tests/StateChangeTest.cpp
+++ b/src/tests/gl_tests/StateChangeTest.cpp
@@ -11634,6 +11634,87 @@
     EXPECT_PIXEL_COLOR_EQ(12, 12, GLColor::blue);
 }
 
+// Test draw after draw with uint8 index type and a non-zero offset.
+TEST_P(StateChangeTestES3, Uint8IndexIdenticalDrawsNonZeroOffset)
+{
+    ANGLE_GL_PROGRAM(program, essl1_shaders::vs::Simple(), essl1_shaders::fs::UniformColor());
+    glUseProgram(program);
+
+    GLint colorLoc = glGetUniformLocation(program, angle::essl1_shaders::ColorUniform());
+    ASSERT_NE(colorLoc, -1);
+
+    GLint posAttrib = glGetAttribLocation(program, essl1_shaders::PositionAttrib());
+    ASSERT_EQ(0, posAttrib);
+
+    // Arrange the vertices as such:
+    //
+    //     1      3      5
+    //      +-----+-----+
+    //      |     |     |
+    //      |     |     |
+    //      |     |     |
+    //      |     |     |
+    //      |     |     |
+    //      |     |     |
+    //      +-----+-----+
+    //     0      2      4
+    //
+    // Drawing a triangle strip with offset 2, the right half of the framebuffer is rendered.
+    std::vector<Vector3> positionData(256, {0, 0, 0});
+
+    positionData[0] = Vector3(-1, -1, 0);
+    positionData[1] = Vector3(-1, 1, 0);
+    positionData[2] = Vector3(0, -1, 0);
+    positionData[3] = Vector3(0, 1, 0);
+    positionData[4] = Vector3(1, -1, 0);
+    positionData[5] = Vector3(1, 1, 0);
+
+    constexpr std::array<GLubyte, 6> indices = {0, 1, 2, 3, 4, 5};
+
+    GLBuffer posBuffer;
+    glBindBuffer(GL_ARRAY_BUFFER, posBuffer);
+    glBufferData(GL_ARRAY_BUFFER, positionData.size() * sizeof(positionData[0]),
+                 positionData.data(), GL_STATIC_DRAW);
+    glVertexAttribPointer(posAttrib, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+    glEnableVertexAttribArray(posAttrib);
+
+    GLBuffer indexBuffer;
+    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indexBuffer);
+    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices.data(), GL_STATIC_DRAW);
+
+    const int w = getWindowWidth();
+    const int h = getWindowHeight();
+
+    glClearColor(0, 0, 0, 1);
+    glClear(GL_COLOR_BUFFER_BIT);
+
+    // Draw red to the right half of the framebuffer
+    glUniform4f(colorLoc, 1, 0, 0, 1);
+    glDrawElements(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_BYTE, reinterpret_cast<const void *>(2));
+
+    // Trigger a render pass change.  In the Vulkan backend, the render pass is not actually closed
+    // until some processing is done, including the index buffer emulation.
+    GLFramebuffer fbo;
+    glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    GLTexture color;
+    glBindTexture(GL_TEXTURE_2D, color);
+    glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, w, h);
+    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color, 0);
+    ASSERT_GL_FRAMEBUFFER_COMPLETE(GL_FRAMEBUFFER);
+
+    // Draw again with the same offset, this time green
+    glUniform4f(colorLoc, 0, 1, 0, 1);
+    glDrawElements(GL_TRIANGLE_STRIP, 4, GL_UNSIGNED_BYTE, reinterpret_cast<const void *>(2));
+
+    // Verify results
+    EXPECT_PIXEL_RECT_EQ(w / 2 + 1, 0, w / 2 - 1, h, GLColor::green);
+    glBindFramebuffer(GL_FRAMEBUFFER, 0);
+    EXPECT_PIXEL_RECT_EQ(0, 0, w / 2 - 1, h, GLColor::black);
+    EXPECT_PIXEL_RECT_EQ(w / 2 + 1, 0, w / 2 - 1, h, GLColor::red);
+
+    ASSERT_GL_NO_ERROR();
+}
+
 }  // anonymous namespace
 
 ANGLE_INSTANTIATE_TEST_ES2(StateChangeTest);
Loading diff…

Original Bug Report

reported by [email protected]

ANGLE Vulkan Out-of-bounds index read via stale index buffer offset

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 logic error in ANGLE’s Vulkan backend can lead to an out-of-bounds (OOB) memory read on devices without VK_EXT_index_type_uint8 support. A stale index buffer offset is retained when alternating draw calls and framebuffer binds, leading to a size underflow or OOB offset during command recording. This allows an attacker to potentially read cross-origin GPU memory.

Affected files:

  • third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/vk_helpers.cpp

Estimated timestamp from git blame: 2025-05-13

Description

A potential vulnerability exists in ANGLE’s Vulkan backend on devices that do not support the VK_EXT_index_type_uint8 extension (common on Android and older Linux devices). Because Vulkan lacks uint8 index support on these platforms, ANGLE must convert GL_UNSIGNED_BYTE index buffers to uint16 during gl.drawElements.

The flaw lies in how ContextVk::setupIndexedDraw manages mCurrentIndexBufferOffset. When called, it unconditionally sets mCurrentIndexBufferOffset to the user-provided indices offset. However, the subsequent CPU conversion to uint16 and the necessary reset of mCurrentIndexBufferOffset back to 0 only occur if DIRTY_BIT_INDEX_BUFFER is set in mGraphicsDirtyBits.

An attacker can desynchronize this state by forcing a render pass to end (e.g., by changing framebuffers). Ending a render pass sets DIRTY_BIT_RENDER_PASS but leaves DIRTY_BIT_INDEX_BUFFER clear. If the attacker immediately issues another gl.drawElements call with the exact same large indices offset, setupIndexedDraw observes that the offset hasn’t changed (indices == mLastIndexBufferOffset) and does not set DIRTY_BIT_INDEX_BUFFER.

Because the dirty bit is clear, the conversion step is bypassed, and mCurrentIndexBufferOffset is not reset to 0. It retains the massive attacker-controlled offset, while mCurrentIndexBuffer continues to point to the small, previously converted buffer suballocation.

During ContextVk::setupDraw, the dirty bits are processed. DIRTY_BIT_RENDER_PASS is processed first, which calls flushDirtyGraphicsRenderPass. This function injects DIRTY_BIT_INDEX_BUFFER into the active dirty bit iterator to ensure the new render pass has the correct state bound.

When ContextVk::handleDirtyGraphicsIndexBuffer executes shortly after, it uses the mismatched state (small buffer, massive offset). This results in:

  1. With VK_KHR_maintenance5: A 64-bit integer underflow when calculating alignedSize (elementArrayBuffer->getSize() - mCurrentIndexBufferOffset), passing a huge size to vkCmdBindIndexBuffer2KHR.
  2. Without VK_KHR_maintenance5: A massive binding offset (bufferOffset + mCurrentIndexBufferOffset) pointing far past the intended suballocation in ANGLE’s shared vertex-conversion pool.

In both cases, vkCmdDrawIndexed will read indices out-of-bounds from the GPU pool, potentially disclosing cross-origin or UI data.

Potential Reproduction Steps (WebGL)

Note: These steps describe a potential attack path. We do not currently have an automated tool to execute this WebGL code.

  1. Target a device lacking VK_EXT_index_type_uint8.
  2. Create two framebuffers (fboA, fboB) and a large GL_ELEMENT_ARRAY_BUFFER.
  3. Bind fboA and perform gl.drawElements(..., GL_UNSIGNED_BYTE, large_offset). This converts the buffer and resets the internal offset to 0.
  4. Bind fboB. This ends the render pass and sets DIRTY_BIT_RENDER_PASS, but leaves DIRTY_BIT_INDEX_BUFFER clear.
  5. Perform the exact same gl.drawElements(..., GL_UNSIGNED_BYTE, large_offset) call.
  6. setupIndexedDraw skips conversion/reset because the offset didn’t change and the dirty bit is clear.
  7. The draw call submits OOB binding parameters to Vulkan, allowing the vertex shader to read and exfiltrate out-of-bounds GPU memory to the framebuffer.

Suggested Fix

Ensure that mCurrentIndexBufferOffset is accurately synchronized with the currently bound mCurrentIndexBuffer. If setupIndexedDraw determines that the current draw requires a converted uint8 buffer and the buffer is already converted and bound, it must ensure mCurrentIndexBufferOffset is set to 0, regardless of whether DIRTY_BIT_INDEX_BUFFER was initially dirty.

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.

View on issue tracker