CVE-2026-17702
Overview
Background
- Ganesh
- Skia’s GPU backend that records drawing work as
GrRenderTaskobjects and replays them against the GPU during a flush. - MSAA resolve / mipmap regeneration
- post-draw GPU operations that copy a multisampled render target down to a single-sampled surface (
resolveRenderTarget) and rebuild a texture’s mip levels (regenerateMipMapLevels), tracked per proxy as “dirty” until performed. - `GrTextureResolveRenderTask`
- the render task that owns a list of
Resolveentries, each carryingfFlags(GrSurfaceProxy::ResolveFlags::kMSAA/kMipMaps) andfMSAAResolveRect, and performs those resolves inonExecute. - Dirty tracking
- per-proxy state (
mipmapsAreDirty, MSAA-dirty rect) that is optimistically cleared at record time byaddProxy(), on the assumption the recorded resolve task will actually run.
Root Cause Analysis
When a resolve was recorded, GrTextureResolveRenderTask::addProxy() immediately marked the target proxy’s mipmaps clean and its MSAA region resolved, trusting that the corresponding GrTextureResolveRenderTask would later execute. If the flush was dropped before task execution — for example a failing preFlush callback or resource-allocator failure — or if a per-target operation such as regenerateMipMapLevels failed, the proxy was left permanently marked clean even though the GPU never performed the resolve, violating the invariant that “dirty” tracking must reflect actual GPU state. The old onExecute compounded this by asserting SkASSERT(!texture->mipmapsAreDirty()) and never re-dirtying on failure.
The fix makes onExecute clear each Resolve::fFlags bit only when its operation actually succeeds, and adds GrTextureResolveRenderTask::endFlush() (enabled via requiresExplicitCleanup() returning true) which re-marks any proxy whose flags are still set as dirty through markMSAADirty() / markMipmapsDirty(). GrDrawingManager is likewise changed to call markMipmapsClean() only when gpu->regenerateMipMapLevels() returns success. This guarantees a subsequent flush will re-record the missing resolve instead of sampling stale or uninitialized data.
endFlush() for any resolve that did not execute.Attack Path
- Trigger a flush that will fail
Cause an in-flight flush to be dropped or partially fail (e.g. an allocation failure in an onFlush callback, mirrored by the test’s
FailingPreFlushCallbackreturningfalsefrompreFlush), so recordedGrTextureResolveRenderTaskwork is skipped. - Leave the proxy falsely "clean"
Because
addProxy()already marked mipmaps clean / MSAA resolved and (pre-patch) nothing unwinds this, the proxy’s dirty tracking now claims a resolve happened that never did. - Issue a subsequent draw that samples the proxy A later draw reads the render target or textures from its mip chain; the manager sees the proxy as clean and records no new resolve, so the GPU samples unresolved multisample contents or stale/uninitialized mip levels.
- Consume the incorrect pixel data
The logic error yields wrong rendering output derived from GPU memory that was never properly resolved or regenerated.
(inferred)Whether this can be shaped into an out-of-bounds read of adjacent GPU memory or any further primitive is not demonstrated by the diff.
Impact Assessment
(inferred) Any escalation to disclosure of adjacent GPU memory or memory corruption beyond incorrect rendering is not established by the patch itself.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/ganesh/GrTextureResolveRenderTask.cpp |
modified | |
FailingPreFlushCallbacktests/GrSurfaceResolveTest.cpp |
modified | |
iftests/GrSurfaceResolveTest.cpp |
modified | |
fortests/GrSurfaceResolveTest.cpp |
modified |
Files Changed
src/gpu/ganesh/GrDrawingManager.cppsrc/gpu/ganesh/GrTextureResolveRenderTask.cppsrc/gpu/ganesh/GrTextureResolveRenderTask.htests/GrSurfaceResolveTest.cpp
Audit Directions
- Optimistic state clearing before deferred workLook for code that mutates proxy/resource dirty or “clean” tracking at record time on the assumption a later task will run, without a compensating unwind if that task is dropped or fails.
- Ignored GPU operation return valuesAudit callers of fallible GPU calls such as
regenerateMipMapLevelsandresolveRenderTargetthat clear tracking state unconditionally instead of gating it on the operation’s success. - Missing explicit cleanup on failed flushesReview
GrRenderTasksubclasses that hold pre-committed side effects but do not overriderequiresExplicitCleanup()/endFlush()to restore invariants when a flush is abandoned.
Patch
From 04dbd15284b8b10a72da7636f23db5a077641437 Mon Sep 17 00:00:00 2001
From: Greg Daniel <egdaniel@google.com>
Date: Fri, 17 Jul 2026 13:28:05 -0400
Subject: [PATCH] [Ganesh] If a resolve task fails to execute unwind the dirty tracking.
If a flush fails for some reason then we can get in an inconsistent
state with our dirty rect tracking for msaa resolves and mip maps.
This happens because we immediately update the proxies tracking of these
values when we recording a resolve task. But if that resolve task
never executes for some reason then we can end up in a bad state.
This changes makes it so that if a resolve task is ended without ever
executing, then it resets the proxies state to what it was before.
Technically if the draws before the resolve also never execute we will
now be marking a region dirty that isn't neccessarily dirty. This
could cause an extra resolve on future draws but is safe. However in
practice is flushes fail, clients will usually either tear everything
down (and thus it doesn't matter), or repeat the same draws again
(which would end up with the same resolve rect anyways). So this
possible extra resolve doesn't have a large real world impact.
Bug: b/517973093
Change-Id: Icc54310b87061471631f7a81adcfe661794a4e9c
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1297216
Reviewed-by: Thomas Smith <thomsmit@google.com>
Commit-Queue: Greg Daniel <egdaniel@google.com>
---
diff --git a/src/gpu/ganesh/GrDrawingManager.cpp b/src/gpu/ganesh/GrDrawingManager.cpp
index 55b58d9..4fa514b 100644
--- a/src/gpu/ganesh/GrDrawingManager.cpp
+++ b/src/gpu/ganesh/GrDrawingManager.cpp
@@ -510,8 +510,9 @@
if (auto* textureProxy = proxy->asTextureProxy()) {
if (textureProxy->mipmapsAreDirty()) {
SkASSERT(textureProxy->peekTexture());
- gpu->regenerateMipMapLevels(textureProxy->peekTexture());
- textureProxy->markMipmapsClean();
+ if (gpu->regenerateMipMapLevels(textureProxy->peekTexture())) {
+ textureProxy->markMipmapsClean();
+ }
}
}
}
diff --git a/src/gpu/ganesh/GrTextureResolveRenderTask.cpp b/src/gpu/ganesh/GrTextureResolveRenderTask.cpp
index 76595fa..0cb2505 100644
--- a/src/gpu/ganesh/GrTextureResolveRenderTask.cpp
+++ b/src/gpu/ganesh/GrTextureResolveRenderTask.cpp
@@ -94,24 +94,25 @@
// Resolve all msaa back-to-back, before regenerating mipmaps.
SkASSERT(fResolves.size() == this->numTargets());
for (int i = 0; i < fResolves.size(); ++i) {
- const Resolve& resolve = fResolves[i];
+ Resolve& resolve = fResolves[i];
if (GrSurfaceProxy::ResolveFlags::kMSAA & resolve.fFlags) {
GrSurfaceProxy* proxy = this->target(i);
// peekRenderTarget might be null if there was an instantiation error.
if (GrRenderTarget* renderTarget = proxy->peekRenderTarget()) {
flushState->gpu()->resolveRenderTarget(renderTarget, resolve.fMSAAResolveRect);
+ resolve.fFlags &= ~GrSurfaceProxy::ResolveFlags::kMSAA;
}
}
}
// Regenerate all mipmaps back-to-back.
for (int i = 0; i < fResolves.size(); ++i) {
- const Resolve& resolve = fResolves[i];
+ Resolve& resolve = fResolves[i];
if (GrSurfaceProxy::ResolveFlags::kMipMaps & resolve.fFlags) {
// peekTexture might be null if there was an instantiation error.
GrTexture* texture = this->target(i)->peekTexture();
- if (texture && texture->mipmapsAreDirty()) {
- flushState->gpu()->regenerateMipMapLevels(texture);
- SkASSERT(!texture->mipmapsAreDirty());
+ if (texture && (!texture->mipmapsAreDirty() ||
+ flushState->gpu()->regenerateMipMapLevels(texture))) {
+ resolve.fFlags &= ~GrSurfaceProxy::ResolveFlags::kMipMaps;
}
}
}
@@ -119,6 +120,29 @@
return true;
}
+void GrTextureResolveRenderTask::endFlush(GrDrawingManager* drawingMgr) {
+ // Any flags still set here correspond to resolves that were recorded by addProxy() but never
+ // executed (the flush was dropped before render-task execution, this task was skipped because
+ // its targets failed to instantiate, or a per-target operation failed). Re-mark those proxies
+ // dirty so a subsequent flush will re-record the resolve.
+ SkASSERT(fResolves.size() == this->numTargets());
+ for (int i = 0; i < fResolves.size(); ++i) {
+ const Resolve& resolve = fResolves[i];
+ GrSurfaceProxy* proxy = this->target(i);
+ if (GrSurfaceProxy::ResolveFlags::kMSAA & resolve.fFlags) {
+ if (GrRenderTargetProxy* rtProxy = proxy->asRenderTargetProxy()) {
+ rtProxy->markMSAADirty(resolve.fMSAAResolveRect);
+ }
+ }
+ if (GrSurfaceProxy::ResolveFlags::kMipMaps & resolve.fFlags) {
+ if (GrTextureProxy* texProxy = proxy->asTextureProxy()) {
+ texProxy->markMipmapsDirty();
+ }
+ }
+ }
+ this->GrRenderTask::endFlush(drawingMgr);
+}
+
#ifdef SK_DEBUG
void GrTextureResolveRenderTask::visitProxies_debugOnly(const GrVisitProxyFunc&) const {}
#endif
diff --git a/src/gpu/ganesh/GrTextureResolveRenderTask.h b/src/gpu/ganesh/GrTextureResolveRenderTask.h
index 5eee9ed..5cd6d02 100644
--- a/src/gpu/ganesh/GrTextureResolveRenderTask.h
+++ b/src/gpu/ganesh/GrTextureResolveRenderTask.h
@@ -44,6 +44,12 @@
bool onExecute(GrOpFlushState*) override;
+ // addProxy() optimistically marks the proxy resolved/clean at recording time. If the flush
+ // is dropped before this task executes (or a per-target operation fails) we must restore the
+ // proxy's dirty state so a later flush will re-record the resolve.
+ bool requiresExplicitCleanup() const override { return true; }
+ void endFlush(GrDrawingManager*) override;
+
#if defined(GPU_TEST_UTILS)
const char* name() const final { return "TextureResolve"; }
#endif
diff --git a/tests/GrSurfaceResolveTest.cpp b/tests/GrSurfaceResolveTest.cpp
index 330c941..fbe52a1 100644
--- a/tests/GrSurfaceResolveTest.cpp
+++ b/tests/GrSurfaceResolveTest.cpp
@@ -21,18 +21,23 @@
#include "include/core/SkTypes.h"
#include "include/gpu/GpuTypes.h"
#include "include/gpu/ganesh/GrBackendSurface.h"
+#include "include/gpu/ganesh/GrContextOptions.h"
#include "include/gpu/ganesh/GrDirectContext.h"
#include "include/gpu/ganesh/GrTypes.h"
#include "include/gpu/ganesh/SkSurfaceGanesh.h"
+#include "include/gpu/ganesh/mock/GrMockTypes.h"
#include "include/private/gpu/ganesh/GrTypesPriv.h"
#include "src/core/SkColorData.h"
#include "src/gpu/SkBackingFit.h"
#include "src/gpu/Swizzle.h"
#include "src/gpu/ganesh/GrCaps.h"
#include "src/gpu/ganesh/GrDirectContextPriv.h"
+#include "src/gpu/ganesh/GrDrawingManager.h"
#include "src/gpu/ganesh/GrFragmentProcessor.h"
+#include "src/gpu/ganesh/GrOnFlushResourceProvider.h"
#include "src/gpu/ganesh/GrPaint.h"
#include "src/gpu/ganesh/GrProxyProvider.h"
+#include "src/gpu/ganesh/GrRenderTargetProxy.h"
#include "src/gpu/ganesh/GrSamplerState.h"
#include "src/gpu/ganesh/GrSurfaceProxy.h"
#include "src/gpu/ganesh/GrSurfaceProxyView.h"
@@ -356,3 +361,154 @@
}
}
}
+
+namespace {
+// A preFlush callback that fails the first N flushes, simulating an allocation failure in an
+// onFlush callback (e.g. atlas instantiation under memory pressure).
+class FailingPreFlushCallback : public GrOnFlushCallbackObject {
+public:
+ explicit FailingPreFlushCallback(int failCount) : fRemainingFailures(failCount) {}
+ bool preFlush(GrOnFlushResourceProvider*) override {
+ if (fRemainingFailures > 0) {
+ --fRemainingFailures;
+ return false;
+ }
+ return true;
+ }
+ bool retainOnFreeGpuResources() override { return true; }
+
+private:
+ int fRemainingFailures;
+};
+} // namespace
+
+// GrTextureResolveRenderTask::addProxy() optimistically marks a proxy's mipmaps clean / MSAA
+// resolved at recording time. If the owning flush is dropped without executing render tasks
+// (e.g., a preFlush callback or the resource allocator fails) the proxy must be re-dirtied so a
+// later flush will record a new resolve.
+DEF_GANESH_TEST(SurfaceResolveProxyStateAfterFailedFlush,
+ reporter,
+ /* options */,
+ CtsEnforcement::kNever) {
+ using ResolveFlags = GrSurfaceProxy::ResolveFlags;
+ using Enable = GrContextOptions::Enable;
+ using MipmapMode = GrSamplerState::MipmapMode;
+
+ for (auto reduceOpsTaskSplitting : {Enable::kYes, Enable::kNo}) {
+ for (int sampleCount : {1, 4}) {
+ GrMockOptions mockOptions;
+ mockOptions.fMipmapSupport = true;
+ mockOptions.fConfigOptions[(int)GrColorType::kRGBA_8888].fRenderability =
+ GrMockOptions::ConfigOptions::Renderability::kMSAA;
+ GrContextOptions ctxOptions;
+ ctxOptions.fReduceOpsTaskSplitting = reduceOpsTaskSplitting;
Original Bug Report
Potential State Desynchronization in GrTextureResolveRenderTask Exposes Stale GPU Memory
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 state desynchronization vulnerability exists in Skia’s Ganesh GPU backend where MSAA targets and mipmaps are eagerly marked resolved/clean during DAG recording. If a subsequent flush is aborted or skipped due to pre-flush or resource allocation failures, these state mutations are not rolled back. This can cause subsequent readbacks to skip necessary GPU-side resolves, potentially exposing recycled or stale GPU memory.
Affected files:
third_party/skia/src/gpu/ganesh/GrTextureResolveRenderTask.cppthird_party/skia/src/gpu/ganesh/SurfaceContext.cpp
Estimated timestamp from git blame: 2019-08-14
Vulnerability Mechanics
In Skia’s Ganesh GPU backend, GrTextureResolveRenderTask::addProxy() eagerly updates a surface proxy’s state during the Directed Acyclic Graph (DAG) recording phase, before any GPU commands are actually dispatched or executed:
// third_party/skia/src/gpu/ganesh/GrTextureResolveRenderTask.cpp
if (GrSurfaceProxy::ResolveFlags::kMSAA & newFlags) {
GrRenderTargetProxy* renderTargetProxy = proxy->asRenderTargetProxy();
...
resolve->fMSAAResolveRect = renderTargetProxy->msaaDirtyRect();
renderTargetProxy->markMSAAResolved(); // Cleared immediately during recording
}
if (GrSurfaceProxy::ResolveFlags::kMipMaps & newFlags) {
GrTextureProxy* textureProxy = proxy->asTextureProxy();
...
textureProxy->markMipmapsClean(); // Cleared immediately during recording
}
Similarly, inside SurfaceContext::internalWritePixels(), mipmaps are marked clean immediately after recording a write task:
// third_party/skia/src/gpu/ganesh/SurfaceContext.cpp
if (numLevels > 1) {
dstProxy->asTextureProxy()->markMipmapsClean();
}
During GrDrawingManager::flush(), several validations are performed. If a pre-flush callback fails (for instance, AtlasPathRenderer::preFlush() failing due to allocation limits under memory pressure), or if GrResourceAllocator::assign() fails due to a lazy proxy instantiation error, the drawing manager skips task execution entirely. It then clears the DAG using removeRenderTasks():
// third_party/skia/src/gpu/ganesh/GrDrawingManager.cpp
bool preFlushSuccessful = true;
for (GrOnFlushCallbackObject* onFlushCBObject : fOnFlushCBObjects) {
preFlushSuccessful &= onFlushCBObject->preFlush(&onFlushProvider);
}
...
if (preFlushSuccessful) {
...
cachePurgeNeeded = !resourceAllocator.failedInstantiation() &&
this->executeRenderTasks(&flushState); // Skipped on failure
}
this->removeRenderTasks(); // Clears DAG; proxies remain marked 'resolved' and 'clean'
Because removeRenderTasks() discards the scheduled tasks but does not roll back the state changes on the corresponding proxies, the proxies remain permanently marked as “resolved” and “clean” in memory, even though the actual GPU-side textures were never resolved or updated.
Potential Security Impact
If the proxy state is desynchronized from the actual GPU memory contents, subsequent operations that check the dirty flags will assume the buffers are up-to-date:
GrRenderTask::addDependency()andresolve_and_mipmap()will observe the proxy state as clean and skip inserting necessary resolve or regeneration tasks.- Subsequent readback or copy operations (e.g.,
asyncRescaleAndReadPixelsYUV420) will execute directly on the unresolved MSAA resolve attachment or uninitialized mip levels. - Since
GrResourceCacherecycles allocations across different origins under memory pressure, this allows a compromised renderer to potentially read back stale GPU memory belonging to other pages or contexts, resulting in a cross-origin read-only information disclosure.
Suggested Attack Vector / Trigger Steps (Theoretical)
An attacker could potentially attempt the following sequence to trigger the vulnerability:
- Allocate an MSAA-enabled surface or a mipmapped surface and perform drawing operations, marking the proxy dirty.
- Record a read or dependency task that schedules a resolve via
GrTextureResolveRenderTask::addProxy(), immediately clearing the proxy’s dirty flags in the recording state. - Artificially induce a resource allocation failure or pre-flush failure (e.g., by queueing extremely large or complex SVG path rendering commands to exhaust atlas allocation limits) in the same flush phase, forcing the drawing manager to skip execution of the DAG.
- Trigger a subsequent readback operation (such as
asyncRescaleAndReadPixelsYUV420) in a new flush. Since the proxy was already marked clean, the resolve is skipped, transferring the stale/unresolved GPU memory contents back to host memory.
Note: These steps are based on static code analysis and represent a potential path; actual behavior has not been validated with a functional proof-of-concept.
Suggested Remediation
To resolve this desynchronization, Skia should avoid mutating the proxy’s active state during the DAG recording phase. Alternatively, if eager mutation is required for dependency tracking, a rollback mechanism must be introduced.
One potential fix is to implement a rollback or state restoration mechanism inside GrDrawingManager::removeRenderTasks() (or during task destruction) to revert any pending proxy state changes back to their original dirty state if the task is discarded without being executed.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
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.