CVE-2026-19160
Overview
Background
- `internalWritePixels`
- The
skgpu::ganesh::SurfaceContextmethod that uploads client-supplied pixels into a GPU destination surface, sometimes via a “write-as-draw” path that stages the data through a temporary/scratch texture. - Internal flush
- A GPU flush that
internalWritePixelsmay trigger viadContext->priv().flushSurface(dstProxy)to push queuedGrRenderTasks (such as aGrWritePixelsTask) to the device before returning. - Scratch texture recycling
- Skia’s resource cache reuses freed
kApproxscratch textures, so a newly instantiated temporary proxy can be backed by memory still holding a previous draw’s pixel contents. - `preFlush()` / `GrOnFlushCallbackObject`
- A pre-flush hook whose failure (returning
false) can cause a flush to abort, dropping the render tasks it was meant to execute.
Root Cause Analysis
On the write-as-draw path, internalWritePixels staged the client pixels into a temporary scratch texture and enqueued a GrWritePixelsTask to fill it, then relied on an internal flush to execute that upload before the temporary texture was drawn into the real destination. The caps-dependent pre-flush lived in GrDrawingManager::newWritePixelsTask, and the trailing flush for non-owned source data (if (!ownAllStorage)) invoked dContext->priv().flushSurface(dstProxy) but discarded its return value, so a failed flush was never propagated. When a flush aborted, the queued GrWritePixelsTask was dropped, leaving the recycled scratch texture holding stale contents from a prior draw, yet internalWritePixels still returned true — violating the invariant that a successful writePixels means the caller’s pixels reached the destination.
The fix adds a flushSurfaceAndCheckSuccess helper that inspects the GrSemaphoresSubmitted result, performs the caps-dependent flush inside internalWritePixels, and returns false when the required flush did not succeed, so the stale-texture draw is reported as a failure instead of silently producing uninitialized output.
flushSurface for a correctness-critical upload and ignoring whether it actually succeeded, allowing a dropped GrWritePixelsTask to leave a recycled scratch texture uninitialized while writePixels reported success; the fix routes both flushes through flushSurfaceAndCheckSuccess and propagates a failed flush as a false return.Attack Path
- Prime the scratch cache
A page issues a
writePixelswhosecanvas2DFastPathuploads known pixels into a freshkApproxscratch texture; after the flush that texture is returned to the resource cache still holding those bytes. - Trigger a second write-as-draw
A subsequent
writePixelswith non-owned source data instantiates a temporary proxy from that recycled scratch texture and queues aGrWritePixelsTaskto overwrite it with the new pixels. - Fail the internal flush
The flush meant to execute that upload aborts (in the test, via a
preFlush()that fails once), so theGrWritePixelsTaskis dropped and the temporary texture keeps its stale contents. - Consume stale pixels
Because the pre-patch code ignored the flush result,
internalWritePixelsreturns success and draws the uninitialized/stale scratch texture into the destination surface, which the caller can then read back — an information disclosure of recycled GPU memory(inferred)as to what other content could occupy that memory. - Escalation
Any read/write primitive, sandbox escape, or code execution beyond disclosing stale GPU-texture bytes is
(inferred)and not demonstrated by the diff.
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/ganesh/SurfaceContext.cpp |
modified | |
iftests/ReadWritePixelsGpuTest.cpp |
modified | |
FailOnceFlushCallbacktests/ReadWritePixelsGpuTest.cpp |
modified |
Files Changed
src/gpu/ganesh/SurfaceContext.cpptests/ReadWritePixelsGpuTest.cppsrc/gpu/ganesh/GrDrawingManager.cpp
Audit Directions
- Ignored flush/submit return valuesSearch for callers of
flushSurface,flushAndSubmit, or similar that discardGrSemaphoresSubmittedwhen the flush is required for correctness, since an aborted flush can silently drop queuedGrRenderTasks. - Recycled scratch/approx resourcesReview paths that instantiate temporary proxies from
kApproxor cached scratch textures and assume a subsequent upload task will run, as a failed upload leaves them holding stale, uninitialized contents. - Success reported before work executesAudit routines that return success after merely enqueuing GPU tasks (e.g., write/draw-as-upload helpers) without confirming the tasks were actually flushed and submitted.
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.