Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in ANGLE
DescriptionHeap buffer overflow in ANGLE
ComponentANGLE
Bug ClassOOB
Tracker489494022
Fix commit74b9ebf70420 (angle/angle) +11/-10
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-31

Files Changed

  • src/libANGLE/renderer/metal/BufferMtl.mm
From 74b9ebf704200aeec307b729ae396764638730c9 Mon Sep 17 00:00:00 2001
From: Geoff Lang <[email protected]>
Date: Mon, 16 Mar 2026 07:19:14 -0700
Subject: [PATCH] Reland "Metal: Use the mtl::Buffer's size when syncing shadow data."

Move buffer size rounding logic to the common allocation code so that
shadow data always matches the size of the Metal buffer.

This reverts commit 1210ebe5f0d393b1abfcb7b67f1af967f0fadb83.

Original change's description:
> Revert "Metal: Use the mtl::Buffer's size when syncing shadow data."
>
> This reverts commit 7a68f0166454119af163c1e08cd3a6c9e61bc6ee.
>
> Reason for revert: Assertion failure
>
> Original change's description:
> > Metal: Use the mtl::Buffer's size when syncing shadow data.
> >
> > When syncing data to the shadow copy during a buffer resize calculation,
> > BufferMtl::size will return the previous size of the buffer since it
> > queries the buffer's frontend state.
> >
> > Use the size of the actual internal buffer and assert that the shadow
> > buffer has been updated to match already.
> >
> > Bug: chromium:489494022
> > Change-Id: Ica3763a3f3ca8e78150295794679b51bba863ca8
> > Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7656589
> > Commit-Queue: Geoff Lang <[email protected]>
> > Reviewed-by: Kenneth Russell <[email protected]>
>
> Bug: chromium:489494022
> No-Presubmit: true
> No-Tree-Checks: true
> No-Try: true
> Change-Id: I4d367a2fa99aa63eae1c1a9100acb72b951ad240
> Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7662487
> Reviewed-by: Kenneth Russell <[email protected]>
> Bot-Commit: Rubber Stamper <[email protected]>
> Commit-Queue: Geoff Lang <[email protected]>

Bug: chromium:489494022
Change-Id: Id1057d578ee80f66c51deae3ceadb511911a3b7f
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7665314
Reviewed-by: Kenneth Russell <[email protected]>
Commit-Queue: Geoff Lang <[email protected]>
---

diff --git a/src/libANGLE/renderer/metal/BufferMtl.mm b/src/libANGLE/renderer/metal/BufferMtl.mm
index 3afbb45..7c76953 100644
--- a/src/libANGLE/renderer/metal/BufferMtl.mm
+++ b/src/libANGLE/renderer/metal/BufferMtl.mm
@@ -339,7 +339,9 @@
     if (mBuffer->isCPUReadMemDirty())
     {
         const uint8_t *ptr = mBuffer->mapReadOnly(contextMtl);
-        memcpy(mShadowCopy.data(), ptr, size());
+        ASSERT(mShadowCopy.size() == mBuffer->size());
+        // Copy based on the shadow buffer's size, don't copy the extra padding bytes.
+        memcpy(mShadowCopy.data(), ptr, mBuffer->size());
         mBuffer->unmap(contextMtl);
 
         mBuffer->resetCPUReadMemDirty();
@@ -524,6 +526,10 @@
                                                 bool returnOldBufferImmediately,
                                                 BufferFeedback *feedback)
 {
+    // Ensures no validation layer issues in std140 with data types like vec3 being 12 bytes vs 16
+    // in MSL. Many buffer types can be bound as a uniform buffer, so align all buffer sizes.
+    const size_t adjustedSize = roundUpPow2(std::max<size_t>(1, size), size_t(16));
+
     mtl::BufferManager &bufferManager = contextMtl->getBufferManager();
     if (returnOldBufferImmediately && mBuffer)
     {
@@ -532,7 +538,7 @@
         bufferManager.returnBuffer(contextMtl, mBuffer);
         mBuffer = nullptr;
     }
-    ANGLE_TRY(bufferManager.getBuffer(contextMtl, storageMode, size, mBuffer));
+    ANGLE_TRY(bufferManager.getBuffer(contextMtl, storageMode, adjustedSize, mBuffer));
 
     feedback->internalMemoryAllocationChanged = true;
 
@@ -561,15 +567,10 @@
 
     mUsage              = usage;
     mGLSize             = intendedSize;
-    size_t adjustedSize = std::max<size_t>(1, intendedSize);
-
-    // Ensures no validation layer issues in std140 with data types like vec3 being 12 bytes vs 16
-    // in MSL. Many buffer types can be bound as a uniform buffer, so align all buffer sizes.
-    adjustedSize = roundUpPow2(adjustedSize, (size_t)16);
 
     // Re-create the buffer
     auto storageMode = mtl::Buffer::getStorageModeForUsage(contextMtl, usage);
-    ANGLE_TRY(allocateNewMetalBuffer(contextMtl, storageMode, adjustedSize,
+    ANGLE_TRY(allocateNewMetalBuffer(contextMtl, storageMode, intendedSize,
                                      /*returnOldBufferImmediately=*/true, feedback));
 
 #ifndef NDEBUG
@@ -582,8 +583,8 @@
     // We may use shadow copy to maintain consistent data between buffers in pool
     size_t shadowSize = (!features.preferCpuForBuffersubdata.enabled &&
                          features.useShadowBuffersWhenAppropriate.enabled &&
-                         adjustedSize <= mtl::kSharedMemBufferMaxBufSizeHint)
-                            ? adjustedSize
+                         mBuffer->size() <= mtl::kSharedMemBufferMaxBufSizeHint)
+                            ? mBuffer->size()
                             : 0;
     ANGLE_CHECK_GL_ALLOC(contextMtl, mShadowCopy.resize(shadowSize));
 
Loading diff…

Original Bug Report

reported by [email protected]

Heap buffer overflow in BufferMtl shadow copy sync during bufferData leads to GPU process memory corruption in macOS

Heap buffer overflow in BufferMtl shadow copy sync during bufferData leads to GPU process memory corruption in macOS

Summary

A heap buffer overflow exists in the ANGLE Metal backend’s BufferMtl::ensureShadowCopySyncedFromGPU function. When a WebGL bufferData call shrinks a buffer, the function copies data using the stale (larger) buffer size from mState.getSize(), which has not yet been updated by the caller gl::Buffer::bufferDataImpl. If the newly allocated Metal buffer was recycled from the BufferManager free list with its cpuReadMemDirty flag still set from a prior GPU write, the sync path executes a memcpy that writes up to 512 KB beyond the bounds of the freshly allocated 64-byte shadow copy. The overflow occurs in the GPU process, which on macOS runs outside the renderer sandbox. The vulnerability affects macOS with the Metal backend and is naturally reachable on systems with Intel integrated GPUs. An attacker-controlled web page can trigger it through ordinary WebGL2 API calls.

Bisect

Introducing Commit: 968041b54770af8917001d8fe9b52a881cfed0b2

  • Date: 2022-08-19
  • Author: Gregg Tavares
  • CL: “Metal: Optimized BufferSubData per device”

The vulnerable ensureShadowCopySyncedFromGPU function with its memcpy(mShadowCopy.data(), ptr, size()) pattern was introduced earlier in commit bdecaf33eb (2020-08-04, “Metal: Implement PBO”). However, the bug only became exploitable when the BufferManager buffer recycling mechanism was added in 968041b547. Before this commit, Metal buffers were managed through a BufferPool that did not recycle buffers with stale dirty flags across unrelated GL buffer objects.

Root Cause

The vulnerability arises from a timing mismatch between when ANGLE’s backend implementation reads the GL buffer size and when the GL layer updates it. In gl::Buffer::bufferDataImpl, the backend’s setData is invoked before mState.mSize is written:

// third_party/angle/src/libANGLE/Buffer.cpp
ANGLE_TRY(setDataWithUsageFlags(context, target, nullptr, dataForImpl, size, usage, flags,
                                bufferStorage));
// ...
mState.mSize = size;  // updated AFTER setData returns

Inside BufferMtl::setDataImpl, a new Metal buffer is obtained from the BufferManager, the shadow copy is resized to match the new (smaller) intended size, and then setSubDataImpl is called to populate it:

// third_party/angle/src/libANGLE/renderer/metal/BufferMtl.mm
ANGLE_TRY(allocateNewMetalBuffer(contextMtl, storageMode, adjustedSize,
                                 /*returnOldBufferImmediately=*/true, feedback));
ANGLE_CHECK_GL_ALLOC(contextMtl, mShadowCopy.resize(shadowSize));
if (data)
{
    ANGLE_TRY(setSubDataImpl(context, data, intendedSize, 0, feedback));
}

The call to setSubDataImpl reaches updateShadowCopyThenCopyShadowToNewBuffer, which first calls ensureShadowCopySyncedFromGPU to bring the shadow copy up to date with any prior GPU modifications:

// third_party/angle/src/libANGLE/renderer/metal/BufferMtl.mm
void BufferMtl::ensureShadowCopySyncedFromGPU(ContextMtl *contextMtl)
{
    if (mBuffer->isCPUReadMemDirty())
    {
        const uint8_t *ptr = mBuffer->mapReadOnly(contextMtl);
        memcpy(mShadowCopy.data(), ptr, size());
        mBuffer->unmap(contextMtl);
        mBuffer->resetCPUReadMemDirty();
    }
}

The size() accessor returns static_cast<size_t>(mState.getSize()), which at this point still holds the old, larger value. Meanwhile, mShadowCopy has just been resized to the new, smaller value. The memcpy therefore writes far beyond the shadow copy’s allocation.

The second ingredient is the BufferManager’s buffer recycling. When getBuffer finds a cached buffer of the requested size, it returns it without clearing the resource usage flags:

// third_party/angle/src/libANGLE/renderer/metal/mtl_buffer_manager.mm
auto iter = freeBuffers.find(size);
if (iter != freeBuffers.end())
{
    bufferRef = iter->second;  // cpuReadMemDirty is NOT reset
    freeBuffers.erase(iter);
    return angle::Result::Continue;
}

A buffer that was previously the target of a GPU blit operation will have cpuReadMemDirty set to true via Resource::setUsedByCommandBufferWithQueueSerial. When such a buffer is recycled into an unrelated GL buffer object, ensureShadowCopySyncedFromGPU sees the stale dirty flag as a legitimate sync request and executes the oversized memcpy.

An attacker can manufacture this condition entirely from JavaScript. First, create a target buffer A with a large size (above the 256 KB shadow copy threshold) to establish a large mState.mSize. Then, create a helper buffer B at the desired small size, force a GPU blit write to B (by issuing copyBufferSubData while B is referenced by an in-flight draw command), wait for the GPU to finish, and return B’s Metal buffer to the free list by reallocating B with a different size. Finally, call bufferData on A with the small size and provide data. The BufferManager recycles B’s dirty buffer for A, the shadow copy is freshly allocated at the small size, and the memcpy uses A’s old large size, producing a controlled heap overflow.

Reproduce

This reproduction was tested on Chromium commit d0f83d769eeed (March 2026) on macOS with the ANGLE Metal backend. The vulnerable code path requires the useShadowBuffersWhenAppropriate ANGLE feature to be enabled. In DisplayMtl.mm, this feature is gated on the GPU vendor:

// third_party/angle/src/libANGLE/renderer/metal/DisplayMtl.mm
ANGLE_FEATURE_CONDITION((&mFeatures), alwaysUseSharedStorageModeForBuffers, isIntel());
ANGLE_FEATURE_CONDITION((&mFeatures), useShadowBuffersWhenAppropriate, isIntel());

When shadow buffers are enabled, BufferMtl::setDataImpl allocates a CPU-side shadow copy for any buffer whose adjusted size is at most 256 KB:

// third_party/angle/src/libANGLE/renderer/metal/BufferMtl.mm
size_t shadowSize = (!features.preferCpuForBuffersubdata.enabled &&
                     features.useShadowBuffersWhenAppropriate.enabled &&
                     adjustedSize <= mtl::kSharedMemBufferMaxBufSizeHint)
                        ? adjustedSize
                        : 0;

Intel Macs use shared-memory storage mode for Metal buffers, where the CPU and GPU access the same physical memory. The shadow copy mechanism was introduced to avoid stalling the GPU when the CPU needs to read back buffer contents, since shared-mode buffers do not support the managed-mode didModifyRange/synchronize synchronization model. On AMD and Apple GPUs, ANGLE uses either managed storage or staged buffer updates instead, so the shadow copy path is never taken and this vulnerability is not reachable through normal operation.

On non-Intel macOS systems (such as Apple Silicon), the feature can be force-enabled via the --enable-angle-features=useShadowBuffersWhenAppropriate command-line flag, which sets hasOverride = true on the feature and causes the ANGLE_FEATURE_CONDITION macro to skip the isIntel() check.

To prepare the build, check out the tested commit and configure an ASAN release build. Create the file out/asan-release/args.gn with the following contents:

is_asan = true
is_debug = false
dcheck_always_on = false
is_component_build = true

Then build Chrome:

ninja -C out/asan-release chrome

No source modifications are required for this PoC. Once the build completes, launch Chromium with the Metal backend and shadow buffer feature enabled:

ASAN_OPTIONS=detect_odr_violation=0 ./out/asan-release/Chromium.app/Contents/MacOS/Chromium \
  --use-angle=metal \
  --enable-angle-features=useShadowBuffersWhenAppropriate \
  --user-data-dir=/tmp/poc-angl076-$(date +%s) \
  --enable-logging=stderr \
  file://$(pwd)/issue_angl076/poc.html

The PoC page opens automatically and executes the WebGL2 trigger sequence. Within seconds, the GPU process will crash with an ASAN heap-buffer-overflow report. The ASAN summary will show a write of 524288 bytes into a 64-byte region within rx::BufferMtl::setSubDataImpl, originating from the content::GpuMain thread in the GPU process.

The vulnerability was additionally verified on a MacBook Pro (Intel Core i7-7820HQ, Intel HD Graphics 630) running a pre-built Chromium ASAN build (version 141.0.7367.0). On this Intel system, the Metal backend is the default and useShadowBuffersWhenAppropriate is natively enabled, so no additional command-line flags are needed:

ASAN_OPTIONS=detect_odr_violation=0 ./Chromium.app/Contents/MacOS/Chromium \
  --user-data-dir=/tmp/poc-angl076-$(date +%s) \
  --enable-logging=stderr \
  file:///path/to/poc.html

ASAN output (Intel Mac, no additional flags):

==58521==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x6060001ea080 at pc 0x00010b19e85a bp 0x7ff7b4fa7ec0 sp 0x7ff7b4fa7678
WRITE of size 524288 at 0x6060001ea080 thread T0
    #0 0x00010b19e859 in __asan_memcpy+0x409 (libclang_rt.asan_osx_dynamic.dylib:x86_64+0x52859)
    #1 0x00011f00500b in rx::BufferMtl::setSubDataImpl(gl::Context const*, void const*, unsigned long, unsigned long, rx::BufferFeedback*)+0x21b (libGLESv2.dylib:x86_64+0x173f00b)
    #2 0x00011f004c51 in rx::BufferMtl::setDataImpl(gl::Context const*, gl::BufferBinding, void const*, unsigned long, gl::BufferUsage, rx::BufferFeedback*)+0x371 (libGLESv2.dylib:x86_64+0x173ec51)
    #3 0x00011e0258d6 in rx::BufferImpl::setDataWithUsageFlags(gl::Context const*, gl::BufferBinding, void*, void const*, unsigned long, gl::BufferUsage, unsigned int, gl::BufferStorage, rx::BufferFeedback*)+0x56 (libGLESv2.dylib:x86_64+0x75f8d6)
    #4 0x00011dd6ae50 in gl::Buffer::setDataWithUsageFlags(gl::Context const*, gl::BufferBinding, void*, void const*, unsigned long, gl::BufferUsage, unsigned int, gl::BufferStorage)+0x140 (libGLESv2.dylib:x86_64+0x4a4e50)
    #5 0x00011dd6a812 in gl::Buffer::bufferDataImpl(gl::Context*, gl::BufferBinding, void const*, long, gl::BufferUsage, unsigned int, gl::BufferStorage)+0x1f2 (libGLESv2.dylib:x86_64+0x4a4812)
    #6 0x00011dd6acff in gl::Buffer::bufferData(gl::Context*, gl::BufferBinding, void const*, long, gl::BufferUsage)+0xf (libGLESv2.dylib:x86_64+0x4a4cff)
    #7 0x0001700e0e32 in gpu::gles2::GLES2DecoderPassthroughImpl::DoBufferData(unsigned int, long, void const*, unsigned int)+0xb2 (Chromium Framework:x86_64+0x1ac51e32)
    #8 0x000170153943 in gpu::error::Error gpu::gles2::GLES2DecoderPassthroughImpl::DoCommandsImpl<false>(unsigned int, void const volatile*, int, int*)+0x1b3 (Chromium Framework:x86_64+0x1acc4943)
    #9 0x00015def21c6 in gpu::CommandBufferService::Flush(int, gpu::AsyncAPIInterface*)+0x576 (Chromium Framework:x86_64+0x8a631c6)
    #10 0x00017029bfaa in gpu::CommandBufferStub::OnAsyncFlush(int, unsigned int, std::__Cr::vector<gpu::SyncToken, std::__Cr::allocator<gpu::SyncToken>> const&)+0x59a (Chromium Framework:x86_64+0x1ae0cfaa)
    ...
    #36 0x000173328560 in content::GpuMain(content::MainFunctionParams)+0xc10 (Chromium Framework:x86_64+0x1de99560)

0x6060001ea080 is located 0 bytes after 64-byte region [0x6060001ea040,0x6060001ea080)
allocated by thread T0 here:
    #0 0x00010b1a16b2 in __asan_memmove+0x2c22 (libclang_rt.asan_osx_dynamic.dylib:x86_64+0x556b2)
    #1 0x00011f15585a in angle::MemoryBuffer::resize(unsigned long)+0x4a (libGLESv2.dylib:x86_64+0x188f85a)
    #2 0x00011f004c2e in rx::BufferMtl::setDataImpl(gl::Context const*, gl::BufferBinding, void const*, unsigned long, gl::BufferUsage, rx::BufferFeedback*)+0x34e (libGLESv2.dylib:x86_64+0x173ec2e)
    ...

SUMMARY: AddressSanitizer: heap-buffer-overflow (libGLESv2.dylib:x86_64+0x173f00b) in rx::BufferMtl::setSubDataImpl+0x21b

The complete untruncated ASAN logs are provided in asan.log (Apple Silicon with feature flag) and asan-intel.log (Intel Mac, default configuration).

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker