CVE-2026-11111
Overview
Files Changed
src/libANGLE/renderer/vulkan/ContextVk.cppsrc/tests/gl_tests/IndexBufferOffsetTest.cppsrc/tests/gl_tests/StateChangeTest.cpp
Patch
From 853856999a543df88a02cc9e36c385ecb798cd48 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
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 <syoussefi@chromium.org>
Reviewed-by: Charlie Lao <cclao@google.com>
Commit-Queue: Charlie Lao <cclao@google.com>
---
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);
Regression Test / PoC
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);
Original Bug Report
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.cppthird_party/angle/src/libANGLE/renderer/vulkan/VertexArrayVk.cppthird_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:
- With
VK_KHR_maintenance5: A 64-bit integer underflow when calculatingalignedSize(elementArrayBuffer->getSize() - mCurrentIndexBufferOffset), passing a huge size tovkCmdBindIndexBuffer2KHR. - 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.
- Target a device lacking
VK_EXT_index_type_uint8. - Create two framebuffers (
fboA,fboB) and a largeGL_ELEMENT_ARRAY_BUFFER. - Bind
fboAand performgl.drawElements(..., GL_UNSIGNED_BYTE, large_offset). This converts the buffer and resets the internal offset to 0. - Bind
fboB. This ends the render pass and setsDIRTY_BIT_RENDER_PASS, but leavesDIRTY_BIT_INDEX_BUFFERclear. - Perform the exact same
gl.drawElements(..., GL_UNSIGNED_BYTE, large_offset)call. setupIndexedDrawskips conversion/reset because the offset didn’t change and the dirty bit is clear.- 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.