High watch ✔︎ verified CVSS 8.1 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in ANGLE
DescriptionOut of bounds read in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar — upheld by independent review by AI
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.
Independent review by AIConfirmed
Reviewer notes: offset.first is the binding index (bufferIndex) per the call site, not a buffer offset. 'uncommon' preconditions overstated: both programs and the shared buffer are attacker-supplied, as in the regression test; only the Metal backend is affected.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker500472605
Fix commit059d08299b2d (angle/angle) +109/-6
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-07 (per vendor advisory)
Fix landed2026-04-21 (public git)
Disclosed2026-06-02
Patch gap42 days — fix public in source before the advisory

Background

`UniformConversionBufferMtl`
A cache entry in BufferMtl that holds a converted copy of a GL uniform buffer laid out to match a specific program’s uniform-block (UBO) requirements.
UBO layout conversion
ANGLE re-packs a GL buffer’s bytes into the layout the Metal shader expects (e.g. std140 row-major mat4 vs. vec4[4]), and caches the result keyed by offset and block size.
`Serial` / `AtomicSerialFactory`
An ANGLE monotonic ID generator whose generate() yields values that are never reused, even after the owning object is destroyed.
`getUniformConversionBuffer`
The BufferMtl lookup that returns an existing conversion buffer matching a request or allocates a new one.

Root Cause Analysis

When a draw call needed a UBO converted to a program’s layout, BufferMtl::getUniformConversionBuffer searched mUniformConversionBuffers using only the buffer offset.first and the uniformBufferBlockSize (stdSize) as the cache key. Two different programs (or two different ProgramExecutableMtl instances, including ones that share the same underlying GL buffer) can request the same offset and block size yet require completely different byte layouts, so a cached entry produced for one program would be handed to another whose shader interprets those bytes differently. Because the converted buffer’s contents and effective bounds were computed for the original program’s layout, the mismatched consumer could read past the region that was actually valid for its own layout, producing an out-of-bounds read.

The fix adds a per-program mProgramSerialId (a never-reused Serial from GenerateProgramSerialId) to both the UniformConversionBufferMtl entry and the getUniformConversionBuffer key, so an entry is reused only by the exact program that created it. The serial’s uniqueness-forever guarantee also prevents a freshly created program from colliding with a deleted program’s stale cache entry, and capping mUniformConversionBuffers at 32 with a std::deque pop_front bounds stale-entry retention.

Key insight
The single mistake was keying the uniform-conversion-buffer cache on offset and block size alone, ignoring which program’s layout the converted bytes were built for; the fix adds a globally unique program serial ID to the cache key so layout-incompatible reuse can no longer occur.

Attack Path

  1. Create two divergent UBO programs An attacker (e.g. via WebGL) compiles two GLSL programs that consume the same uniform block at the same binding offset but with layouts that convert to different sizes/strides, such as row-major mat4 versus vec4[4].
  2. Prime the cache Draw with the first program so getUniformConversionBuffer allocates a UniformConversionBufferMtl keyed only by offset.first and stdSize.
  3. Trigger mismatched reuse Draw with the second program against the same GL buffer offset and block size; the pre-fix key matches and the stale conversion buffer sized for the first layout is returned. (inferred)
  4. Out-of-bounds read The second program’s shader/consumer interprets the returned buffer under its own larger/differently-strided layout and reads beyond the valid converted region. (inferred)

Impact Assessment

An attacker who can submit GL/WebGL draw calls gains an out-of-bounds read of GPU-adjacent buffer memory inside the ANGLE Metal backend, which on Chrome runs in the sandboxed GPU process. Preconditions are the ability to compile two programs with incompatible UBO layouts sharing a buffer offset/block size and to issue draws that exercise the shared conversion-buffer cache. The advisory classifies this as an out-of-bounds read in ANGLE and states no exploited-in-the-wild status; any escalation of the leaked data toward information disclosure or further compromise is (inferred).

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

Audit Directions

  • Incomplete cache keys
    Audit other ANGLE conversion/scratch caches (e.g. mIndexConversionBuffers, vertex/attribute conversion) whose keys omit the consuming program or pipeline state that determines the converted layout.
  • Cross-program buffer aliasing
    Look for paths where multiple ProgramExecutableMtl instances or shader variants share a GL buffer yet assume a single canonical layout for cached conversions.
  • Reused identity after deletion
    Review caches keyed on pointers or indices that can be recycled after object deletion, and prefer never-reused Serial identifiers as done here.
From 059d08299b2d8935df718f3fac801616203823f7 Mon Sep 17 00:00:00 2001
From: Le Hoang Quyen <[email protected]>
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 <[email protected]>
Commit-Queue: Quyen Le <[email protected]>
Auto-Submit: Quyen Le <[email protected]>
Reviewed-by: Quyen Le <[email protected]>
---

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 [email protected]

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
Uploaded PoC files