Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ANGLE
DescriptionInappropriate implementation in ANGLE
ComponentANGLE
Bug ClassLogic Error
Tracker500052361
Fix commita812b1d2c8f5 (angle/angle) +67/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-12

Files Changed

  • src/libANGLE/Context.cpp
  • src/tests/gl_tests/UniformBufferTest.cpp
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);
 
Loading diff…

Regression Test / PoC

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

Original Bug Report

reported by [email protected]

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.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/ContextVk.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/vk_cache_utils.cpp
  • third_party/angle/src/libANGLE/renderer/vulkan/ProgramExecutableVk.cpp
  • third_party/angle/src/libANGLE/State.cpp
  • third_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.)

  1. Setup: Create a WebGL2 context. Compile a shader program using multiple active std140 uniform blocks. Keep the number of active blocks small (e.g., <= 6) to ensure ProgramExecutableVk::usesDynamicUniformBufferDescriptors() returns true.
  2. 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.
  3. Sync: Issue gl.drawArrays to synchronize state and clear the dirty masks in the backend.
  4. Set Offset Mask: Call gl.bindBufferRange on binding index 0, changing only the offset. This sets mUniformBufferBlocksDirtyTypeMask to exactly {Offset}.
  5. Trigger Bug: Call gl.uniformBlockBinding(program, 1, 5) to remap uniform block 1 from binding 1 (Buffer B) to binding 5 (Buffer C). Context::onUniformBlockBindingUpdated fails to update the mask, leaving it as {Offset}.
  6. Exploit: Issue another gl.drawArrays. ContextVk::syncState takes the fast path. It applies Buffer C’s massive dynamic offset to Buffer B’s VkBuffer descriptor.
  7. 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.

View on issue tracker