High watch CVSS 3.1 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in ANGLE
DescriptionInappropriate implementation in ANGLE
ComponentANGLE
Sub-componentVulkan
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
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.
Tracker500052361
Fix commita812b1d2c8f5 (angle/angle) +67/-0
CWECWE-119, CWE-284 (Buffer bounds error)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-06 (per vendor advisory)
Fix landed2026-04-08 (public git)
Disclosed2026-05-12
Patch gap34 days — fix public in source before the advisory

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) via glBindBufferRange.
Dirty-bit / dirty-type mask
ANGLE tracks changed state with dirty bits so the backend re-syncs only what changed; mUniformBufferBlocksDirtyTypeMask records 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.

Key insight
A uniform-block binding change updated the generic dirty bits but failed to set 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

  1. 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).
  2. Trigger the offset-only fast path glBindBufferRange changes only a binding’s offset, which the Vulkan backend handles via the offset-only fast path without recomputing the index remap.
  3. Remap a block glUniformBlockBinding repoints a uniform block to a different binding point; pre-patch this did not set BufferDirtyType::Binding, so the stale index remap persisted.
  4. 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

Per the vendor advisory this is a high-severity inappropriate-implementation/logic error in ANGLE: a page using WebGL can cause a uniform block to read from a stale/incorrect buffer binding after an offset-only update followed by a block rebind, in the GPU process. Whether this escalates to an out-of-bounds read of other buffer contents depends on the stale mapping and is inferred; the advisory does not report in-the-wild exploitation.

Files Changed

  • src/libANGLE/Context.cpp
  • src/tests/gl_tests/UniformBufferTest.cpp

Audit Directions

  • Split dirty-state tracking
    Where 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 state
    Review 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 remaps
    Check other glUniformBlockBinding/glShaderStorageBlockBinding/glBindBufferRange paths for the same dirty-type coverage, so block-to-binding index remaps are always rebuilt after offset-only updates.
From a812b1d2c8f5e2223a56cdc9d346fb2af1e40235 Mon Sep 17 00:00:00 2001
From: Shahbaz Youssefi <syoussefi@chromium.org>
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 <cclao@google.com>
Commit-Queue: Shahbaz Youssefi <syoussefi@chromium.org>
---

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 vm...@google.com

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