CVE-2026-14419
Overview
Background
- Graphite
- Skia’s next-generation GPU backend that batches draws and streams vertex/uniform data through pooled GPU buffers.
- `DrawBufferManager`
- Graphite owner object that hands out GPU sub-allocations and tracks per-buffer state in
fCurrentBuffersandfUnavailableScratchBuffers. - `BufferSubAllocator`
- RAII cursor over one owner buffer that carves off sub-ranges and, on
reset(), returns itsfBufferto the owner’s reuse pools. - `fMappingFailed`
- owner flag set when GPU buffer mapping fails, marking the per-buffer bookkeeping as torn down / invalidated.
Root Cause Analysis
When buffer mapping fails, DrawBufferManager sets fMappingFailed and tears down or invalidates its per-buffer bookkeeping (fCurrentBuffers, fUnavailableScratchBuffers). However, BufferSubAllocator::reset() did not fully account for this state: it proceeded to touch fOwner->fCurrentBuffers[fStateIndex] and move fBuffer back into the owner’s reuse/scratch pools. Because the mapping-failure path had already invalidated or freed that owner-side buffer state, reset() operated on and reinserted references to buffer state that was no longer valid, yielding a use-after-free in the GPU process. The old SkASSERT even encoded the inconsistency (the buffer might be absent from fUnavailableScratchBuffers when fMappingFailed), but the release path still dereferenced and mutated the stale bookkeeping.
The fix short-circuits reset() on fMappingFailed — dropping fBuffer, fRemaining, and fTransferBuffer and returning immediately — so it never touches the invalidated owner state, and tightens the scratch-buffer asserts for the normal path.
fMappingFailed) invalidated shared owner bookkeeping, but the RAII destructor path (reset()) still ran its normal buffer-return logic against that now-invalid state. Correctness required the cleanup path to honor the failure flag before touching any owner-owned buffer structures.Attack Path
- Reach the allocator
Drive Graphite rendering (WebGPU/Canvas GPU content) so
DrawBufferManagerhands out sub-allocations throughBufferSubAllocator. - Force a mapping failure
Induce a GPU buffer mapping failure so the owner sets
fMappingFailedand itsfCurrentBuffers/fUnavailableScratchBuffersbookkeeping is invalidated. - Trigger the tainted reset
Let the
BufferSubAllocatorbe destroyed, invokingreset(), which still touchesfOwner->fCurrentBuffers[fStateIndex]and reinserts the stalefBufferinto the reuse pools. - Reuse the stale buffer Purge the cache and issue a new allocation so the freed/stale buffer state is reused, producing the use-after-free.
- Exploit Groom the GPU-process heap so the reused buffer state is attacker-influenced, turning the UAF into a corruption primitive.
Impact Assessment
- Use-after-free of GPU buffer state in the GPU process, reachable from renderer-driven Graphite workloads.
- Depending on heap grooming, corruption of reused buffer state can lead to controlled memory writes and potential remote code execution in the GPU process; mapping-failure conditions raise reliability of reaching the sink.
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/graphite/BufferManager.cpp |
modified |
Files Changed
src/gpu/graphite/BufferManager.cppsrc/gpu/graphite/BufferManager.htests/graphite/BufferManagerTest.cpp
Audit Directions
- Error flags vs. cleanup pathsAudit every RAII/
reset()/destructor that returns resources to a shared owner for whether an owner-level failure or teardown flag (likefMappingFailed) can invalidate the bookkeeping it mutates. - Assert-masked invariantsWhere an
SkASSERTencodes an “OR failed-state” disjunction, treat the failed branch as a live release-build path and verify the surrounding mutation is safe under it, not just asserted around. - Buffer reuse poolsReview insert/remove into
fCurrentBuffersandfUnavailableScratchBuffersacross Graphite for reinsertion of freed or stale buffers after mapping/allocation failures.
Patch
From e7bff78bf5d2994f627742aaf5040b2ba55c4475 Mon Sep 17 00:00:00 2001
From: Thomas Smith <thomsmit@google.com>
Date: Thu, 28 May 2026 10:41:50 -0400
Subject: [PATCH] [graphite] BufferSubAllocator respects failed mapping on reset
Bug: b/516981393
Change-Id: If6837e26ee520ad34c8df46a34850220ec5b538c
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1247536
Commit-Queue: Thomas Smith <thomsmit@google.com>
Reviewed-by: Michael Ludwig <michaelludwig@google.com>
---
diff --git a/src/gpu/graphite/BufferManager.cpp b/src/gpu/graphite/BufferManager.cpp
index bd726251..32319fc 100644
--- a/src/gpu/graphite/BufferManager.cpp
+++ b/src/gpu/graphite/BufferManager.cpp
@@ -263,6 +263,13 @@
if (fBuffer) {
SkASSERT(fOwner);
+ if (fOwner->fMappingFailed) {
+ fBuffer = nullptr;
+ fRemaining = 0;
+ fTransferBuffer = {};
+ return;
+ }
+
DrawBufferManager::BufferState& state = fOwner->fCurrentBuffers[fStateIndex];
if (fBuffer->shareable() == Shareable::kScratch) {
// TODO: Merge this reuse of scratch resources with the ScratchResourceManager, but
@@ -271,11 +278,8 @@
// The scratch buffer's availability for reuse (scoped to the owning DrawBufferManager)
// was tied to this BufferSubAllocator, so when that is reset, we just remove the buffer
// from the set of unavailable buffers.
- SkASSERT((fOwner->fMappingFailed && state.fUnavailableScratchBuffers.empty()) ||
- state.fUnavailableScratchBuffers.contains(fBuffer.get()));
- if (!fOwner->fMappingFailed) {
- state.fUnavailableScratchBuffers.remove(fBuffer.get());
- }
+ SkASSERT(state.fUnavailableScratchBuffers.contains(fBuffer.get()));
+ state.fUnavailableScratchBuffers.remove(fBuffer.get());
SkASSERT(!fTransferBuffer); // Scratch buffers shouldn't be using transfer buffers
fOwner->fUsedBuffers.emplace_back(std::move(fBuffer), BindBufferInfo{});
diff --git a/src/gpu/graphite/BufferManager.h b/src/gpu/graphite/BufferManager.h
index 192ceeb..d811401 100644
--- a/src/gpu/graphite/BufferManager.h
+++ b/src/gpu/graphite/BufferManager.h
@@ -351,6 +351,10 @@
// for recording buffer data for the next Recording.
[[nodiscard]] bool transferToRecording(Recording*);
+#if defined(GPU_TEST_UTILS)
+ void testingOnly_onFailedBuffer() { this->onFailedBuffer(); }
+#endif
+
private:
friend class BufferSubAllocator;
diff --git a/tests/graphite/BufferManagerTest.cpp b/tests/graphite/BufferManagerTest.cpp
index 6c64e4d..da9784a1 100644
--- a/tests/graphite/BufferManagerTest.cpp
+++ b/tests/graphite/BufferManagerTest.cpp
@@ -70,4 +70,48 @@
REPORTER_ASSERT(reporter, ssbo.fBuffer != mappedSsbo.fBuffer);
}
+DEF_GRAPHITE_TEST_FOR_RENDERING_CONTEXTS(BufferManagerStaleAllocatorTest, reporter, context,
+ CtsEnforcement::kApiLevel_202404) {
+ std::unique_ptr<Recorder> recorder = context->makeRecorder();
+ DrawBufferManager* dbm = recorder->priv().drawBufferManager();
+
+ // Keep a reference to the buffer to prevent reuse false positives.
+ sk_sp<const Buffer> rawBuffer;
+ {
+ auto [writer, binding, allocator] =
+ dbm->getMappedUniformBuffer(/*stride=*/16, /*headroom=*/16);
+ REPORTER_ASSERT(reporter, allocator.isValid());
+ rawBuffer = sk_ref_sp(binding.fBuffer);
+ REPORTER_ASSERT(reporter, rawBuffer != nullptr);
+
+ // Force the buffer allocator to fail to simulate an allocation failure
+ dbm->testingOnly_onFailedBuffer();
+ REPORTER_ASSERT(reporter, dbm->hasMappingFailed());
+
+ // BufferSubAllocator::reset() is called on allocator destruction
+ }
+
+ // Recorder::snap() failure branch clears fMappingFailed and drops the failed Recording.
+ auto failedRecording = recorder->snap();
+ REPORTER_ASSERT(reporter, !failedRecording);
+
+ // Purge everything in the cache by setting the max budget to 0 and freeing all resources
+ size_t originalBudget = recorder->maxBudgetedBytes();
+ recorder->setMaxBudgetedBytes(0);
+ recorder->freeGpuResources();
+ recorder->setMaxBudgetedBytes(originalBudget); // probably unnecessary but safe
+
+ // Get another allocator
+ auto [staleWriter, staleBinding, staleAllocator] =
+ dbm->getMappedUniformBuffer(/*stride=*/16, /*headroom=*/16);
+ REPORTER_ASSERT(reporter, staleAllocator.isValid());
+
+ // The buffer should not be the same as rawBuffer
+ REPORTER_ASSERT(reporter, staleBinding.fBuffer != rawBuffer.get());
+
+ auto successRecording = recorder->snap();
+ REPORTER_ASSERT(reporter, successRecording);
+}
+
} // namespace skgpu::graphite
+
Original Bug Report
Potential Use-After-Free and Wild Write in Graphite DrawBufferManager on Allocation Failure
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A potential vulnerability in Skia’s Graphite DrawBufferManager can lead to a Use-After-Free or wild write in the GPU process. When a buffer allocation fails mid-recording, stack-local allocators are destructed and can stash stale raw pointers that are subsequently reused. In release builds, failure-handling paths fail to clear these stale allocators, allowing subsequent invalid memory operations.
Affected files:
third_party/skia/src/gpu/graphite/BufferManager.cppthird_party/skia/src/gpu/graphite/BufferManager.h
Estimated timestamp from git blame: 2025-10-13
Root Cause Analysis
In Skia’s Graphite implementation, BufferSubAllocator::reset() (defined in third_party/skia/src/gpu/graphite/BufferManager.cpp) handles returning or stashing a GPU buffer when a sub-allocator goes out of scope. However, it does not properly handle the state where the owning DrawBufferManager has encountered an allocation or mapping failure (fMappingFailed is true).
When an allocation failure occurs, DrawBufferManager::onFailedBuffer() is called. It marks fMappingFailed = true and clears state.fAvailableBuffer (lines 370-375). However, active stack-local BufferSubAllocator instances (held by utilities like UniformTracker or DrawWriter) are unaffected at this stage.
When the failed recording is aborted and the stack unwinds, the destructors of these stack-local allocators are called, invoking BufferSubAllocator::reset(). Inside reset() (lines 282-284), the following condition is evaluated to determine whether to stash the buffer or move it to fUsedBuffers:
else if (state.fAvailableBuffer.fBuffer.get() == fBuffer.get() ||
this->remainingBytes() < state.fAvailableBuffer.remainingBytes() ||
this->remainingBytes() < state.fMinAlignment)
Because onFailedBuffer() recently cleared state.fAvailableBuffer, its underlying fBuffer is null, meaning state.fAvailableBuffer.remainingBytes() is 0. Consequently, this->remainingBytes() < state.fAvailableBuffer.remainingBytes() evaluates to false. Execution falls into the else block (line 295):
state.fAvailableBuffer = std::move(*this);
This move assignment copies the raw CPU mapped pointer (fMappedPtr) and the raw transfer buffer pointer (fTransferBuffer) completely unchanged into state.fAvailableBuffer.
In release builds, the safety assert in DrawBufferManager::transferToRecording() is compiled out, allowing transferToRecording() to return false and reset fMappingFailed to false while silently leaving the stale allocator inside fAvailableBuffer (lines 391-403). On a subsequent successful recording session, getBuffer() retrieves this stale allocator and returns it to the caller, leading to a wild write via the dangling fMappedPtr and a potential heap Use-After-Free (UAF) read/downcast on the deleted srcBuffer inside DawnCommandBuffer::onCopyBufferToBuffer (line 1232).
Additionally, any stack-local allocators that execute the else if branch of reset() will push their stale references to fUsedBuffers (line 291). Because transferToRecording() returns early on fMappingFailed without clearing fUsedBuffers, these stale buffers contaminate the manager’s state into the next recording session.
Suggested/Potential Trigger Path
Note: These steps are suggested and potential; our automated analysis tools do not currently have the capability to run code or execute a live proof-of-concept.
- Trigger Transient Failure: An attacker forces a transient allocation/mapping failure in the GPU process (for instance, by scheduling extremely large canvas or WebGPU operations to hit memory limits).
- OnFailedBuffer Invocation: The allocation failure invokes
onFailedBuffer(), which marksfMappingFailed = trueand clearsfAvailableBuffer. - Destructor Stashing: The failed recording aborts, and stack unwinding invokes the destructor of stack-local allocators (e.g.,
UniformTracker). The destructor executes theelseblock ofBufferSubAllocator::reset(), moving raw, stale pointers (fMappedPtr,fTransferBuffer) intostate.fAvailableBuffer. - Recording Discard:
transferToRecording()returns early, resettingfMappingFailedbut leaving the stale allocator infAvailableBufferuntouched. - Re-use and Corruption: A subsequent successful recording begins. The allocator retrieves the stale cache from
fAvailableBuffer. Writing tofMappedPtrresults in a wild write. When the allocator goes out of scope, the stale transfer buffer is pushed tofUsedBuffers, causing a heap Use-After-Free insideDawnCommandBuffer::onCopyBufferToBufferwhen the command copy is executed.
Suggested Fix
To prevent both stashing and contamination of fUsedBuffers during an active allocation failure, BufferSubAllocator::reset() should check if fOwner->fMappingFailed is true and immediately discard its resources:
void BufferSubAllocator::reset() {
if (fBuffer) {
SkASSERT(fOwner);
if (fOwner->fMappingFailed) {
fBuffer.reset();
fRemaining = 0;
return;
}
// ... existing reset logic ...
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.