CVE-2026-16417
Overview
Background
- Ganesh
- Skia’s GPU backend that records drawing work into render tasks and later flushes and submits them to the GPU.
- `GrDrawingManager`
- the Ganesh component that owns the queued render tasks and executes them during a flush, returning whether the flush completed.
- `SurfaceContext::readPixels`
- the readback path that flushes a source proxy’s pending draws and then copies the rendered texels back to the CPU, sometimes via an intermediate scratch surface.
- Uninitialized/stale readback
- reading pixels from GPU memory (here, a recycled scratch texture from the resource cache) whose intended contents were never actually rendered.
Root Cause Analysis
In SurfaceContext.cpp the readback path called dContext->priv().flushSurface(srcProxy.get()) and then unconditionally proceeded to dContext->submit() and getGpu()->readPixels(...), ignoring whether the flush that was supposed to render the source content actually succeeded. Inside GrDrawingManager the flush routine returned a hardcoded true even when resourceAllocator.failedInstantiation() was true or executeRenderTasks was never run, so a failed flush was indistinguishable from a successful one to the caller. The violated invariant is that a readback must only occur after the pixels it reads have been rendered; when the flush dropped the queued render tasks, readPixels read whatever stale data was already resident in the (recycled scratch) texture.
The fix threads a real flushSuccessful status out of GrDrawingManager (only set when tasks are actually executed) and has SurfaceContext::readPixels detect the failure: if flushSurface returns GrSemaphoresSubmitted::kNo while there were pending tasks (getLastRenderTask(srcProxy.get()) != nullptr), it returns false instead of reading stale texels. This works because it aligns the success reported to the caller with whether rendering truly happened, so a failed flush aborts the readback rather than returning garbage.
GrDrawingManager’s flush always reported success (return true) and SurfaceContext::readPixels never checked the flush result, so a dropped/failed flush silently fell through to a readback of unrendered memory. The fix propagates the true flushSuccessful state and makes readPixels bail out when a flush of a proxy with pending tasks did not run.Attack Path
- Seed a scratch surface with known stale contents Trigger a readback that draws through an intermediate approx-fit scratch surface so a recycled scratch texture holding leftover data lands in the resource cache.
- Queue a new draw and force the flush to fail
Enqueue a draw whose render tasks target that path, then cause the implicit flush to fail or drop its render tasks (in the test, via an
GrOnFlushCallbackObjectwhosepreFlushreturnsfalse), soexecuteRenderTasksnever runs. - Reach the unchecked readback
SurfaceContext::readPixelsignores the flush result and callsgetGpu()->readPixels, returning the recycled scratch texture’s stale/uninitialized texels while reporting success. - Recover leaked memory contents
The attacker reads back pixel values that were never rendered by the intended draw, disclosing residual GPU texture memory
(inferred).
Impact Assessment
(inferred). Preconditions are the ability to trigger a readback through an intermediate scratch surface and to induce a failed/dropped flush of the source proxy’s pending tasks. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1. Any escalation beyond stale-data disclosure is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/ganesh/GrDrawingManager.cpp |
modified | |
ifsrc/gpu/ganesh/SurfaceContext.cpp |
modified | |
FailingFlushCallbacktests/ReadWritePixelsGpuTest.cpp |
modified | |
iftests/ReadWritePixelsGpuTest.cpp |
modified |
Files Changed
src/gpu/ganesh/GrDrawingManager.cppsrc/gpu/ganesh/SurfaceContext.cpptests/ReadWritePixelsGpuTest.cpp
Audit Directions
- Ignored flush/submit resultsSearch for call sites that flush or submit GPU work and then read back or reuse the result without checking the returned status (e.g.
flushSurface,GrSemaphoresSubmitted), since a silently failed flush can leave stale data readable. - Hardcoded success returnsLook for functions like
GrDrawingManager::flushthatreturn trueregardless of whether the underlying work (executeRenderTasks, allocator instantiation) actually completed, masking failures from callers. - Recycled scratch/approx-fit surfacesAudit paths that render into intermediate scratch or approx-fit surfaces drawn from the resource cache, where a skipped render leaves the previous tenant’s contents exposed to readback.
Patch
From 731888c38ee41e5892488f1edb8b45160fa9863c Mon Sep 17 00:00:00 2001
From: Thomas Smith <thomsmit@google.com>
Date: Thu, 16 Jul 2026 11:01:22 -0400
Subject: [PATCH] [ganesh] prevent stale readbacks
* Ganesh's SurfaceContext::readPixels did not consider whether the content it was attempting to readback was successfully rendered or not, leading to a scenario where stale texture data could potentially be readback.
* Add some state tracking so that a failed flush is propagated out of the drawing manager and to the surface context
Bug: b/521491024
Change-Id: Idb2b5eaccda7f4ec388e3dc415ee41d329d28533
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1295737
Reviewed-by: Michael Ludwig <michaelludwig@google.com>
Commit-Queue: Thomas Smith <thomsmit@google.com>
---
diff --git a/src/gpu/ganesh/GrDrawingManager.cpp b/src/gpu/ganesh/GrDrawingManager.cpp
index 398bc35..55b58d9 100644
--- a/src/gpu/ganesh/GrDrawingManager.cpp
+++ b/src/gpu/ganesh/GrDrawingManager.cpp
@@ -175,6 +175,7 @@
}
bool cachePurgeNeeded = false;
+ bool flushSuccessful = false;
if (preFlushSuccessful) {
bool usingReorderedDAG = false;
@@ -205,8 +206,10 @@
resourceAllocator.assign();
}
- cachePurgeNeeded = !resourceAllocator.failedInstantiation() &&
- this->executeRenderTasks(&flushState);
+ if (!resourceAllocator.failedInstantiation()) {
+ cachePurgeNeeded = this->executeRenderTasks(&flushState);
+ flushSuccessful = true;
+ }
}
this->removeRenderTasks();
@@ -226,7 +229,7 @@
}
fFlushing = false;
- return true;
+ return flushSuccessful;
}
bool GrDrawingManager::submitToGpu() {
diff --git a/src/gpu/ganesh/SurfaceContext.cpp b/src/gpu/ganesh/SurfaceContext.cpp
index ba6bb93..efc3f09 100644
--- a/src/gpu/ganesh/SurfaceContext.cpp
+++ b/src/gpu/ganesh/SurfaceContext.cpp
@@ -292,7 +292,12 @@
pt.fY = flip ? srcSurface->height() - pt.fY - dst.height() : pt.fY;
}
- dContext->priv().flushSurface(srcProxy.get());
+ bool hasPendingTasks =
+ dContext->priv().drawingManager()->getLastRenderTask(srcProxy.get()) != nullptr;
+ GrSemaphoresSubmitted flushResult = dContext->priv().flushSurface(srcProxy.get());
+ if (flushResult == GrSemaphoresSubmitted::kNo && hasPendingTasks) {
+ return false;
+ }
dContext->submit();
if (!dContext->priv().getGpu()->readPixels(srcSurface,
SkIRect::MakePtSize(pt, dst.dimensions()),
diff --git a/tests/ReadWritePixelsGpuTest.cpp b/tests/ReadWritePixelsGpuTest.cpp
index 143ff72..c64b79e 100644
--- a/tests/ReadWritePixelsGpuTest.cpp
+++ b/tests/ReadWritePixelsGpuTest.cpp
@@ -43,7 +43,9 @@
#include "src/gpu/ganesh/GrCaps.h"
#include "src/gpu/ganesh/GrDataUtils.h"
#include "src/gpu/ganesh/GrDirectContextPriv.h"
+#include "src/gpu/ganesh/GrDrawingManager.h"
#include "src/gpu/ganesh/GrImageInfo.h"
+#include "src/gpu/ganesh/GrOnFlushResourceProvider.h"
#include "src/gpu/ganesh/GrPixmap.h"
#include "src/gpu/ganesh/GrSamplerState.h"
#include "src/gpu/ganesh/GrSurfaceProxy.h"
@@ -243,6 +245,19 @@
template <typename T>
using GpuReadDstFn = SkAutoPixmapStorage(const T&);
+class FailingFlushCallback : public GrOnFlushCallbackObject {
+public:
+ bool preFlush(GrOnFlushResourceProvider*) override {
+ ++fCount;
+ return false;
+ }
+
+ int count() const { return fCount; }
+
+private:
+ int fCount = 0;
+};
+
} // anonymous namespace
SkPixmap make_pixmap_have_valid_alpha_type(SkPixmap pm) {
@@ -1513,3 +1528,61 @@
ComparePixels(syncResult.pixmap(), asyncResult, tol, error);
}
+
+// readPixels() that uses an intermediate scratch surface should not return stale recycled scratch
+// contents if the implicit flush of the queued draw into that surface drops its render tasks.
+DEF_GANESH_TEST_FOR_RENDERING_CONTEXTS(ReadPixelsIntermediateFailedFlush,
+ reporter,
+ ctxInfo,
+ CtsEnforcement::kNextRelease) {
+ auto dContext = ctxInfo.directContext();
+ if (dContext->supportsProtectedContent()) {
+ return;
+ }
+
+ static constexpr int kSize = 100;
+ static constexpr SkColor kStaleColor = 0xFF112233;
+ static constexpr SkColor kSrcColor = 0xFF008800;
+
+ auto srcII = SkImageInfo::Make({kSize, kSize}, kRGBA_8888_SkColorType, kPremul_SkAlphaType);
+ auto dstII = srcII.makeAlphaType(kUnpremul_SkAlphaType);
+ auto surf = SkSurfaces::RenderTarget(dContext, skgpu::Budgeted::kYes, srcII);
+ if (!surf) {
+ return;
+ }
+
+ SkAutoPixmapStorage pixels;
+ pixels.alloc(dstII);
+
+ // Reading the unpremul destination from the premul source draws through an intermediate
+ // approx-fit surface. Do this once so a matching scratch surface lands in the resource cache
+ // with known stale contents.
+ surf->getCanvas()->clear(kStaleColor);
+ if (!surf->readPixels(pixels, 0, 0)) {
+ return;
+ }
+
+ surf->getCanvas()->clear(kSrcColor);
+ dContext->flushAndSubmit(GrSyncCpu::kYes);
+
+ // From here on the flush-time callback fails so any queued render tasks are discarded.
+ FailingFlushCallback failingCallback;
+ dContext->priv().addOnFlushCallbackObject(&failingCallback);
+
+ pixels.erase(SkColors::kTransparent);
+ bool ok = surf->readPixels(pixels, 0, 0);
+
+ dContext->priv().drawingManager()->testingOnly_removeOnFlushCallbackObject(&failingCallback);
+
+ if (ok) {
+ SkColor result = pixels.getColor(0, 0);
+ REPORTER_ASSERT(reporter,
+ result == kSrcColor,
+ "readPixels reported success but returned 0x%08x, expected 0x%08x",
+ result,
+ kSrcColor);
+ } else {
+ REPORTER_ASSERT(reporter, failingCallback.count() > 0);
+ }
+}
+
Original Bug Report
Potential GPU-Process Cross-Origin Texture Disclosure via Ganesh SurfaceContext::readPixels
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 logic vulnerability in Skia’s Ganesh backend could allow a compromised renderer to read cross-origin texture data. When executing a synchronous readback via the fast-path copy branch, Skia eagerly instantiates a scratch render target which may contain stale texture contents from another origin. If the subsequent drawing manager flush fails due to GPU memory pressure or atlas allocation issues, the copy operation is silently dropped while the readback still returns success with the stale data.
Affected files:
third_party/skia/src/gpu/ganesh/SurfaceContext.cppthird_party/skia/src/gpu/ganesh/GrDrawingManager.cppthird_party/skia/src/gpu/ganesh/ops/AtlasPathRenderer.cppthird_party/skia/src/gpu/ganesh/GrResourceAllocator.cpp
Estimated timestamp from git blame: 2019-05-10
Description
A potential information disclosure vulnerability exists in Skia’s Ganesh backend, specifically within SurfaceContext::readPixels. When a readback utilizes the fast-path copy branch (canvas2DFastPath or kCopyToTexture2D), Skia allocates an internal kApprox scratch render target to perform the readback. Under certain circumstances where a subsequent flush fails, this can allow a compromised renderer to read stale recycled-texture contents (cross-origin pixel bytes) from the global, shared cache instead of the correct source pixels.
Root Cause Analysis
In third_party/skia/src/gpu/ganesh/SurfaceContext.cpp:
if (readFlag == GrCaps::SurfaceReadPixelsSupport::kCopyToTexture2D || canvas2DFastPath) {
...
auto sfc = dContext->priv().makeSFC(
tempInfo, "SurfaceContext_ReadPixels", SkBackingFit::kApprox); // 1. Deferred approx RT proxy
...
sfc->fillRectToRectWithFP(..., std::move(fp)); // 2. Queues OpsTask in shared DAG
pt = {0, 0};
tempCtx = std::move(sfc);
...
return tempCtx->readPixels(dContext, dst, pt); // 3. Recurse on temp surface
}
The recursive call re-enters readPixels with srcProxy equal to the temporary SFC’s proxy. Because this proxy is non-lazy, it is immediately instantiated:
if (!srcProxy->instantiate(dContext->priv().resourceProvider())) {
return false;
}
Since SkBackingFit::kApprox is used, the resource provider queries the shared GrResourceCache for a matching scratch texture. Because the GrResourceCache is shared globally on the GPU thread across all renderers on the same context, the returned texture may contain another origin’s last-rendered pixels.
Following instantiation, the code reaches the direct-read path:
dContext->priv().flushSurface(srcProxy.get()); // 4. flushSurface() return value is IGNORED
dContext->submit(); // 5. submit() return value is IGNORED
if (!dContext->priv().getGpu()->readPixels(srcSurface, ..., readDst, readRB)) {
return false;
}
When flushSurface() invokes GrDrawingManager::flush(), several gates are checked. If any GrOnFlushCallbackObject::preFlush() returns false (e.g., AtlasPathRenderer::preFlush on atlas-texture allocation failure under GPU memory pressure) or if GrResourceAllocator::assign() fails to instantiate any other co-pending lazy proxy in the shared DAG:
- The entire
executeRenderTasks()execution is skipped. - The drawing manager calls
removeRenderTasks(), which discards all tasks in the DAG, including the attacker’s queuedfillRectToRectWithFPcopy task. flush()still returnstrue(signaling success but executing no tasks).
Because the copy task was discarded, the recycled scratch texture is never overwritten. The subsequent call to GrGpu::readPixels proceeds to copy the stale, recycled scratch texture contents containing the victim’s cross-origin pixel bytes into the caller’s shared memory buffer and reports success.
Potential Step-by-Step Attack Path
(Note: These are potential steps as our tooling does not currently have the capability to run code or compile a working PoC.)
- A compromised renderer causes a victim origin to perform GPU rasterization of a specific, predictable approximate size (e.g., $256 \times 256$ pixels), ensuring a dirty texture of that size is released and recycled into
GrResourceCache’sfScratchMap. - The attacker’s command buffer channel queues work in the shared DAG that is guaranteed to fail during flush (e.g., complex clipped paths targeting
AtlasPathRendereror allocating extremely large SharedImages to induce memory pressure). - The attacker issues a
ReadbackARGBImagePixelsINTERNALIPC command on the raster command buffer, settingdst_sk_alpha_typetokUnpremul_SkAlphaTypeanddst_sk_color_typetokRGBA_8888_SkColorTypeto forcecanvas2DFastPathduringreadPixels. - Skia’s
readPixelsrecycles the victim’s scratch texture but fails to run the draw-overwrite task because the pre-flush/allocator failure aborts render task execution. - The driver maps the un-overwritten GPU texture and copies the raw stale pixel data directly into the attacker’s shared memory buffer, returning success.
Suggested Fix
Propagate flush execution failures or require that any temporary scratch target allocated with SkBackingFit::kApprox is explicitly zero-initialized or cleared before reading back if the associated drawing tasks were skipped. Alternatively, ensure that SurfaceContext::readPixels validates that the required render tasks successfully executed before copying data from the GPU texture.
Evaluated with Chrome root at commit: 3947e01999a53d4e2382e39736cb79d79c7dffcf
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.