CVE-2026-14419
Overview
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
Patch
From e7bff78bf5d2994f627742aaf5040b2ba55c4475 Mon Sep 17 00:00:00 2001 From: Thomas Smith <[email protected]> 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 <[email protected]> Reviewed-by: Michael Ludwig <[email protected]> --- 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.