High CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Skia
DescriptionUse after free in Skia
ComponentSkia
Sub-componentGraphite
Bug ClassUAF
Tracker500393328
Fix commit04ff0203681d (skia) +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
CreditedGoogle
Reported2026-04-07 (per vendor advisory)
Fix landed2026-04-23 (public git)
Disclosed2026-05-27
Patch gap34 days — fix public in source before the advisory

Background

`SkLRUCache::find`
Returns a raw pointer to the fValue field (sk_sp<GraphicsPipeline>*) inside a heap-allocated LRU Entry node; the pointer is only valid while the node lives in the cache.
`SkAutoSpinlock`
Scoped lock; it releases fSpinLock at the end of its enclosing block.
LRU eviction
addGraphicsPipeline → SkLRUCache::insert evicts and frees the least-recently-used Entry when the cache exceeds its size limit (default 256), deleting the backing heap memory.

Root Cause Analysis

GlobalCache::findGraphicsPipeline captured the raw sk_sp<GraphicsPipeline>* entry returned by fGraphicsPipelineCache.find(key) while holding fSpinLock, did its bookkeeping under the lock, but then dereferenced entry after the SkAutoSpinlock scope closed and the lock was released. In a multi-threaded GPU process, a second thread could acquire the lock in that window and call addGraphicsPipeline, whose insert evicts the LRU tail and frees the very Entry that entry points into. When the first thread resumed and executed this->invokePipelineCallback(..., entry->get()) / return *entry;, it read and copied an sk_sp out of freed heap memory — a use-after-free. The sk_sp copy constructor then calls SkSafeRef on the (now attacker-reclaimable) pointer stored where fValue used to be.

The patch adopts the pattern already used correctly by findComputePipeline: it copies the pipeline into a local sk_sp<GraphicsPipeline> result inside the locked scope (result = *entry;) and performs all subsequent work (didAsyncCompilationFail, epoch update, updateAccessTime/markUsed, tracing, the callback, and the return) through result rather than the raw entry. Because result is a strong reference taken while the entry is guaranteed live, a concurrent eviction merely drops the cache’s reference while result keeps the pipeline alive; nothing is dereferenced through the raw cache pointer after the lock is dropped. The bug class is a UAF (CWE-416) caused by a lock-scope/lifetime error (the advisory calls it a race condition leading to UAF — both are accurate; the memory-safety sink is the UAF).

Key insight
A raw pointer into an LRU cache node was dereferenced after its protecting spinlock was released, so a concurrent eviction on another thread could free the node and turn the pointer into a dangling reference that was then copied as an sk_sp.

Attack Path

  1. Fill the cache From GPU-process-reachable work (WebGL/WebGPU/canvas), create 256 unique graphics pipelines to bring fGraphicsPipelineCache to its limit (inferred web reachability of pipeline creation).
  2. Race the lookup On thread 1, trigger findGraphicsPipeline for a cached pipeline A; the race window is the moment fSpinLock is released before the raw entry dereference.
  3. Evict concurrently On thread 2, create 256 more unique pipelines via addGraphicsPipeline; insert’s LRU eviction frees pipeline A’s Entry node.
  4. Reclaim Spray the GPU heap to place a controlled 64-bit value at the former fValue offset of the freed node (not quarantined, since no raw_ptr references it).
  5. Dangling copy Thread 1 resumes return *entry;; the sk_sp copy constructor calls SkSafeRef(P) on the attacker pointer.
  6. Arbitrary atomic increment Resource::ref() is a non-virtual inline doing fRefs.fetch_add((uint64_t)1<<2) at P+8, yielding a controlled 64-bit atomic-increment primitive (no vtable needed); the advisory escalates this to OOB r/w and RCE in the GPU process (inferred escalation).

Impact Assessment

Runs in the GPU process, which Skia Graphite shares across threads and which is unsandboxed/elevated. Triggering requires winning a race between a cache lookup and an eviction, so the advisory’s AC:H is apt; no special privilege is needed beyond the ability to drive GPU pipeline creation from web content. The confirmed primitive from the diff is a use-after-free read that copies an attacker-controllable sk_sp, leading (per the advisory) to a reliable arbitrary 64-bit atomic-increment via the inlined Resource::ref(). The onward OOB read/write, ASLR defeat, and RCE/sandbox-escape are plausible but (inferred) beyond the diff. MiraclePtr/BRP does not apply because entry is a stack-local raw pointer in Skia (excluded from the rewriter) and the freed memory is not quarantined.

Changed Functions

FunctionChangeNotes
if
src/gpu/graphite/GlobalCache.cpp
modified

Files Changed

  • src/gpu/graphite/GlobalCache.cpp

Audit Directions

  • Lock-scope escapes
    Grep GlobalCache.cpp and other Graphite/Ganesh caches for find(...) results (raw node/value pointers) used after the guarding SkAutoSpinlock/mutex scope ends; require copying a strong ref under lock.
  • `SkLRUCache` consumers
    Review all callers that retain SkLRUCache::find return pointers across any point where insert/reset/eviction can run on another thread.
  • Non-virtual inline ref as a primitive
    Note that Resource::ref() being a non-virtual inline makes any sk_sp-from-freed-memory bug directly weaponizable; audit other code paths that copy sk_sp/sk_ref_sp out of shared mutable containers under fine-grained locking.
From 04ff0203681d3e21a201084d39c29e6d44ef8358 Mon Sep 17 00:00:00 2001
From: Robert Phillips <robertphillips@google.com>
Date: Thu, 23 Apr 2026 12:57:10 -0400
Subject: [PATCH] [graphite] Fix a security issue in GlobalCache::findGraphicsPipeline

Bug: b/500393328
Change-Id: I35ad93eaba08fcb4c0896993ff857902622581a0
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1217456
Commit-Queue: Robert Phillips <robertphillips@google.com>
Reviewed-by: Michael Ludwig <michaelludwig@google.com>
---

diff --git a/src/gpu/graphite/GlobalCache.cpp b/src/gpu/graphite/GlobalCache.cpp
index 146c0a2..86583be 100644
--- a/src/gpu/graphite/GlobalCache.cpp
+++ b/src/gpu/graphite/GlobalCache.cpp
@@ -185,31 +185,33 @@
     [[maybe_unused]] bool forPrecompile =
             SkToBool(pipelineCreationFlags & PipelineCreationFlags::kForPrecompilation);
 
-    sk_sp<GraphicsPipeline>* entry = nullptr;
+    sk_sp<GraphicsPipeline> result;
     {
         SkAutoSpinlock lock{fSpinLock};
 
-        entry = fGraphicsPipelineCache.find(key);
+        sk_sp<GraphicsPipeline>* entry = fGraphicsPipelineCache.find(key);
         if (entry) {
-            if ((*entry)->didAsyncCompilationFail()) SK_UNLIKELY {
+            result = *entry;
+
+            if (result->didAsyncCompilationFail()) SK_UNLIKELY {
                 // If the pipeline failed, remove it from the cache and let it be regenerated.
-                this->removeGraphicsPipeline((*entry).get());
+                this->removeGraphicsPipeline(result.get());
                 return nullptr;
             }
 #if defined(GPU_TEST_UTILS)
             ++fStats.fGraphicsCacheHits;
 #endif
 
-            if ((*entry)->epoch() != fEpochCounter) {
-                (*entry)->markEpoch(fEpochCounter);   // update epoch due to use in a new epoch
+            if (result->epoch() != fEpochCounter) {
+                result->markEpoch(fEpochCounter);   // update epoch due to use in a new epoch
                 ++fStats.fPipelineUsesInEpoch;
             }
-            if (!forPrecompile && (*entry)->fromPrecompile() && !(*entry)->wasUsed()) {
+            if (!forPrecompile && result->fromPrecompile() && !result->wasUsed()) {
                 ++fStats.fNormalPreemptedByPrecompile;
             }
 
-            (*entry)->updateAccessTime();
-            (*entry)->markUsed();
+            result->updateAccessTime();
+            result->markUsed();
 
 #if defined(SK_PIPELINE_LIFETIME_LOGGING)
             static const char* kNames[2] = { "CacheHitForN", "CacheHitForP" };
@@ -217,7 +219,7 @@
                                  TRACE_STR_STATIC(kNames[forPrecompile]),
                                  TRACE_EVENT_SCOPE_THREAD,
                                  "key", key.hash(),
-                                 "compilationID", (*entry)->getPipelineInfo().fCompilationID);
+                                 "compilationID", result->getPipelineInfo().fCompilationID);
 #endif
         } else {
 #if defined(GPU_TEST_UTILS)
@@ -242,12 +244,11 @@
         }
     }
 
-    if (entry) {
-        this->invokePipelineCallback(ContextOptions::PipelineCacheOp::kPipelineFound, entry->get());
-        return *entry;
+    if (result) {
+        this->invokePipelineCallback(ContextOptions::PipelineCacheOp::kPipelineFound, result.get());
     }
 
-    return nullptr;
+    return result;
 }
 
 #if SK_HISTOGRAMS_ENABLED
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential Race condition leading to Use-After-Free in Graphite GlobalCache::findGraphicsPipeline

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 race condition exists in GlobalCache::findGraphicsPipeline where a stack-local raw pointer to a cache entry is dereferenced after its protecting spinlock is released. This can result in a Use-After-Free if another thread evicts the entry from the LRU cache during the window between lock release and dereference. An attacker could potentially use this to execute an arbitrary 64-bit atomic increment, leading to RCE in the unsandboxed GPU process.

Affected files:

  • third_party/skia/src/gpu/graphite/GlobalCache.cpp

Estimated timestamp from git blame: 2025-10-29

Description

A potential Use-After-Free (UAF) vulnerability exists in the Graphite backend of Skia, specifically within GlobalCache::findGraphicsPipeline. The function retrieves a raw pointer to an entry in fGraphicsPipelineCache (an SkLRUCache) while holding fSpinLock. However, it releases the lock before dereferencing this pointer.

In third_party/skia/src/gpu/graphite/GlobalCache.cpp:

188:     sk_sp<GraphicsPipeline>* entry = nullptr;
189:     {
190:         SkAutoSpinlock lock{fSpinLock};
191: 
192:         entry = fGraphicsPipelineCache.find(key);
... // Bookkeeping performed under lock
242:         }
243:     } // fSpinLock is released here
244: 
245:     if (entry) {
246:         this->invokePipelineCallback(ContextOptions::PipelineCacheOp::kPipelineFound, entry->get());
247:         return *entry;
248:     }

SkLRUCache::find() returns a raw pointer to the fValue field within a heap-allocated Entry node. Because fSpinLock is released at line 243, a concurrent thread can acquire the lock and perform operations that evict or remove the entry from the cache. For example, a concurrent call to addGraphicsPipeline calls fGraphicsPipelineCache.insert(), which evicts the LRU tail if the cache size limit (default 256) is exceeded. Eviction results in the heap memory backing the Entry being deleted, leaving entry as a dangling pointer.

Impact & Exploitability

In Chromium’s GPU process, GlobalCache is shared across multiple threads (e.g., Viz compositor thread and GPU main thread). This provides a mechanism for concurrent access where one thread can trigger an eviction while another is returning a pipeline from the cache.

This issue is not protected by MiraclePtr/BRP because entry is a stack-local raw pointer within Skia code, which is currently excluded from the MiraclePtr rewriter. Furthermore, because there are no raw_ptr references to the cache entry memory, PartitionAlloc does not quarantine the memory upon deletion, making it immediately available for reallocation.

When a race occurs, the dereference at line 247 (return *entry;) accesses freed memory. This invokes the copy constructor for sk_sp<GraphicsPipeline>.

The following steps suggest how an attacker could potentially exploit this:

  1. The attacker creates 256 unique graphics pipelines to fill the LRU cache to its limit.
  2. The attacker triggers findGraphicsPipeline on Thread 1 for an existing pipeline (Pipeline A), pausing execution immediately after the fSpinLock is released.
  3. Concurrently on Thread 2, the attacker triggers the creation of 256 new unique pipelines via addGraphicsPipeline. This evicts Pipeline A from the cache and deletes its Entry node.
  4. The attacker sprays the GPU process heap to reclaim the freed Entry chunk, placing a fake 64-bit pointer (P) at the exact memory offset corresponding to the former fValue field.
  5. Thread 1 resumes and executes return *entry;.
  6. The sk_sp copy constructor reads the attacker-controlled pointer P and calls SkSafeRef(P).
  7. SkSafeRef executes P->ref(). Crucially, ref() in skgpu::graphite::Resource (the base class of GraphicsPipeline) is a non-virtual inline method. This means the compiler emits a direct static function call, completely bypassing the need for the fake object to have a valid vtable.
  8. Resource::ref() executes an atomic 64-bit addition: fRefs.fetch_add((uint64_t)1 << 2, std::memory_order_relaxed); at offset +8 from P.

The attacker has achieved a pristine, highly reliable arbitrary 64-bit atomic increment primitive. By setting P+8 to the address of an array bound, string length, or index within a critical GPU process object, the attacker can surgically inflate the bound by 4. This provides out-of-bounds read and write capabilities, allowing them to bypass ASLR and hijack control flow. Because the Chromium GPU process is unsandboxed and has elevated privileges, achieving RCE within it constitutes a full sandbox escape (Severity 0).

Recommendation

The implementation should copy the sk_sp<GraphicsPipeline> value into a local variable while the lock is still held, and then use that local variable outside the lock scope. This is already the pattern used correctly in GlobalCache::findComputePipeline in the same file.

    sk_sp<GraphicsPipeline> pipeline;
    {
        SkAutoSpinlock lock{fSpinLock};
        if (auto* entry = fGraphicsPipelineCache.find(key)) {
            // ... bookkeeping ...
            pipeline = *entry;
        } else {
            // ... cache miss logic ...
        }
    }

    if (pipeline) {
        this->invokePipelineCallback(ContextOptions::PipelineCacheOp::kPipelineFound, pipeline.get());
        return pipeline;
    }
    return nullptr;

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