High watch CVSS 3.1 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Skia
DescriptionInappropriate implementation in Skia
ComponentSkia
Sub-componentGanesh
Bug ClassLogic Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker517973093
Fix commit04dbd15284b8 (skia) +194/-7
CWECWE-346 (Origin validation error)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-29 (per vendor advisory)
Fix landed2026-07-17 (public git)
Disclosed2026-07-29
Patch gap12 days — fix public in source before the advisory

Background

Ganesh
Skia’s GPU backend that records drawing work as GrRenderTask objects 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 Resolve entries, each carrying fFlags (GrSurfaceProxy::ResolveFlags::kMSAA / kMipMaps) and fMSAAResolveRect, and performs those resolves in onExecute.
Dirty tracking
per-proxy state (mipmapsAreDirty, MSAA-dirty rect) that is optimistically cleared at record time by addProxy(), 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.

Key insight
The single mistake is eagerly clearing dirty state at record time without unwinding it when the recorded work never runs, so a dropped or partially failed flush leaves a proxy claiming to be resolved when it is not; the fix ties flag clearing to actual execution success and restores dirty state in endFlush() for any resolve that did not execute.

Attack Path

  1. 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 FailingPreFlushCallback returning false from preFlush), so recorded GrTextureResolveRenderTask work is skipped.
  2. 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.
  3. 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.
  4. 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

An attacker who can drive Skia’s Ganesh backend into a failed flush (reachable from web content that exercises GPU-accelerated canvas/WebGL rendering) can leave a proxy’s dirty tracking inconsistent, causing later draws to sample unresolved or unregenerated GPU surfaces in the GPU process context. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1. (inferred) Any escalation to disclosure of adjacent GPU memory or memory corruption beyond incorrect rendering is not established by the patch itself.

Changed Functions

FunctionChangeNotes
if
src/gpu/ganesh/GrTextureResolveRenderTask.cpp
modified
FailingPreFlushCallback
tests/GrSurfaceResolveTest.cpp
modified
if
tests/GrSurfaceResolveTest.cpp
modified
for
tests/GrSurfaceResolveTest.cpp
modified

Files Changed

  • src/gpu/ganesh/GrDrawingManager.cpp
  • src/gpu/ganesh/GrTextureResolveRenderTask.cpp
  • src/gpu/ganesh/GrTextureResolveRenderTask.h
  • tests/GrSurfaceResolveTest.cpp

Audit Directions

  • Optimistic state clearing before deferred work
    Look 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 values
    Audit callers of fallible GPU calls such as regenerateMipMapLevels and resolveRenderTarget that clear tracking state unconditionally instead of gating it on the operation’s success.
  • Missing explicit cleanup on failed flushes
    Review GrRenderTask subclasses that hold pre-committed side effects but do not override requiresExplicitCleanup() / endFlush() to restore invariants when a flush is abandoned.
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;
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cpp
  • third_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:

  1. GrRenderTask::addDependency() and resolve_and_mipmap() will observe the proxy state as clean and skip inserting necessary resolve or regeneration tasks.
  2. Subsequent readback or copy operations (e.g., asyncRescaleAndReadPixelsYUV420) will execute directly on the unresolved MSAA resolve attachment or uninitialized mip levels.
  3. Since GrResourceCache recycles 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:

  1. Allocate an MSAA-enabled surface or a mipmapped surface and perform drawing operations, marking the proxy dirty.
  2. Record a read or dependency task that schedules a resolve via GrTextureResolveRenderTask::addProxy(), immediately clearing the proxy’s dirty flags in the recording state.
  3. 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.
  4. 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.

View on issue tracker