Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in ANGLE
DescriptionUse after free in ANGLE
ComponentANGLE
Sub-componentMetal
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — 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: Mechanism matches the diff and commit message. Minor: the code is in the Metal backend (mtl_library_cache), so it is limited to platforms using that backend; 'GPU/renderer process' is imprecise, since ANGLE's Metal backend runs in the GPU process.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker497724490
Fix commit3bb4e2424dc4 (angle/angle) +15/-14
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSyn4pse (@ret2happy)
Reported2026-03-30 · bounty $16000 (per vendor advisory)
Fix landed2026-04-02 (public git)
Disclosed2026-05-05
Patch gap33 days — fix public in source before the advisory

Background

`LibraryCache`
ANGLE’s Metal-backend cache that maps shader source keys to compiled Metal shader libraries so identical shaders are not recompiled.
`LibraryCacheEntry`
a per-key struct holding a std::mutex lock and a compiled library, guarded so only one thread compiles a given shader at a time.
`HashingMRUCache`
a bounded most-recently-used map whose oldest entries are evicted (trimmed) once it exceeds kMaxCachedLibraries.
`angle::TrimCache`
helper that removes least-recently-used entries from the cache down to a target size, invalidating any references into the removed slots.

Root Cause Analysis

The cache stored LibraryCacheEntry objects by value inside mCache, and both getCacheEntry and its callers held a raw C++ reference (LibraryCacheEntry &entry) to the in-map object across a long, unlocked compilation region. Because getCacheEntry calls angle::TrimCache(kMaxCachedLibraries, kGCLimit, ...) and then mCache.Put(...) while only holding mCacheLock, a concurrent request could trigger trimming that evicts and destroys an entry that another thread was still compiling into. That thread then dereferenced its now-dangling reference — locking entry.lock and writing entry.library = CreateShaderLibrary(...) — a use-after-free on freed cache-entry memory, the violated invariant being that a compiling entry must outlive its compilation.

The fix changes the map to store std::shared_ptr<LibraryCacheEntry> and returns a shared owning handle from getCacheEntry, so eviction only drops the cache’s reference while any compiling thread keeps the entry alive until it is fully unreferenced.

Key insight
The core mistake was holding a raw reference to a value stored in a size-bounded, self-evicting cache across an operation that can trigger eviction from another thread; wrapping entries in std::shared_ptr decouples the entry’s lifetime from its cache membership so trimming can no longer free memory still in use.

Attack Path

  1. Force many concurrent compiles Drive the renderer to request shader compilation for more than kMaxCachedLibraries (128) distinct shaders simultaneously across multiple threads so the cache is under trimming pressure.
  2. Obtain a reference into the cache Each thread calls getShaderLibrary/getCacheEntry, receiving a raw LibraryCacheEntry & into mCache before releasing mCacheLock.
  3. Trigger eviction of an in-use entry A racing getCacheEntry call runs angle::TrimCache and mCache.Put, evicting and destroying an entry that another thread still references while compiling.
  4. Dereference the dangling entry The compiling thread writes through the freed reference (entry.lock, entry.library = CreateShaderLibrary(...)), producing the use-after-free write. (inferred)
  5. Leverage the freed-memory write Grooming the freed LibraryCacheEntry slab to gain a controlled read/write or corruption primitive is not shown by the patch and would be needed for exploitation. (inferred)

Impact Assessment

An attacker who can submit shaders (for example via WebGL/WebGPU content) to the ANGLE Metal backend can race the cache into freeing a LibraryCacheEntry still being compiled, yielding a use-after-free write in the GPU/renderer process; the precondition is concurrent compilation of more than kMaxCachedLibraries shaders. The advisory classifies this as a use-after-free in ANGLE and states no exploited-in-the-wild status here. Escalation of the freed-entry write into a controlled memory-corruption primitive or sandbox-relevant outcome is (inferred) and not demonstrated by the diff itself.

Files Changed

  • src/libANGLE/renderer/metal/mtl_library_cache.h
  • src/libANGLE/renderer/metal/mtl_library_cache.mm

Audit Directions

  • References into evictable containers
    Audit any code that returns or retains a raw reference/pointer to a value stored in an MRU/LRU or otherwise size-bounded cache across a lock release or long operation.
  • Lock-then-unlock-then-use across trimming
    Look for patterns where a global cache lock is dropped before per-entry work while a concurrent path can call trim/evict, especially around angle::TrimCache and HashingMRUCache::Put callers.
  • Value-stored mutable entries
    Flag caches storing mutable entries by value (rather than by owning smart pointer) where entry lifetime is coupled to cache membership rather than to active users.
From 3bb4e2424dc4424edc30cddb384817bcab078438 Mon Sep 17 00:00:00 2001
From: Geoff Lang <[email protected]>
Date: Wed, 01 Apr 2026 15:54:06 -0400
Subject: [PATCH] Metal: Store LibraryCacheEntry in a shared_ptr

This ensures that if an entry is removed from the cache while it is
compiling, it is not deleted until it is fully unreferenced.

If there were > kMaxCachedLibraries simultaneous compile requests, it
was possible that the cache gets trimmed while threads are compiling and
we can end up writing to removed cache entries.

Bug: chromium:497724490
Change-Id: Iba4281d5d12c6882de51fc1bf88d60157ffc7fb6
Reviewed-on: https://chromium-review.googlesource.com/c/angle/angle/+/7722080
Commit-Queue: Geoff Lang <[email protected]>
Reviewed-by: dan sinclair <[email protected]>
---

diff --git a/src/libANGLE/renderer/metal/mtl_library_cache.h b/src/libANGLE/renderer/metal/mtl_library_cache.h
index d936e6a..93dfc15 100644
--- a/src/libANGLE/renderer/metal/mtl_library_cache.h
+++ b/src/libANGLE/renderer/metal/mtl_library_cache.h
@@ -77,7 +77,7 @@
         std::mutex lock;
     };
 
-    LibraryCacheEntry &getCacheEntry(LibraryKey &&key);
+    std::shared_ptr<LibraryCacheEntry> getCacheEntry(LibraryKey &&key);
 
     static constexpr unsigned int kMaxCachedLibraries = 128;
 
@@ -87,7 +87,8 @@
     // Lock for searching and adding new entries to the cache
     std::mutex mCacheLock;
 
-    using CacheMap = angle::base::HashingMRUCache<LibraryKey, LibraryCacheEntry, LibraryKeyHasher>;
+    using CacheMap = angle::base::
+        HashingMRUCache<LibraryKey, std::shared_ptr<LibraryCacheEntry>, LibraryKeyHasher>;
     CacheMap mCache;
 };
 
diff --git a/src/libANGLE/renderer/metal/mtl_library_cache.mm b/src/libANGLE/renderer/metal/mtl_library_cache.mm
index 5fb63ea..27512a1 100644
--- a/src/libANGLE/renderer/metal/mtl_library_cache.mm
+++ b/src/libANGLE/renderer/metal/mtl_library_cache.mm
@@ -36,15 +36,15 @@
                                                  bool usesInvariance)
 {
     ASSERT(source != nullptr);
-    LibraryCache::LibraryCacheEntry &entry =
+    std::shared_ptr<LibraryCache::LibraryCacheEntry> entry =
         getCacheEntry(LibraryKey(source, macros, disableFastMath, usesInvariance));
 
     // Try to lock the entry and return the library if it exists. If we can't lock then it means
     // another thread is currently compiling.
-    std::unique_lock<std::mutex> entryLockGuard(entry.lock, std::try_to_lock);
+    std::unique_lock<std::mutex> entryLockGuard(entry->lock, std::try_to_lock);
     if (entryLockGuard)
     {
-        return entry.library;
+        return entry->library;
     }
     else
     {
@@ -69,23 +69,23 @@
     }
 
     ASSERT(source != nullptr);
-    LibraryCache::LibraryCacheEntry &entry =
+    std::shared_ptr<LibraryCache::LibraryCacheEntry> entry =
         getCacheEntry(LibraryKey(source, macros, disableFastMath, usesInvariance));
 
     // Lock this cache entry while compiling the shader. This causes other threads calling this
     // function to wait and not duplicate the compilation.
-    std::lock_guard<std::mutex> entryLockGuard(entry.lock);
-    if (entry.library)
+    std::lock_guard<std::mutex> entryLockGuard(entry->lock);
+    if (entry->library)
     {
-        return entry.library;
+        return entry->library;
     }
 
-    entry.library = CreateShaderLibrary(metalDevice, *source, macros, disableFastMath,
-                                        usesInvariance, errorOut);
-    return entry.library;
+    entry->library = CreateShaderLibrary(metalDevice, *source, macros, disableFastMath,
+                                         usesInvariance, errorOut);
+    return entry->library;
 }
 
-LibraryCache::LibraryCacheEntry &LibraryCache::getCacheEntry(LibraryKey &&key)
+std::shared_ptr<LibraryCache::LibraryCacheEntry> LibraryCache::getCacheEntry(LibraryKey &&key)
 {
     // Lock while searching or adding new items to the cache.
     std::lock_guard<std::mutex> cacheLockGuard(mCacheLock);
@@ -98,7 +98,7 @@
 
     angle::TrimCache(kMaxCachedLibraries, kGCLimit, "metal library", &mCache);
 
-    iter = mCache.Put(std::move(key), LibraryCacheEntry());
+    iter = mCache.Put(std::move(key), std::make_shared<LibraryCacheEntry>());
     return iter->second;
 }
 
Loading diff…

Original Bug Report

reported by [email protected]

UAF in Metal LibraryCache

Summary

ANGLE’s Metal shader-library cache returns a LibraryCacheEntry & from LibraryCache::getCacheEntry while holding mCacheLock, but LibraryCache::getOrCompileShaderLibrary drops that cache lock before taking entry.lock. Once the cache was converted to an evicting HashingMRUCache, concurrent shader-link tasks could call TrimCache and destroy the referenced entry before the caller locks it, leading to the UAF in GPU process during Metal library compilation.

Details

CreateMslShaderLib compiles translated MSL through the display-global Metal library cache. LibraryCache::getOrCompileShaderLibrary obtains a reference to an entry and only then locks that entry’s mutex:

ASSERT(source != nullptr);
LibraryCache::LibraryCacheEntry &entry =
    getCacheEntry(LibraryKey(source, macros, disableFastMath, usesInvariance));

// Lock this cache entry while compiling the shader. This causes other threads calling this
// function to wait and not duplicate the compilation.
std::lock_guard<std::mutex> entryLockGuard(entry.lock);
if (entry.library)
{
    return entry.library;
}

entry.library = CreateShaderLibrary(metalDevice, *source, macros, disableFastMath,
                                    usesInvariance, errorOut);
return entry.library;

LibraryCache::getCacheEntry protects lookup and insertion with mCacheLock, but it returns a reference to an object stored inside the MRU container and releases the cache lock before the caller touches that reference again. The same function also trims the MRU before inserting:

    // Lock while searching or adding new items to the cache.
    std::lock_guard<std::mutex> cacheLockGuard(mCacheLock);

    auto iter = mCache.Get(key);
    if (iter != mCache.end())
    {
        return iter->second;
    }

    angle::TrimCache(kMaxCachedLibraries, kGCLimit, "metal library", &mCache);

    iter = mCache.Put(std::move(key), LibraryCacheEntry());
    return iter->second;

That means the lifetime guarantee on entry ends when mCacheLock is released. A second worker can immediately re-enter getCacheEntry, hit angle::TrimCache, and erase the least-recently-used node that the first thread still references. The destructor for LibraryCacheEntry does lock entry.lock, but that only serializes destruction with in-flight compilation if the caller already has a valid pointer to the same object. Therefore it does not prevent a caller from dereferencing after the container node itself has been freed, and thus cause the UAF.

Bisection

This issue is introduced by the commit https://chromium-review.googlesource.com/c/angle/angle/+/4614362, which introduced the MRUCache, making the attacker available to free TrimCache and cause UAF.

Reproduction

Host the poc.html using python3 -m http.server 8080

Run chromium (e.g., https://storage.googleapis.com/chromium-browser-asan/mac-release-arm64/asan-mac-release-1606755.zip) with:

./Chromium.app/Contents/MacOS/Chromium http://127.0.0.1:8080/poc.html http://127.0.0.1:8080/poc.html http://127.0.0.1:8080/poc.html http://127.0.0.1:8080/poc.html

You would observe the UAF shown in asan.txt.

Suggested Fix

The minimal fix is to store cache entries with ref-counted pointer.

View on issue tracker