Overview

Critical
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Skia
DescriptionUse after free in Skia
ComponentSkia
Sub-componentGraphite
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker516981393
Fix commite7bff78bf5d2 (skia) +57/-5
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-27 (per vendor advisory)
Fix landed2026-05-28 (public git)
Disclosed2026-06-30
Patch gap33 days — fix public in source before the advisory

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 fCurrentBuffers and fUnavailableScratchBuffers.
`BufferSubAllocator`
RAII cursor over one owner buffer that carves off sub-ranges and, on reset(), returns its fBuffer to 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.

Key insight
An error/teardown flag (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

  1. Reach the allocator Drive Graphite rendering (WebGPU/Canvas GPU content) so DrawBufferManager hands out sub-allocations through BufferSubAllocator.
  2. Force a mapping failure Induce a GPU buffer mapping failure so the owner sets fMappingFailed and its fCurrentBuffers / fUnavailableScratchBuffers bookkeeping is invalidated.
  3. Trigger the tainted reset Let the BufferSubAllocator be destroyed, invoking reset(), which still touches fOwner->fCurrentBuffers[fStateIndex] and reinserts the stale fBuffer into the reuse pools.
  4. 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.
  5. 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

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

Files Changed

  • src/gpu/graphite/BufferManager.cpp
  • src/gpu/graphite/BufferManager.h
  • tests/graphite/BufferManagerTest.cpp

Audit Directions

  • Error flags vs. cleanup paths
    Audit every RAII/reset()/destructor that returns resources to a shared owner for whether an owner-level failure or teardown flag (like fMappingFailed) can invalidate the bookkeeping it mutates.
  • Assert-masked invariants
    Where an SkASSERT encodes 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 pools
    Review insert/remove into fCurrentBuffers and fUnavailableScratchBuffers across Graphite for reinsertion of freed or stale buffers after mapping/allocation failures.
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
+
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cpp
  • third_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.

  1. 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).
  2. OnFailedBuffer Invocation: The allocation failure invokes onFailedBuffer(), which marks fMappingFailed = true and clears fAvailableBuffer.
  3. Destructor Stashing: The failed recording aborts, and stack unwinding invokes the destructor of stack-local allocators (e.g., UniformTracker). The destructor executes the else block of BufferSubAllocator::reset(), moving raw, stale pointers (fMappedPtr, fTransferBuffer) into state.fAvailableBuffer.
  4. Recording Discard: transferToRecording() returns early, resetting fMappingFailed but leaving the stale allocator in fAvailableBuffer untouched.
  5. Re-use and Corruption: A subsequent successful recording begins. The allocator retrieves the stale cache from fAvailableBuffer. Writing to fMappedPtr results in a wild write. When the allocator goes out of scope, the stale transfer buffer is pushed to fUsedBuffers, causing a heap Use-After-Free inside DawnCommandBuffer::onCopyBufferToBuffer when 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.

View on issue tracker