CVE-2026-9923
Overview
Background
- `SkLRUCache::find`
- Returns a raw pointer to the
fValuefield (sk_sp<GraphicsPipeline>*) inside a heap-allocated LRUEntrynode; the pointer is only valid while the node lives in the cache. - `SkAutoSpinlock`
- Scoped lock; it releases
fSpinLockat the end of its enclosing block. - LRU eviction
addGraphicsPipeline→SkLRUCache::insertevicts and frees the least-recently-usedEntrywhen 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).
sk_sp.Attack Path
- Fill the cache
From GPU-process-reachable work (WebGL/WebGPU/canvas), create 256 unique graphics pipelines to bring
fGraphicsPipelineCacheto its limit (inferred web reachability of pipeline creation). - Race the lookup
On thread 1, trigger
findGraphicsPipelinefor a cached pipeline A; the race window is the momentfSpinLockis released before the rawentrydereference. - Evict concurrently
On thread 2, create 256 more unique pipelines via
addGraphicsPipeline;insert’s LRU eviction frees pipeline A’sEntrynode. - Reclaim
Spray the GPU heap to place a controlled 64-bit value at the former
fValueoffset of the freed node (not quarantined, since noraw_ptrreferences it). - Dangling copy
Thread 1 resumes
return *entry;; thesk_spcopy constructor callsSkSafeRef(P)on the attacker pointer. - Arbitrary atomic increment
Resource::ref()is a non-virtual inline doingfRefs.fetch_add((uint64_t)1<<2)atP+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
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
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/graphite/GlobalCache.cpp |
modified |
Files Changed
src/gpu/graphite/GlobalCache.cpp
Audit Directions
- Lock-scope escapesGrep
GlobalCache.cppand other Graphite/Ganesh caches forfind(...)results (raw node/value pointers) used after the guardingSkAutoSpinlock/mutex scope ends; require copying a strong ref under lock. - `SkLRUCache` consumersReview all callers that retain
SkLRUCache::findreturn pointers across any point whereinsert/reset/eviction can run on another thread. - Non-virtual inline ref as a primitiveNote that
Resource::ref()being a non-virtual inline makes anysk_sp-from-freed-memory bug directly weaponizable; audit other code paths that copysk_sp/sk_ref_spout of shared mutable containers under fine-grained locking.
Patch
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
Original Bug Report
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:
- The attacker creates 256 unique graphics pipelines to fill the LRU cache to its limit.
- The attacker triggers
findGraphicsPipelineon Thread 1 for an existing pipeline (Pipeline A), pausing execution immediately after thefSpinLockis released. - 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 itsEntrynode. - The attacker sprays the GPU process heap to reclaim the freed
Entrychunk, placing a fake 64-bit pointer (P) at the exact memory offset corresponding to the formerfValuefield. - Thread 1 resumes and executes
return *entry;. - The
sk_spcopy constructor reads the attacker-controlled pointerPand callsSkSafeRef(P). SkSafeRefexecutesP->ref(). Crucially,ref()inskgpu::graphite::Resource(the base class ofGraphicsPipeline) 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.Resource::ref()executes an atomic 64-bit addition:fRefs.fetch_add((uint64_t)1 << 2, std::memory_order_relaxed);at offset +8 fromP.
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.