CVE-2026-7901
Overview
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 lockand a compiledlibrary, 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.
std::shared_ptr decouples the entry’s lifetime from its cache membership so trimming can no longer free memory still in use.Attack Path
- 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. - Obtain a reference into the cache
Each thread calls
getShaderLibrary/getCacheEntry, receiving a rawLibraryCacheEntry &intomCachebefore releasingmCacheLock. - Trigger eviction of an in-use entry
A racing
getCacheEntrycall runsangle::TrimCacheandmCache.Put, evicting and destroying an entry that another thread still references while compiling. - Dereference the dangling entry
The compiling thread writes through the freed reference (
entry.lock,entry.library = CreateShaderLibrary(...)), producing the use-after-free write.(inferred) - Leverage the freed-memory write
Grooming the freed
LibraryCacheEntryslab to gain a controlled read/write or corruption primitive is not shown by the patch and would be needed for exploitation.(inferred)
Impact Assessment
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.hsrc/libANGLE/renderer/metal/mtl_library_cache.mm
Audit Directions
- References into evictable containersAudit 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 trimmingLook for patterns where a global cache lock is dropped before per-entry work while a concurrent path can call trim/evict, especially around
angle::TrimCacheandHashingMRUCache::Putcallers. - Value-stored mutable entriesFlag 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.
Patch
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; }
Original Bug Report
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.
- http://127.0.0.1:8080/poc.html
- https://chromium-review.googlesource.com/c/angle/angle/+/4614362
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/metal/ProgramExecutableMtl.mm;l=349
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/metal/mtl_library_cache.mm;l=135
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/metal/mtl_library_cache.mm;l=55
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/metal/mtl_library_cache.mm;l=88
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/angle/src/libANGLE/renderer/metal/mtl_library_cache.mm;l=99
- https://storage.googleapis.com/chromium-browser-asan/mac-release-arm64/asan-mac-release-1606755.zip