CVE-2026-19160
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/ganesh/SurfaceContext.cpp |
modified | |
iftests/ReadWritePixelsGpuTest.cpp |
modified | |
FailOnceFlushCallbacktests/ReadWritePixelsGpuTest.cpp |
modified |
Files Changed
src/gpu/ganesh/GrDrawingManager.cppsrc/gpu/ganesh/SurfaceContext.cpptests/ReadWritePixelsGpuTest.cpp
Patch
From cee83412c5b59f9406ddc2e7147290ea9ac9d9aa Mon Sep 17 00:00:00 2001 From: Michael Ludwig <[email protected]> Date: Thu, 23 Jul 2026 16:23:44 -0400 Subject: [PATCH] [ganesh] Check results of internal flushes for internalWritePixels success This moves the caps' dependent pre-flush out of newWritePixelsTask and performs the flush inside internalWritePixels. Also checks for the success of the flush at the end of internalWritePixels when the source pixel data isn't owned. These use a shared helper function. Adds a unit test that can trigger writing stale scratch texture contents if the internalWritePixels' flush failed when performing an upload to the scratch texture when taking a write-as-draw path for the primary writePixels target. Bug: 536068737 Fixed: 536068737 Change-Id: I75c01c1db94c3c38ba302473a0ba502e5cff2c94 Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1302397 Reviewed-by: Thomas Smith <[email protected]> Commit-Queue: Michael Ludwig <[email protected]> Reviewed-by: Nathan Sanchez <[email protected]> --- diff --git a/src/gpu/ganesh/GrDrawingManager.cpp b/src/gpu/ganesh/GrDrawingManager.cpp index 0b986eb..689f673 100644 --- a/src/gpu/ganesh/GrDrawingManager.cpp +++ b/src/gpu/ganesh/GrDrawingManager.cpp @@ -995,16 +995,6 @@ SkASSERT(fContext); this->closeActiveOpsTask(); - const GrCaps& caps = *fContext->priv().caps(); - - // On platforms that prefer flushes over VRAM use (i.e., ANGLE) we're better off forcing a - // complete flush here. - if (!caps.preferVRAMUseOverFlushes()) { - this->flushSurfaces(SkSpan<GrSurfaceProxy*>{}, - SkSurfaces::BackendSurfaceAccess::kNoAccess, - GrFlushInfo{}, - nullptr); - } GrRenderTask* task = this->appendTask(GrWritePixelsTask::Make(this, std::move(dst), diff --git a/src/gpu/ganesh/SurfaceContext.cpp b/src/gpu/ganesh/SurfaceContext.cpp index 32475be..8f2e415 100644 --- a/src/gpu/ganesh/SurfaceContext.cpp +++ b/src/gpu/ganesh/SurfaceContext.cpp @@ -585,6 +585,22 @@ } pt.fY = flip ? dstSurface->height() - pt.fY - src[0].height() : pt.fY; + auto flushSurfaceAndCheckSuccess = [dContext](GrSurfaceProxy* dstProxy, bool expectsTasks) { + const bool hasPendingTasks = + dContext->priv().drawingManager()->getLastRenderTask(dstProxy) != nullptr; + SkASSERT(!expectsTasks || hasPendingTasks); + GrSemaphoresSubmitted flushResult = dContext->priv().flushSurface(dstProxy); + return flushResult == GrSemaphoresSubmitted::kYes || !hasPendingTasks; + }; + + // On platforms that prefer flushes over VRAM use (i.e., ANGLE) we're better off forcing a + // complete flush here. + if (!caps->preferVRAMUseOverFlushes()) { + if (!flushSurfaceAndCheckSuccess(dstProxy, /*expectsTasks=*/false)) { + return false; + } + } + if (!dContext->priv().drawingManager()->newWritePixelsTask( sk_ref_sp(dstProxy), SkIRect::MakePtSize(pt, src[0].dimensions()), @@ -600,7 +616,9 @@ if (!ownAllStorage) { // If any pixmap doesn't own its pixels then we must flush so that the pixels are pushed to // the GPU before we return. - dContext->priv().flushSurface(dstProxy); + if (!flushSurfaceAndCheckSuccess(dstProxy, /*expectsTasks=*/true)) { + return false; + } } return true; } diff --git a/tests/ReadWritePixelsGpuTest.cpp b/tests/ReadWritePixelsGpuTest.cpp index c64b79e..dc0602b 100644 --- a/tests/ReadWritePixelsGpuTest.cpp +++ b/tests/ReadWritePixelsGpuTest.cpp @@ -1586,3 +1586,87 @@ } } +DEF_GANESH_TEST_FOR_RENDERING_CONTEXTS(WritePixelsFailedFlushLeaksScratch, + reporter, + ctxInfo, + CtsEnforcement::kNextRelease) { + auto dContext = ctxInfo.directContext(); + if (dContext->supportsProtectedContent()) { + return; // This test performs readbacks + } + + // internalWritePixels() calls this function, which flushes work the first time it's called. We + // don't want that to happen while the test's FailOnceCallback is installed, so trigger it now. + (void) dContext->priv().validPMUPMConversionExists(); + + static constexpr int kSize = 16; + // alpha=0xFF so the unpremul->premul shader is a no-op and we can compare exact bytes. + static constexpr SkColor kStalePixel = SkColorSetARGB(0xFF, 0x33, 0x22, 0x11); + static constexpr SkColor kSrcPixel = SkColorSetARGB(0xFF, 0x00, 0x88, 0x44); + + auto dstII = SkImageInfo::Make({kSize, kSize}, kRGBA_8888_SkColorType, kPremul_SkAlphaType); + auto srcII = dstII.makeAlphaType(kUnpremul_SkAlphaType); + auto surf = SkSurfaces::RenderTarget(dContext, skgpu::Budgeted::kYes, dstII); + if (!surf) { + return; + } + + SkBitmap srcBM; + srcBM.allocPixels(srcII, srcII.minRowBytes()); + srcBM.eraseColor(kStalePixel); + + // Prime: writePixels(unpremul kStalePixel). canvas2DFastPath uploads kStalePixel into a + // fresh kApprox scratch texture and draws it into |surf|. After the flush the scratch + // texture (still holding kStalePixel) is returned to the resource cache. + if (!surf->getCanvas()->writePixels(srcBM, 0, 0)) { + return; + } + dContext->flushAndSubmit(GrSyncCpu::kYes); + + // Arm a preFlush() that fails exactly once. When it fires on the flush for uploading to a + // temporary texture, we should propagate the failure and not use the temporary texture as the + // input for the draw that performs the final "write". + class FailOnceFlushCallback : public GrOnFlushCallbackObject { + public: + bool preFlush(GrOnFlushResourceProvider*) override { return ++fCount > 1; } + int count() const { return fCount; } + private: + int fCount = 0; + }; + FailOnceFlushCallback cb; + dContext->priv().addOnFlushCallbackObject(&cb); + + // writePixels(unpremul kSrcPixel): tempProxy is instantiated from the recycled + // scratch texture (still kStalePixel). The GrWritePixelsTask that would overwrite it with + // kSrcPixel is queued and then dropped by the failed flush, leaving the tempProxy stale. + srcBM.eraseColor(kSrcPixel); + bool ok = surf->getCanvas()->writePixels(srcBM, 0, 0); + + dContext->priv().drawingManager()->testingOnly_removeOnFlushCallbackObject(&cb); + + if (!ok) { + // Assuming the various branches are taken inside internalWritePixels to trigger the + // FailOnceFlushCallback, it should be propagated as a failure of the overall writePixels(). + REPORTER_ASSERT(reporter, cb.count() > 0); + return; + } + + // If none of the code paths triggered an internal flush from the write pixels, it should + // execute successfully when we flush here. + dContext->flushAndSubmit(GrSyncCpu::kYes); + + SkBitmap readback; + readback.allocPixels(dstII); + if (!surf->readPixels(readback, 0, 0)) { + ERRORF(reporter, "readback failed"); + return; + } + + SkColor got = readback.getColor(0, 0); + // Bug: |got| == kStalePixel (recycled scratch contents) instead of kSrcPixel. + REPORTER_ASSERT(reporter, + got == kSrcPixel, + "writePixels reported success but destination holds stale scratch " + "contents 0x%08x (expected 0x%08x, stale=0x%08x, preFlush hits=%d)", + got, kSrcPixel, kStalePixel, cb.count()); +}
Original Bug Report
Potential cross-origin texture leak via unchecked flushSurface in SurfaceContext::internalWritePixels
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: An unchecked return value of flushSurface() in Skia’s SurfaceContext can potentially allow a compromised renderer to leak stale GPU texture memory. If a forced flush fails during a staging pixel upload, the upload task is silently dropped while the function still reports success. This results in subsequent draw operations sampling stale, un-overwritten pixels from a recycled scratch texture into an attacker-readable SharedImage.
Affected files:
third_party/skia/src/gpu/ganesh/SurfaceContext.cpp
Estimated timestamp from git blame: 2021-02-11
Summary
There is a potential information disclosure vulnerability in Skia’s Ganesh rendering backend. Specifically, SurfaceContext::internalWritePixels() does not check the return value of flushSurface(). Under conditions where a flush fails (such as GPU memory exhaustion or drawing manager task errors), a queued pixel-upload task can be silently discarded while the function still reports success. This can allow a compromised renderer to read back uninitialized or stale GPU scratch textures containing prior cross-origin data.
Root Cause Analysis
In third_party/skia/src/gpu/ganesh/SurfaceContext.cpp, the canvas2DFastPath branch handles unpremultiplied source pixels by staging them into a temporary proxy (tempProxy) and then drawing that proxy into the destination with a premultiplication shader.
The inner staging upload is performed via a recursive writePixels() call, which reaches the following logic:
// third_party/skia/src/gpu/ganesh/SurfaceContext.cpp:600-605
if (!ownAllStorage) {
// If any pixmap doesn't own its pixels then we must flush so that the pixels are pushed to
// the GPU before we return.
dContext->priv().flushSurface(dstProxy); // <--- Return value is discarded
}
return true; // <--- Unconditional success returned
If the drawing manager’s flush fails (for example, due to resource allocator failures or on-flush callback errors), GrDrawingManager::flush() silently clears the DAG via removeRenderTasks() and returns false. However, because the return value at line 603 is discarded, the staging context reports that the upload succeeded.
This behavior contrasts with the hardened read-path counterpart in the same file, which correctly validates the flush outcome:
// third_party/skia/src/gpu/ganesh/SurfaceContext.cpp:295-300
bool hasPendingTasks =
dContext->priv().drawingManager()->getLastRenderTask(srcProxy.get()) != nullptr;
GrSemaphoresSubmitted flushResult = dContext->priv().flushSurface(srcProxy.get());
if (flushResult == GrSemaphoresSubmitted::kNo && hasPendingTasks) {
return false;
}
Potential Attack Scenario
An attacker controlling a compromised renderer might attempt to exploit this logic through the following potential steps:
- Context Sharing: Establish two independent raster interfaces sharing a single
GrDirectContexton the GPU thread. - Target Setup: Create a destination renderable SharedImage with premultiplied alpha.
- Priming a Failure: On the secondary interface, queue tasks into the shared drawing manager DAG that are guaranteed to fail during a flush. Examples include:
- Registering a Skia Promise Image with a corrupted lazy fulfillment callback designed to return
nullptr. - Queueing extremely large offscreen layers that force a failure in the resource allocator (
failedInstantiation) under simulated GPU memory pressure.
- Registering a Skia Promise Image with a corrupted lazy fulfillment callback designed to return
- Triggering the Fast Path: Issue a
WritePixelscommand on the primary interface using an unpremultiplied source alpha type. This mismatch bypasses direct texture upload and triggers thecanvas2DFastPathstaging logic. - Scratch Recycling: Skia allocates
tempProxywith an approximate fit (SkBackingFit::kApprox). This retrieves a recycled scratch texture fromGrResourceCachethat has not been cleared and still contains prior cross-origin data. - Silent Task Discard: The recursive staging upload queues a
GrWritePixelsTaskand forces a flush because the source pixmap does not own its storage. The flush fails due to the failing tasks primed in Step 3. The entire DAG (including the staging upload task) is silently cleared duringremoveRenderTasks(), but the failure is ignored. - Sampling Stale Pixels: The outer fast path completes by executing a draw from
tempProxy(which still holds the un-cleared, stale GPU memory) into the destination SharedImage. - Data Disclosure: The attacker marks the SharedImage cleared and reads it back via
ReadbackARGBImagePixelsINTERNAL, obtaining the leaked cross-origin pixels.
Note: These steps represent a potential attack flow based on static analysis of the codebase. Our tooling does not currently have the capability to run or verify functional exploit code.
Suggested Fix
Modify SurfaceContext::internalWritePixels() to check the return value of flushSurface(). If the flush fails and there are pending write tasks, the function should abort and return false, matching the defensive design of the sibling readPixels() implementation:
if (!ownAllStorage) {
bool hasPendingTasks =
dContext->priv().drawingManager()->getLastRenderTask(dstProxy) != nullptr;
GrSemaphoresSubmitted flushResult = dContext->priv().flushSurface(dstProxy);
if (flushResult == GrSemaphoresSubmitted::kNo && hasPendingTasks) {
return false;
}
}
Evaluated with Chrome root at commit: bf775e5d75cb9e1767e2cd02cc93efa0077d14a5
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.