CVE-2026-17757
Overview
Background
- Ganesh
- Skia’s GPU backend that records drawing into
OpsTaskunits of work and flushes them as GPU render passes. - `OpsTask`
- a batch of GPU ops targeting one render target that carries a stencil policy (
fInitialStencilContent) and picks the stencil attachment’s load/store ops when it executes. - Stencil load/store ops
GrLoadOp::kClear,GrLoadOp::kDiscardandGrLoadOp::kLoaddecide the attachment’s contents at render-pass start, whileGrStoreOp::kStore/GrStoreOp::kDiscarddecide whether results are written back; akDiscardload leaves the contents undefined.- `StencilContent::kDontCare`
- an
OpsTaskstate declaring the task does not depend on the prior stencil contents.
Root Cause Analysis
When fInitialStencilContent was StencilContent::kDontCare, the execute path unconditionally chose GrLoadOp::kDiscard for the stencil attachment. It paired this with a stencil store op of GrStoreOp::kStore, which is effectively always chosen because caps.discardStencilValuesAfterRenderPass() is forced to return false (per b/160958008, as the new comment notes), so stencilStoreOp only becomes GrStoreOp::kDiscard when that cap is true. A kDiscard load followed by a kStore writes undefined stencil contents back into the buffer, and because a stencil buffer can be reused by a later OpsTask that only adds a stencil after starting without one, uninitialized values could be persisted into a buffer that subsequent stencil-based rendering relies on. The invariant violated is that any stencil buffer whose results will be stored must first be given defined contents.
The fix maps the problematic case — a stencil exists and caps.performStencilClearsAsDraws() is false — to GrLoadOp::kClear, proactively clearing the buffer, and keeps GrLoadOp::kDiscard only when there is no stencil or clears are performed by draws. A new SK_DEBUG assert enforces exactly that condition whenever a discard load-op is still selected.
kDiscard stencil load-op with a kStore stencil store-op for a kDontCare task that owns a real stencil buffer, which stores undefined data into a reusable buffer; the fix substitutes GrLoadOp::kClear whenever a stencil exists and caps.performStencilClearsAsDraws() is false.Attack Path
- Create a stencil-clearing task
Draw content that needs a stencil (e.g., a complex antialiased path) so an
OpsTask(O1) clears and fills a stencil buffer with clear/store ops. - Start a non-stencil task that reuses the buffer
Begin a new
OpsTask(O2) that does not yet need a stencil; withfInitialStencilContent == StencilContent::kDontCareit previously selectedGrLoadOp::kDiscardwhile its store op staysGrStoreOp::kStore. - Force the second task to reuse the stencil
Get
O2to later attach and reuse the same stencil buffer, so the discard load leaves its contents undefined and the store writes those uninitialized values back. - Consume the corrupted stencil
Later stencil-based rendering reads the uninitialized stencil values, affecting rendering output; any escalation beyond incorrect rendering (information disclosure) is
(inferred).
Impact Assessment
OpsTask reuse pattern can cause uninitialized stencil memory to be used and stored, corrupting stencil-based rendering in the process that runs Ganesh (the GPU process (inferred)). Preconditions include a configuration where a stencil buffer exists, caps.performStencilClearsAsDraws() is false, and a stencil buffer is reused across tasks (the accompanying test additionally requires dynamic MSAA support). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. Escalation to an information leak or memory-corruption primitive beyond what the patch demonstrates is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
switchsrc/gpu/ganesh/ops/OpsTask.cpp |
modified | |
ifsrc/gpu/ganesh/ops/OpsTask.cpp |
modified | |
iftests/StencilClearTest.cpp |
modified |
Files Changed
gn/tests.gnisrc/gpu/ganesh/ops/OpsTask.cpptests/StencilClearTest.cpp
Audit Directions
- Discard-load paired with store-storeAudit every site selecting
GrLoadOp::kDiscardand confirm the pairedGrStoreOpis notkStoreon a resource whose contents are later consumed. - kDontCare / "don't care" attachment policiesReview other
StencilContentand attachment states that skip clearing yet keep the resource live and reusable acrossOpsTaskboundaries. - Caps-gated clear assumptionsCheck code guarded by
caps.performStencilClearsAsDraws()andcaps.discardStencilValuesAfterRenderPass()for invariants that no longer hold now that the latter is forced to return false.
Patch
From e6496d127941b51f348f4c880032547d4c440c37 Mon Sep 17 00:00:00 2001
From: Robert Phillips <robertphillips@google.com>
Date: Thu, 25 Jun 2026 13:21:59 +0000
Subject: [PATCH] Fix Ganesh stencil UMR
When, for a given OpsTask, the stencil ops are discard/store there was a possibility of uninitialized values to creep into the stencil buffer.
This CL reduces the cases in which discard will be used, mapping the problematic cases to clear/store.
Bug: b/502351526
Change-Id: Ic5d7e352d4c59426f103f2cfc39d5cf3fd879213
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1269136
Reviewed-by: Greg Daniel <egdaniel@google.com>
Commit-Queue: Robert Phillips <robertphillips@google.com>
---
diff --git a/gn/tests.gni b/gn/tests.gni
index ea33c23..2bb7714 100644
--- a/gn/tests.gni
+++ b/gn/tests.gni
@@ -513,6 +513,7 @@
"$_tests/SlugTest.cpp",
"$_tests/SmallPathRendererTest.cpp",
"$_tests/SrcSrcOverBatchTest.cpp",
+ "$_tests/StencilClearTest.cpp",
"$_tests/SurfaceDrawContextTest.cpp",
"$_tests/SurfaceSemaphoreTest.cpp",
"$_tests/TextBlobCacheTest.cpp",
diff --git a/src/gpu/ganesh/ops/OpsTask.cpp b/src/gpu/ganesh/ops/OpsTask.cpp
index 24ce928..6dbc866 100644
--- a/src/gpu/ganesh/ops/OpsTask.cpp
+++ b/src/gpu/ganesh/ops/OpsTask.cpp
@@ -589,7 +589,17 @@
GrLoadOp stencilLoadOp;
switch (fInitialStencilContent) {
case StencilContent::kDontCare:
- stencilLoadOp = GrLoadOp::kDiscard;
+ if (stencil && !caps.performStencilClearsAsDraws()) {
+ // This OpTask has a stencil, doesn't care about its contents,
+ // isn't clearing it with draws, and is going to store the result.
+ // In that case, we proactively clear it so that uninitialized data won't
+ // creep into the stencil buffer.
+ stencilLoadOp = GrLoadOp::kClear;
+ } else {
+ // This should only intentionally happen for the AtlasRenderTask which
+ // immediately inserts a clear.
+ stencilLoadOp = GrLoadOp::kDiscard;
+ }
break;
case StencilContent::kUserBitsCleared:
SkASSERT(!caps.performStencilClearsAsDraws());
@@ -624,6 +634,7 @@
// their store op might be "discard", and we currently make the assumption that a discard will
// not invalidate what's already in main memory. This is probably ok for now, but certainly
// something we want to address soon.
+ // b/160958008 forces discardStencilValuesAfterRenderPass to always return false.
GrStoreOp stencilStoreOp = (caps.discardStencilValuesAfterRenderPass() && !fMustPreserveStencil)
? GrStoreOp::kDiscard
: GrStoreOp::kStore;
@@ -647,6 +658,16 @@
if (markStencilCleared) {
stencil->markHasPerformedInitialClear();
}
+
+#if defined(SK_DEBUG)
+ if (stencilLoadOp == GrLoadOp::kDiscard) {
+ // The only time we should have a stencil discard load-op is when either:
+ // there is no stencil buffer
+ // or stencil clears are being performed by draws
+ SkASSERT(!stencil || caps.performStencilClearsAsDraws());
+ }
+#endif
+
flushState->setOpsRenderPass(renderPass);
renderPass->begin();
@@ -736,10 +757,10 @@
fTotalBounds.join(toMerge->fTotalBounds);
fRenderPassXferBarriers |= toMerge->fRenderPassXferBarriers;
if (fInitialStencilContent == StencilContent::kDontCare) {
- // Propogate the first stencil content that isn't kDontCare.
+ // Propagate the first stencil content that isn't kDontCare.
//
// Once the stencil has any kind of initial content that isn't kDontCare, then the
- // inital contents of subsequent opsTasks that get merged in don't matter.
+ // initial contents of subsequent opsTasks that get merged in don't matter.
//
// (This works because the opsTask all target the same render target and are in
// painter's order. kPreserved obviously happens automatically with a merge, and kClear
diff --git a/tests/StencilClearTest.cpp b/tests/StencilClearTest.cpp
new file mode 100644
index 0000000..6bb1155
--- /dev/null
+++ b/tests/StencilClearTest.cpp
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file.
+ */
+
+#include "include/core/SkCanvas.h"
+#include "include/core/SkPaint.h"
+#include "include/core/SkPathBuilder.h"
+#include "include/core/SkSurface.h"
+#include "include/gpu/ganesh/GrDirectContext.h"
+#include "include/gpu/ganesh/SkSurfaceGanesh.h"
+#include "tests/CtsEnforcement.h"
+#include "tests/Test.h"
+
+namespace {
+
+SkPath make_star() {
+ SkPathBuilder starPath;
+ starPath.moveTo(0.0f, -33.3333f);
+ starPath.lineTo(9.62f, -16.6667f);
+ starPath.lineTo(28.867f, -16.6667f);
+ starPath.lineTo(19.24f, 0.0f);
+ starPath.lineTo(28.867f, 16.6667f);
+ starPath.lineTo(9.62f, 16.6667f);
+ starPath.lineTo(0.0f, 33.3333f);
+ starPath.lineTo(-9.62f, 16.6667f);
+ starPath.lineTo(-28.867f, 16.6667f);
+ starPath.lineTo(-19.24f, 0.0f);
+ starPath.lineTo(-28.867f, -16.6667f);
+ starPath.lineTo(-9.62f, -16.6667f);
+ starPath.close();
+
+ return starPath.detach();
+}
+
+sk_sp<SkSurface> gpu_surface(GrDirectContext* dContext) {
+ SkSurfaceProps props(SkSurfaceProps::kDynamicMSAA_Flag, kUnknown_SkPixelGeometry);
+
+ SkImageInfo ii = SkImageInfo::Make(256, 256, kRGBA_8888_SkColorType, kPremul_SkAlphaType);
+ return SkSurfaces::RenderTarget(dContext,
+ skgpu::Budgeted::kYes,
+ ii,
+ /* sampleCount= */ 4,
+ kTopLeft_GrSurfaceOrigin,
+ &props,
+ /*shouldCreateWithMips=*/true);
+}
+
+static void disable_split_reduction(GrContextOptions* options) {
+ options->fReduceOpsTaskSplitting = GrContextOptions::Enable::kNo;
+}
+
+} // anonymous namespace
+
+// This test exercises b/502351526.
+// The ultimate goal is to:
+// create an OpsTask (O1) that uses a stencil buffer (S) and clears it
+// create a new OpsTask (O2) that doesn't use a stencil
+// later, get O2 to add a stencil (and, specifically, reuse S)
+// In the bug this will allow uninitialized values to creep into the
+// stencil buffer and, potentially, impact later stencil-based rendering.
+// When executed this test will trigger an assert if the bug reoccurs.
+DEF_GANESH_TEST_FOR_CONTEXTS(StencilClearTest,
+ skgpu::IsRenderingContext,
+ reporter,
+ ctxInfo,
+ disable_split_reduction,
+ CtsEnforcement::kNextRelease) {
+ GrDirectContext* dContext = ctxInfo.directContext();
+
+ SkPath star = make_star();
+
+ // This first draw clears the stencil buffer and fills it with the first star.
+ // The OpsTask (O1) has clear/store stencilOps and marks the stencil buffer as cleared.
+ sk_sp<SkSurface> s1 = gpu_surface(dContext);
+ if (!s1) {
+ return; // Dynamic MSAA isn't supported everywhere
+ }
+
+ {
+ SkCanvas* canvas = s1->getCanvas();
+
+ SkPaint paint;
+ paint.setColor(SK_ColorBLUE);
+ paint.setAntiAlias(true);
+
+ canvas->concat(SkMatrix::ScaleTranslate(4, 4, 128, 128));
+ canvas->drawPath(star, paint);
+ }
+
+ // This starts a new Task that doesn't (yet) need stencil.
+ // Previously, this OpsTask (O2) would have discard/store stencilOps. With
+ // the fix this will now have clear/store stencilOps.
+ sk_sp<SkSurface> s2 = gpu_surface(dContext);
+ {
+ SkCanvas* canvas = s2->getCanvas();
+
+ SkPaint paint;
+ paint.setColor(SK_ColorRED);
+ canvas->drawRect(SkRect::MakeWH(256, 256), paint);
+ }
+
+ // This block just serves to close the active Task from s2
Original Bug Report
Potential uninitialized GPU stencil memory leak in Skia Ganesh
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 without the Chrome Security team.
Overview: A logic flaw in Skia’s Ganesh backend allows an OpsTask to attach and store uninitialized stencil buffer contents if a subsequent task enables stencil usage on a shared proxy. On tile-based Vulkan GPUs, this causes the task to write undefined tile memory residue back into the stencil buffer. A subsequent task can then read this uninitialized data, leading to a potential cross-origin GPU memory leak observable via canvas readback.
Affected files:
third_party/skia/src/gpu/ganesh/ops/OpsTask.cppthird_party/skia/src/gpu/ganesh/SurfaceDrawContext.cppthird_party/skia/src/gpu/ganesh/ops/OpsTask.hgpu/command_buffer/service/shared_context_state.cc
Estimated timestamp from git blame: 2021-05-03
Summary
There is a potential information disclosure vulnerability in Skia’s Ganesh rendering backend. The issue stems from a synchronization failure between the GrRenderTargetProxy’s stencil flag and the individual OpsTask initialization states. When a stencil-requiring operation is recorded, it retroactively enables the stencil flag for the entire proxy, causing previously recorded tasks to attach the stencil buffer without properly initializing it. On tile-based Vulkan GPUs (primarily Android), this results in uninitialized tile memory residue being written to main memory and subsequently read by the attacker.
Technical Details
By reviewing the Skia source code, the vulnerability unfolds through the following mechanism:
- Task 0 (T0) Creation: A basic draw operation (no stencil required) is recorded into
OpsTaskT0. ItsfInitialStencilContentdefaults toStencilContent::kDontCare. The canvas’sSurfaceDrawContextcurrently hasfNeedsStencil = false. - Task Split: The task is split (e.g., due to an operation requiring a destination read).
SurfaceDrawContext::willReplaceOpsTaskis called. BecausefNeedsStencilis false, it does not flag T0 to preserve the stencil (fMustPreserveStencilremains false). - Task 1 (T1) Creation: A stencil-requiring operation is recorded into the new task T1.
SurfaceDrawContext::setNeedsStencil()setsfNeedsStencil = true, updates T1’s state tokUserBitsCleared, and crucially, setsneedsStencil = trueon the sharedGrRenderTargetProxy. - T0 Execution (The Leak): During flush,
OpsTask::onExecuteruns for T0. It seesproxy->needsStencil() == trueand attaches the stencil buffer. Because its state iskDontCare, it sets the load op toGrLoadOp::kDiscard(mapping toVK_ATTACHMENT_LOAD_OP_DONT_CARE). However,caps.discardStencilValuesAfterRenderPass()is hardcoded tofalsein Skia (due to bug b/160958008), andfMustPreserveStencilis false, so the store op becomesGrStoreOp::kStore. On tile-based Vulkan GPUs, this combination writes uninitialized on-chip tile memory (containing residue from prior cross-origin GPU passes) back to the stencil attachment in main memory. - T1 Execution (The Read): T1 executes with
kUserBitsCleared. If the attachment was cleared in a previous flush,stencil->hasPerformedInitialClear()is true. The code falls through toGrLoadOp::kLoad, causing T1 to load the uninitialized data stored by T0. The leaked bits dictate clipping, which can be read out as pixel colors.
Note: Chrome’s gpu/command_buffer/service/shared_context_state.cc disables fReduceOpsTaskSplitting, preserving task boundaries and preventing merge-time corrections of this state.
Potential Attacker Steps
Please note: These are suggested steps based on static code analysis. Our tooling agent does not yet have the ability to run code to produce a working Proof of Concept.
- Execute a WebGL or Canvas2D draw that requires a stencil, then force a flush (e.g., via
getImageData()). This creates the stencil attachment and sets its sticky “cleared” bit to true. - Draw a non-stencil primitive (creating T0).
- Execute an operation that forces a task split, such as drawing the canvas onto itself with a complex blend mode (triggering a destination read barrier).
- Draw a stencil-requiring primitive (creating T1).
- Force a flush by calling
getImageData()and read the pixel values. The drawn pixels will reveal the leaked stencil residue bits from the uninitialized GPU tile memory.
Suggested Fix
There are two primary ways to address this state mismatch:
- Fix in OpsTask Execution: In
OpsTask::onExecute(), iffInitialStencilContentiskDontCare, the rendering pass should explicitly avoid storing the stencil buffer unless it was the task that actively requested it. ForcingstencilStoreOp = GrStoreOp::kDiscardforkDontCaretasks would prevent the uninitialized tile memory from being written back to the attachment. - Fix in Proxy State Propagation: Alternatively, when
SurfaceDrawContext::setNeedsStencil()is called, it could iterate through all existing closed tasks that share the currentGrRenderTargetProxyand update theirfMustPreserveStencilorfInitialStencilContentstates, rather than just updating the activeOpsTask.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.