CVE-2026-8556
Overview
Background
- Uniform Buffer Object (UBO)
- A GPU buffer bound to a shader uniform block; a program’s uniform blocks are associated with binding points via
glUniformBlockBinding, and buffers are bound to those points (optionally at an offset/size) viaglBindBufferRange. - Dirty-bit / dirty-type mask
- ANGLE tracks changed state with dirty bits so the backend re-syncs only what changed;
mUniformBufferBlocksDirtyTypeMaskrecords which kind of UBO change (e.g.BufferDirtyType::Binding) must be reprocessed. - Offset-only fast path
- The Vulkan backend has an optimization that, when only a buffer’s offset changed, updates dynamic offsets without recomputing the full block-to-binding index remap.
- `glUniformBlockBinding`
- The GL call that remaps a program’s uniform block to a different binding point, requiring the backend to recompute which buffer each block reads from.
Root Cause Analysis
The function handling a uniform-block binding change (in src/libANGLE/Context.cpp, at the @@ -9341 hunk) set DIRTY_BIT_UNIFORM_BUFFER_BINDINGS and marked the specific block dirty via mDirtyUniformBlocks, but did not record the change in mUniformBufferBlocksDirtyTypeMask. In the Vulkan backend, when a preceding glBindBufferRange had taken the offset-only fast path, the block-to-binding index remap was not considered dirty of type Binding, so a subsequent glUniformBlockBinding that repointed a block to a different binding point left the backend using a stale index mapping. The invariant violated is that any change to the block→binding association must mark the binding dirty type so the descriptor/index remap is rebuilt. As the regression test BlockBindChangeAfterOffsetChange shows, a block could end up reading from the wrong buffer (the test rebinds block1 to binding 2’s 16 MiB buffer after an offset-only change and expects the correct combined pixel color).
The fix adds mState.mUniformBufferBlocksDirtyTypeMask.set(BufferDirtyType::Binding) so the binding-type remap is always re-synced.
BufferDirtyType::Binding in mUniformBufferBlocksDirtyTypeMask, so the Vulkan offset-only fast path kept a stale block-to-buffer index remap; the fix marks the binding dirty type so the remap is rebuilt.Attack Path
- Set up bindings
A WebGL/GLES program binds several UBOs to binding points, including a large buffer at a high offset, and issues a draw to flush dirty bits (per
BlockBindChangeAfterOffsetChange). - Trigger the offset-only fast path
glBindBufferRangechanges only a binding’s offset, which the Vulkan backend handles via the offset-only fast path without recomputing the index remap. - Remap a block
glUniformBlockBindingrepoints a uniform block to a different binding point; pre-patch this did not setBufferDirtyType::Binding, so the stale index remap persisted. - Wrong buffer read The next draw reads a uniform block from the wrong buffer/region, yielding incorrect results and potentially an out-of-bounds read of GPU buffer memory (inferred — the diff shows incorrect buffer selection but no concrete OOB sink).
Impact Assessment
Files Changed
src/libANGLE/Context.cppsrc/tests/gl_tests/UniformBufferTest.cpp
Audit Directions
- Split dirty-state trackingWhere state change is recorded in more than one structure (a dirty bit and a dirty-type mask), audit every mutator to confirm it updates all of them; a missed secondary flag re-enables stale fast paths.
- Backend fast paths vs. frontend stateReview Vulkan/GL backend optimizations (offset-only, no-op, incremental) to ensure every frontend state transition they skip is invalidated when a later call changes the skipped association.
- UBO/SSBO binding remapsCheck other
glUniformBlockBinding/glShaderStorageBlockBinding/glBindBufferRangepaths for the same dirty-type coverage, so block-to-binding index remaps are always rebuilt after offset-only updates.
Patch
From a812b1d2c8f5e2223a56cdc9d346fb2af1e40235 Mon Sep 17 00:00:00 2001 From: Shahbaz Youssefi <[email protected]> Date: Wed, 08 Apr 2026 12:56:56 -0400 Subject: [PATCH] Vulkan: Fix UBO index remap after offset-only update Bug: chromium:500052361 Change-Id: I08700488e1a571f3c5d9897c4b81b7dedee92d51 Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7736311 Reviewed-by: Charlie Lao <[email protected]> Commit-Queue: Shahbaz Youssefi <[email protected]> --- diff --git a/src/libANGLE/Context.cpp b/src/libANGLE/Context.cpp index 5bb6d0f..9614024 100644 --- a/src/libANGLE/Context.cpp +++ b/src/libANGLE/Context.cpp @@ -9341,6 +9341,7 @@ { mState.mDirtyBits.set(state::DIRTY_BIT_UNIFORM_BUFFER_BINDINGS); mState.mDirtyUniformBlocks.set(uniformBlockIndex); + mState.mUniformBufferBlocksDirtyTypeMask.set(BufferDirtyType::Binding); mStateCache.onUniformBufferStateChange(this); } diff --git a/src/tests/gl_tests/UniformBufferTest.cpp b/src/tests/gl_tests/UniformBufferTest.cpp index e3bdf40..4261724 100644 --- a/src/tests/gl_tests/UniformBufferTest.cpp +++ b/src/tests/gl_tests/UniformBufferTest.cpp @@ -4815,6 +4815,72 @@ EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green); } +// Test that rebinding UBOs with |glUniformBlockBinding| a buffer offset has changed works. +TEST_P(UniformBufferTest, BlockBindChangeAfterOffsetChange) +{ + constexpr char kFS[] = R"(#version 300 es +precision highp float; +layout(std140) uniform Block0 { vec4 u0; }; +layout(std140) uniform Block1 { vec4 u1; }; +out vec4 fragColor; +void main() { + fragColor = u0 + u1; +})"; + + ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Simple(), kFS); + glUseProgram(program); + + const GLuint block0Index = glGetUniformBlockIndex(program, "Block0"); + const GLuint block1Index = glGetUniformBlockIndex(program, "Block1"); + + // Map block bindings to something explicit + glUniformBlockBinding(program, block0Index, 0); + glUniformBlockBinding(program, block1Index, 1); + + constexpr GLuint kSmallBufferSize = 256; + constexpr GLuint kLargeBufferSize = 16 * 1024 * 1024; + + const std::vector<float> kBuffer0InitData(kSmallBufferSize, 0.25); + const std::vector<float> kBuffer1InitData(kSmallBufferSize, 0.1); + const std::vector<float> kBuffer2InitData(kLargeBufferSize, 0.5); + + GLBuffer buffer0; + glBindBuffer(GL_UNIFORM_BUFFER, buffer0); + glBufferData(GL_UNIFORM_BUFFER, sizeof(float) * kSmallBufferSize, kBuffer0InitData.data(), + GL_STATIC_DRAW); + + GLBuffer buffer1; + glBindBuffer(GL_UNIFORM_BUFFER, buffer1); + glBufferData(GL_UNIFORM_BUFFER, sizeof(float) * kSmallBufferSize, kBuffer1InitData.data(), + GL_STATIC_DRAW); + + GLBuffer buffer2; + glBindBuffer(GL_UNIFORM_BUFFER, buffer2); + glBufferData(GL_UNIFORM_BUFFER, sizeof(float) * kLargeBufferSize, kBuffer2InitData.data(), + GL_STATIC_DRAW); + + // Bind all the buffers. Note that binding 2 is unused by the program. Bind the large buffer + // at an offset near the end. + glBindBufferRange(GL_UNIFORM_BUFFER, 0, buffer0, 0, kSmallBufferSize); + glBindBufferRange(GL_UNIFORM_BUFFER, 1, buffer1, 0, kSmallBufferSize); + glBindBufferRange(GL_UNIFORM_BUFFER, 2, buffer2, kLargeBufferSize - kSmallBufferSize, + kSmallBufferSize); + + // Issue a draw call to sync all dirty bits. + drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f); + + // Change the offset of binding 0 only. This takes a special fast-path in the Vulkan backend. + glBindBufferRange(GL_UNIFORM_BUFFER, 0, buffer0, kSmallBufferSize, kSmallBufferSize); + + // Switch the binding of the other buffer to the huge buffer. + glUniformBlockBinding(program, block1Index, 2); + + // Draw again. It must correctly read from buffer0 and buffer2. + drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f); + EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(191, 191, 191, 191), 1); + ASSERT_GL_NO_ERROR(); +} + GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(UniformBufferTest); ANGLE_INSTANTIATE_TEST_ES3(UniformBufferTest);
Regression Test / PoC
diff --git a/src/tests/gl_tests/UniformBufferTest.cpp b/src/tests/gl_tests/UniformBufferTest.cpp
index e3bdf40..4261724 100644
--- a/src/tests/gl_tests/UniformBufferTest.cpp
+++ b/src/tests/gl_tests/UniformBufferTest.cpp
@@ -4815,6 +4815,72 @@
EXPECT_PIXEL_COLOR_EQ(0, 0, GLColor::green);
}
+// Test that rebinding UBOs with |glUniformBlockBinding| a buffer offset has changed works.
+TEST_P(UniformBufferTest, BlockBindChangeAfterOffsetChange)
+{
+ constexpr char kFS[] = R"(#version 300 es
+precision highp float;
+layout(std140) uniform Block0 { vec4 u0; };
+layout(std140) uniform Block1 { vec4 u1; };
+out vec4 fragColor;
+void main() {
+ fragColor = u0 + u1;
+})";
+
+ ANGLE_GL_PROGRAM(program, essl3_shaders::vs::Simple(), kFS);
+ glUseProgram(program);
+
+ const GLuint block0Index = glGetUniformBlockIndex(program, "Block0");
+ const GLuint block1Index = glGetUniformBlockIndex(program, "Block1");
+
+ // Map block bindings to something explicit
+ glUniformBlockBinding(program, block0Index, 0);
+ glUniformBlockBinding(program, block1Index, 1);
+
+ constexpr GLuint kSmallBufferSize = 256;
+ constexpr GLuint kLargeBufferSize = 16 * 1024 * 1024;
+
+ const std::vector<float> kBuffer0InitData(kSmallBufferSize, 0.25);
+ const std::vector<float> kBuffer1InitData(kSmallBufferSize, 0.1);
+ const std::vector<float> kBuffer2InitData(kLargeBufferSize, 0.5);
+
+ GLBuffer buffer0;
+ glBindBuffer(GL_UNIFORM_BUFFER, buffer0);
+ glBufferData(GL_UNIFORM_BUFFER, sizeof(float) * kSmallBufferSize, kBuffer0InitData.data(),
+ GL_STATIC_DRAW);
+
+ GLBuffer buffer1;
+ glBindBuffer(GL_UNIFORM_BUFFER, buffer1);
+ glBufferData(GL_UNIFORM_BUFFER, sizeof(float) * kSmallBufferSize, kBuffer1InitData.data(),
+ GL_STATIC_DRAW);
+
+ GLBuffer buffer2;
+ glBindBuffer(GL_UNIFORM_BUFFER, buffer2);
+ glBufferData(GL_UNIFORM_BUFFER, sizeof(float) * kLargeBufferSize, kBuffer2InitData.data(),
+ GL_STATIC_DRAW);
+
+ // Bind all the buffers. Note that binding 2 is unused by the program. Bind the large buffer
+ // at an offset near the end.
+ glBindBufferRange(GL_UNIFORM_BUFFER, 0, buffer0, 0, kSmallBufferSize);
+ glBindBufferRange(GL_UNIFORM_BUFFER, 1, buffer1, 0, kSmallBufferSize);
+ glBindBufferRange(GL_UNIFORM_BUFFER, 2, buffer2, kLargeBufferSize - kSmallBufferSize,
+ kSmallBufferSize);
+
+ // Issue a draw call to sync all dirty bits.
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+
+ // Change the offset of binding 0 only. This takes a special fast-path in the Vulkan backend.
+ glBindBufferRange(GL_UNIFORM_BUFFER, 0, buffer0, kSmallBufferSize, kSmallBufferSize);
+
+ // Switch the binding of the other buffer to the huge buffer.
+ glUniformBlockBinding(program, block1Index, 2);
+
+ // Draw again. It must correctly read from buffer0 and buffer2.
+ drawQuad(program, essl3_shaders::PositionAttrib(), 0.5f);
+ EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(191, 191, 191, 191), 1);
+ ASSERT_GL_NO_ERROR();
+}
+
GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(UniformBufferTest);
ANGLE_INSTANTIATE_TEST_ES3(UniformBufferTest);
Original Bug Report
ANGLE/Vulkan: glUniformBlockBinding bypasses UBO dirty-type mask causing GPU OOB read
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 allows glUniformBlockBinding to bypass necessary descriptor set updates by failing to update the UBO dirty-type mask. This can result in an out-of-bounds GPU memory read by applying a large dynamic offset to an incorrect, smaller buffer descriptor.
Affected files:
third_party/angle/src/libANGLE/Context.cppthird_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cppthird_party/angle/src/libANGLE/renderer/vulkan/vk_cache_utils.cppthird_party/angle/src/libANGLE/renderer/vulkan/ProgramExecutableVk.cppthird_party/angle/src/libANGLE/State.cppthird_party/angle/src/libANGLE/ProgramExecutable.cpp
Estimated timestamp from git blame: 2025-07-10
Summary
A potential vulnerability in ANGLE’s Vulkan backend allows for out-of-bounds (OOB) GPU memory reads. The issue arises because glUniformBlockBinding fails to properly update the Uniform Buffer Object (UBO) dirty-type mask (mUniformBufferBlocksDirtyTypeMask). This leads the Vulkan backend to incorrectly use an ‘offset-only’ fast path during state synchronization, applying a new dynamic offset to a stale VkBuffer descriptor. This violates Vulkan safety requirements (VUID-01979) and can be exploited to leak cross-origin GPU memory data.
Technical Details
In ANGLE, State::mUniformBufferBlocksDirtyTypeMask tracks whether UBO updates involve only offset changes or full binding changes. The Vulkan backend uses this mask to optimize descriptor set updates. If the mask is exactly {Offset}, ContextVk::syncState may take the updateUniformBufferBlocksOffset fast path. This fast path only updates the mDynamicOffsets array sent to Vulkan without refreshing the underlying VkBuffer handles or ranges in the descriptor set.
When glUniformBlockBinding is called, it triggers Context::onUniformBlockBindingUpdated. This function marks the uniform block as dirty (DIRTY_BIT_UNIFORM_BUFFER_BINDINGS) but crucially fails to update mUniformBufferBlocksDirtyTypeMask. If an attacker ensures this mask is currently {Offset} (e.g., by changing the offset of a different buffer), a subsequent remapping via glUniformBlockBinding will leave the mask as {Offset}.
During the next draw call, ContextVk::syncState sees the {Offset} mask and takes the fast path. It retrieves the offset for the new binding and applies it to the mDynamicOffsets array. However, because a full update wasn’t triggered, the descriptor set still contains the VkBuffer from the previous binding.
If the new binding’s offset is large, the combined dynamicOffset + range can exceed the size of the original VkBuffer. In vk_cache_utils.cpp, DescriptorSetDescBuilder::updateOneUniformBufferOffset contains an assertion to catch this serial mismatch, but it is compiled out in release builds, allowing the mismatched offset to reach the Vulkan driver.
Potential Trigger Steps
(Note: These are suggested steps based on code analysis; our tooling has not executed a working proof of concept.)
- Setup: Create a WebGL2 context. Compile a shader program using multiple active
std140uniform blocks. Keep the number of active blocks small (e.g., <= 6) to ensureProgramExecutableVk::usesDynamicUniformBufferDescriptors()returns true. - Initial Bindings: Bind buffers using
gl.bindBufferRange:- Buffer A to binding index 0.
- Buffer B (small) to binding index 1.
- Buffer C (large, with a large offset) to binding index 5.
- Sync: Issue
gl.drawArraysto synchronize state and clear the dirty masks in the backend. - Set Offset Mask: Call
gl.bindBufferRangeon binding index 0, changing only the offset. This setsmUniformBufferBlocksDirtyTypeMaskto exactly{Offset}. - Trigger Bug: Call
gl.uniformBlockBinding(program, 1, 5)to remap uniform block 1 from binding 1 (Buffer B) to binding 5 (Buffer C).Context::onUniformBlockBindingUpdatedfails to update the mask, leaving it as{Offset}. - Exploit: Issue another
gl.drawArrays.ContextVk::syncStatetakes the fast path. It applies Buffer C’s massive dynamic offset to Buffer B’sVkBufferdescriptor. - Leak: The GPU reads out-of-bounds from Buffer B’s memory pool. The shader processes this data, and the attacker reads it back via
gl.readPixels, leaking cross-origin GPU memory.
Suggested Fix
In third_party/angle/src/libANGLE/Context.cpp, update Context::onUniformBlockBindingUpdated to properly flag the binding as changed:
void Context::onUniformBlockBindingUpdated(GLuint uniformBlockIndex)
{
mState.mDirtyBits.set(state::DIRTY_BIT_UNIFORM_BUFFER_BINDINGS);
mState.mDirtyUniformBlocks.set(uniformBlockIndex);
// Add this line:
mState.mUniformBufferBlocksDirtyTypeMask.set(gl::BufferDirtyType::Binding);
mStateCache.onUniformBufferStateChange(this);
}
Evaluated with Chrome root at commit: f200f57a19490707ff8bc7aa5de3cbc443a3afad
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.