Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in ANGLE
DescriptionOut of bounds read in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker500472605
Fix commit059d08299b2d (angle/angle) +109/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Files Changed

  • src/libANGLE/renderer/metal/BufferMtl.h
  • src/libANGLE/renderer/metal/BufferMtl.mm
  • src/libANGLE/renderer/metal/ProgramExecutableMtl.h
  • src/libANGLE/renderer/metal/ProgramExecutableMtl.mm
  • src/tests/gl_tests/UniformBufferTest.cpp
From 059d08299b2d8935df718f3fac801616203823f7 Mon Sep 17 00:00:00 2001
From: Le Hoang Quyen <lehoangquyen@chromium.org>
Date: Tue, 21 Apr 2026 19:49:58 +0800
Subject: [PATCH] Metal: Add program serial ID to uniform conversion buffer cache key

This change adds a unique serial ID to each ProgramExecutableMtl, which
is now included in the UniformConversionBufferMtl cache lookup. This
prevents incorrect reuse of conversion buffers when different programs
share the same GL buffer but have different UBO layout requirements
(e.g., row-major mat4 vs. vec4[4]).

The uniform conversion buffer cache in BufferMtl is also transitioned
from a std::vector to a std::deque with a maximum size of 32 to ensure
better performance and memory usage. The max size is included to avoid
retaining deleted programs' data in the cache indefinitely.

Bug: angleproject:500472605
Bug: angleproject:500942658
Change-Id: Iafff85b0a250ad155f5ca808879ed786dd59d64d
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7742760
Reviewed-by: Geoff Lang <geofflang@chromium.org>
Commit-Queue: Quyen Le <lehoangquyen@chromium.org>
Auto-Submit: Quyen Le <lehoangquyen@google.com>
Reviewed-by: Quyen Le <lehoangquyen@chromium.org>
---

diff --git a/src/libANGLE/renderer/metal/BufferMtl.h b/src/libANGLE/renderer/metal/BufferMtl.h
index 3d47421..290aadf 100644
--- a/src/libANGLE/renderer/metal/BufferMtl.h
+++ b/src/libANGLE/renderer/metal/BufferMtl.h
@@ -12,6 +12,7 @@
 
 #import <Metal/Metal.h>
 
+#include <deque>
 #include <optional>
 #include <utility>
 
@@ -84,11 +85,13 @@
 struct UniformConversionBufferMtl : public ConversionBufferMtl
 {
     UniformConversionBufferMtl(ContextMtl *context,
+                               uint64_t programSerialIdIn,
                                std::pair<size_t, size_t> offsetIn,
                                size_t blockSize);
 
     size_t initialSrcOffset() { return offset.second; }
 
+    const uint64_t programSerialId;
     const size_t uniformBufferBlockSize;
     const std::pair<size_t, size_t> offset;
 };
@@ -180,6 +183,7 @@
                                                        size_t offset);
 
     ConversionBufferMtl *getUniformConversionBuffer(ContextMtl *context,
+                                                    uint64_t programSerialId,
                                                     std::pair<size_t, size_t> offset,
                                                     size_t blockSize);
 
@@ -246,7 +250,8 @@
 
     std::vector<IndexConversionBufferMtl> mIndexConversionBuffers;
 
-    std::vector<UniformConversionBufferMtl> mUniformConversionBuffers;
+    // TODO(crbug.com/500942658): Consider using LRU cache
+    std::deque<UniformConversionBufferMtl> mUniformConversionBuffers;
 
     struct RestartRangeCache
     {
diff --git a/src/libANGLE/renderer/metal/BufferMtl.mm b/src/libANGLE/renderer/metal/BufferMtl.mm
index 8f1de35..3206158 100644
--- a/src/libANGLE/renderer/metal/BufferMtl.mm
+++ b/src/libANGLE/renderer/metal/BufferMtl.mm
@@ -84,9 +84,11 @@
 
 // UniformConversionBufferMtl implementation
 UniformConversionBufferMtl::UniformConversionBufferMtl(ContextMtl *context,
+                                                       uint64_t programSerialIdIn,
                                                        std::pair<size_t, size_t> offsetIn,
                                                        size_t uniformBufferBlockSize)
     : ConversionBufferMtl(context, 0, mtl::kUniformBufferSettingOffsetMinAlignment),
+      programSerialId(programSerialIdIn),
       uniformBufferBlockSize(uniformBufferBlockSize),
       offset(offsetIn)
 {}
@@ -411,12 +413,14 @@
 }
 
 ConversionBufferMtl *BufferMtl::getUniformConversionBuffer(ContextMtl *context,
+                                                           uint64_t programSerialId,
                                                            std::pair<size_t, size_t> offset,
                                                            size_t stdSize)
 {
     for (UniformConversionBufferMtl &buffer : mUniformConversionBuffers)
     {
-        if (buffer.offset.first == offset.first && buffer.uniformBufferBlockSize == stdSize)
+        if (buffer.programSerialId == programSerialId && buffer.offset.first == offset.first &&
+            buffer.uniformBufferBlockSize == stdSize)
         {
             if (buffer.offset.second <= offset.second &&
                 (offset.second - buffer.offset.second) % buffer.uniformBufferBlockSize == 0)
@@ -424,7 +428,13 @@
         }
     }
 
-    mUniformConversionBuffers.emplace_back(context, offset, stdSize);
+    constexpr size_t kMaxCacheSize = 32;
+    if (mUniformConversionBuffers.size() >= kMaxCacheSize)
+    {
+        mUniformConversionBuffers.pop_front();
+    }
+
+    mUniformConversionBuffers.emplace_back(context, programSerialId, offset, stdSize);
     return &mUniformConversionBuffers.back();
 }
 
diff --git a/src/libANGLE/renderer/metal/ProgramExecutableMtl.h b/src/libANGLE/renderer/metal/ProgramExecutableMtl.h
index d5843a0..77353c0 100644
--- a/src/libANGLE/renderer/metal/ProgramExecutableMtl.h
+++ b/src/libANGLE/renderer/metal/ProgramExecutableMtl.h
@@ -292,6 +292,10 @@
     uint32_t mShadowCompareModes[mtl::kMaxShaderSamplers];
 
     gl::ShaderMap<std::unique_ptr<mtl::BufferPool>> mDefaultUniformBufferPools;
+
+    // A unique ID to identify this program executable across its lifetime. It's guaranteed that
+    // there is no duplicated ID ever even if the program is deleted.
+    Serial mProgramSerialId;
 };
 
 angle::Result CreateMslShaderLib(mtl::Context *context,
diff --git a/src/libANGLE/renderer/metal/ProgramExecutableMtl.mm b/src/libANGLE/renderer/metal/ProgramExecutableMtl.mm
index ceac4b0..4435b2f 100644
--- a/src/libANGLE/renderer/metal/ProgramExecutableMtl.mm
+++ b/src/libANGLE/renderer/metal/ProgramExecutableMtl.mm
@@ -24,6 +24,12 @@
 {
 #define SHADER_ENTRY_NAME @"main0"
 
+Serial GenerateProgramSerialId()
+{
+    static AtomicSerialFactory gProgramSerialFactory;
+    return gProgramSerialFactory.generate();
+}
+
 bool CompareBlockInfo(const sh::BlockMemberInfo &a, const sh::BlockMemberInfo &b)
 {
     return a.offset < b.offset;
@@ -378,7 +384,10 @@
 DefaultUniformBlockMtl::~DefaultUniformBlockMtl() = default;
 
 ProgramExecutableMtl::ProgramExecutableMtl(const gl::ProgramExecutable *executable)
-    : ProgramExecutableImpl(executable), mProgramHasFlatAttributes(false), mShadowCompareModes{}
+    : ProgramExecutableImpl(executable),
+      mProgramHasFlatAttributes(false),
+      mShadowCompareModes{},
+      mProgramSerialId(GenerateProgramSerialId())
 {
     mCurrentShaderVariants.fill(nullptr);
 
@@ -1304,8 +1313,8 @@
 
             UniformConversionBufferMtl *conversion =
                 (UniformConversionBufferMtl *)bufferMtl->getUniformConversionBuffer(
-                    context, std::pair<size_t, size_t>(bufferIndex, srcOffset),
-                    conversionInfo.stdSize());
+                    context, mProgramSerialId.getValue(),
+                    std::pair<size_t, size_t>(bufferIndex, srcOffset), conversionInfo.stdSize());
             // Has the content of the buffer has changed since last conversion?
             if (conversion->dirty)
             {
diff --git a/src/tests/gl_tests/UniformBufferTest.cpp b/src/tests/gl_tests/UniformBufferTest.cpp
index 0041c10..36d5d79 100644
--- a/src/tests/gl_tests/UniformBufferTest.cpp
+++ b/src/tests/gl_tests/UniformBufferTest.cpp
@@ -4913,6 +4913,81 @@
     ASSERT_GL_NO_ERROR();
 }
 
+// Test that reusing the same uniform buffer for two different programs where the Metal
+// layout results in different sizes (e.g. mat2[64] vs row_major mat4[32]) works correctly.
+// See crbug.com/500472605 for more details.
+// Both have std140 size of 2048 bytes.
+// mat2[64]: std140 is 64 * 32 = 2048 bytes, Metal is 64 * 16 = 1024 bytes.
+// mat4[32]: std140 is 32 * 64 = 2048 bytes, Metal is 32 * 64 = 2048 bytes.
+TEST_P(UniformBufferTest, SameBufferDifferentMetalSize)
+{
+    // Program A: mat2[64] (stdSize 2048, metalSize 1024)
+    const char *kVS  = essl3_shaders::vs::Simple();
+    const char *kFSA = R"(#version 300 es
+precision highp float;
+layout(std140) uniform block {
+    mat2 m[64];
+} ubo;
+out vec4 fragColor;
+void main()
+{
+    fragColor = vec4(ubo.m[63][0], 0.0, 1.0);
+})";
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 0041c10..36d5d79 100644
--- a/src/tests/gl_tests/UniformBufferTest.cpp
+++ b/src/tests/gl_tests/UniformBufferTest.cpp
@@ -4913,6 +4913,81 @@
     ASSERT_GL_NO_ERROR();
 }
 
+// Test that reusing the same uniform buffer for two different programs where the Metal
+// layout results in different sizes (e.g. mat2[64] vs row_major mat4[32]) works correctly.
+// See crbug.com/500472605 for more details.
+// Both have std140 size of 2048 bytes.
+// mat2[64]: std140 is 64 * 32 = 2048 bytes, Metal is 64 * 16 = 1024 bytes.
+// mat4[32]: std140 is 32 * 64 = 2048 bytes, Metal is 32 * 64 = 2048 bytes.
+TEST_P(UniformBufferTest, SameBufferDifferentMetalSize)
+{
+    // Program A: mat2[64] (stdSize 2048, metalSize 1024)
+    const char *kVS  = essl3_shaders::vs::Simple();
+    const char *kFSA = R"(#version 300 es
+precision highp float;
+layout(std140) uniform block {
+    mat2 m[64];
+} ubo;
+out vec4 fragColor;
+void main()
+{
+    fragColor = vec4(ubo.m[63][0], 0.0, 1.0);
+})";
+
+    // Program B: row_major mat4[32] (stdSize 2048, metalSize 2048)
+    const char *kFSB = R"(#version 300 es
+precision highp float;
+layout(std140, row_major) uniform block {
+    mat4 m[32];
+} ubo;
+out vec4 fragColor;
+void main()
+{
+    fragColor = ubo.m[30][0];
+})";
+
+    ANGLE_GL_PROGRAM(programA, kVS, kFSA);
+    ANGLE_GL_PROGRAM(programB, kVS, kFSB);
+
+    glUniformBlockBinding(programA, glGetUniformBlockIndex(programA, "block"), 0);
+    glUniformBlockBinding(programB, glGetUniformBlockIndex(programB, "block"), 0);
+
+    GLBuffer buffer;
+    glBindBuffer(GL_UNIFORM_BUFFER, buffer);
+
+    // 2048 bytes of data. (512 floats)
+    std::vector<float> data(512, 0.0f);
+    // Program A: m[63][0] is at offset 63 * 32 = 2016. (r0c0, r1c0)
+    // float index = 2016 / 4 = 504.
+    data[504] = 1.0f;
+    data[505] = 0.5f;
+
+    // Program B: m[30] starts at offset 30 * 64 = 1920.
+    // m[30][0] is the first column of the 31st matrix.
+    // In row-major, m[30][0] = (r0c0, r1c0, r2c0, r3c0) of m[30].
+    // Float indices (offset / 4):
+    // r0c0: 1920 / 4 = 480
+    // r1c0: 1936 / 4 = 484
+    // r2c0: 1952 / 4 = 488
+    // r3c0: 1968 / 4 = 492
+    data[480] = 0.2f;
+    data[484] = 0.1f;
+    data[488] = 0.7f;
+    data[492] = 0.4f;
+
+    glBufferData(GL_UNIFORM_BUFFER, data.size() * sizeof(float), data.data(), GL_STATIC_DRAW);
+    glBindBufferBase(GL_UNIFORM_BUFFER, 0, buffer);
+
+    // Program A: mat2[64]. Should see m[63][0] = (1.0, 0.5)
+    drawQuad(programA, essl3_shaders::PositionAttrib(), 0.5f);
+    EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(255, 127, 0, 255), 1);
+
+    // Program B: row_major mat4[32]. Should see m[30][0] = (0.2, 0.1, 0.7, 0.4)
+    // If it incorrectly reuses Program A's 1024-byte buffer, it will go OOB.
+    drawQuad(programB, essl3_shaders::PositionAttrib(), 0.5f);
+    EXPECT_PIXEL_COLOR_NEAR(0, 0, GLColor(51, 25, 178, 102), 1);
+}
+
 GTEST_ALLOW_UNINSTANTIATED_PARAMETERIZED_TEST(UniformBufferTest);
 ANGLE_INSTANTIATE_TEST_ES3(UniformBufferTest);
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential OOB GPU Read via ANGLE Metal UBO Conversion Cache Confusion

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 Metal backend allows different WebGL programs to erroneously share a cached Uniform Buffer Object (UBO) conversion. The cache key fails to include the Metal-specific layout size, causing out-of-bounds offset calculations when a program with a larger layout reuses a buffer allocated for a smaller one. This could potentially allow an attacker to read cross-origin GPU memory using a malicious shader.

Affected files:

  • third_party/angle/src/libANGLE/renderer/metal/BufferMtl.mm
  • third_party/angle/src/libANGLE/renderer/metal/ProgramExecutableMtl.mm

Estimated timestamp from git blame: 2023-11-06

Summary

A potential out-of-bounds (OOB) GPU memory read exists in ANGLE’s Metal backend. The vulnerability stems from a cache confusion flaw in BufferMtl::getUniformConversionBuffer, where converted Uniform Buffer Objects (UBOs) are cached without accounting for their Metal-specific layout sizes (metalSize). This allows an attacker to trick the backend into using an undersized MTLBuffer with an out-of-bounds offset, potentially leaking cross-origin textures or other sensitive data from the GPU heap.

Root Cause Analysis

When a GLSL uniform block requires layout conversion for Metal (e.g., due to matrix padding or std140 rules), the result is stored in a dynamic buffer pool. The cache key used to reuse these buffers is comprised of only the bufferIndex, srcOffset, and the standard GLSL layout size (stdSize).

Crucially, the key omits metalSize, which can vary significantly for the same stdSize. For example, a vec3 takes 12 bytes in std140 (allowing subsequent scalars to pack tightly) but requires a full 16 bytes in Metal. This allows different uniform structures to have the same stdSize but drastically different metalSizes.

If a program with a large metalSize reuses a cached buffer generated by a program with a small metalSize, ProgramExecutableMtl::legalizeUniformBufferOffsets skips conversion and calculates a physical offset into the cached buffer:

size_t bytesToOffset = numBlocksToOffset * conversionInfo.metalSize();

Because metalSize is much larger than what the cached buffer was allocated for, bytesToOffset points out-of-bounds. An ASSERT macro designed to catch this bounds violation is compiled out in Chrome’s Release builds. The out-of-bounds offset is passed directly to the Metal API ([MTLRenderCommandEncoder setVertexBuffer:offset:atIndex:]), which lacks runtime bounds checking in production, leading to out-of-bounds reads during shader execution.

Potential Reproduction Steps

Note: These are suggested steps based on code analysis, as our tooling agent cannot execute a live proof-of-concept.

  1. An attacker creates a WebGL 2 context on macOS (defaulting to the Metal backend).
  2. The attacker compiles Program A with a uniform block requiring conversion (e.g., containing layout(row_major) mat4), where stdSize matches its metalSize (e.g., 256 bytes).
  3. The attacker compiles Program B with a uniform block where stdSize is also 256 bytes, but metalSize is much larger (e.g., 512 bytes due to vec3 packing differences).
  4. The attacker allocates a large WebGL buffer and binds it using gl.bindBufferRange(..., offset=0).
  5. They execute a draw call with Program A. This allocates and populates a conversion buffer sized for metalSize = 256.
  6. The attacker binds the same WebGL buffer at a high offset (e.g., offset = max_size - 256) and executes a draw call with Program B.
  7. The cache matches on stdSize and offset alignment, improperly returning Program A’s smaller buffer.
  8. The physical offset calculated for Program B uses its 512-byte metalSize, pushing the read pointer far past the end of the allocated MTLBuffer.
  9. The shader reads the leaked adjacent GPU heap memory, outputs it to the canvas, and the attacker retrieves it via gl.readPixels().

Suggested Fix

Update BufferMtl::getUniformConversionBuffer to include metalSize (and ideally, a hash of the full UBOConversionInfo layout or stdSize vs metalSize ratio) in its cache key criteria. This will ensure that uniform blocks with different Metal layouts cannot alias the same converted buffer.

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