CVE-2026-15766
Overview
Background
- Stencil attachment
- an auxiliary GPU buffer (
GrAttachment) attached to a render target whose per-pixel values gate rasterization, and which must be initialized (cleared) before its contents are read. - Load op
- the operation a render pass performs on an attachment at its start;
GrLoadOp::kClearfills it with a constant,GrLoadOp::kDiscardleaves it undefined, and a load reads back the attachment’s existing memory. - Initial clear tracking
- Skia’s bookkeeping for whether a stencil attachment has already been cleared, used to decide whether a later render pass can safely load the existing values instead of re-clearing them.
- Tiler GPU
- a mobile-class GPU (ARM, Imagination, Qualcomm per
is_tiler_gpu) where a full clear plus discard is cheaper than a load, which is why Skia tracks cleared regions to skip redundant clears.
Root Cause Analysis
Before the patch, GrAttachment recorded initial-clear state with a single boolean fHasPerformedInitialClear, exposed through hasPerformedInitialClear() and set by markHasPerformedInitialClear(). When a render pass in OpsTask.cpp cleared only the sub-rectangle described by fClippedContentBounds, the code nonetheless marked the entire attachment as cleared, violating the invariant that “cleared” must mean every pixel that will later be loaded has been initialized. A subsequent render pass covering a larger or disjoint area would then observe the attachment as already cleared and issue a load op over the uncleared region, reading uninitialized stencil memory.
The fix replaces the boolean with an SkIRect fClearedArea and the containment predicate hasAreaBeenCleared(SkIRect) (backed by SkIRect::contains), and computes boundsRequiredByStencil from fClippedContentBounds so a load is only taken when the required area is actually contained in the previously cleared area. It also adds explicit caps flags fDiscardStencilValuesAfterRenderPass and fClearsAreFasterThanLoads so the per-area tracking is skipped only when it is provably unnecessary (values are discarded each pass, or a clear is always issued), closing the path that let uninitialized values be loaded.
fHasPerformedInitialClear flag, so a partial clear was mistaken for a full clear; the fix tracks the concrete cleared rectangle in fClearedArea and requires hasAreaBeenCleared() to hold for boundsRequiredByStencil before permitting a load.Attack Path
- Reach the stencil load path
Cause the renderer to run content on a tiler GPU where
caps.discardStencilValuesAfterRenderPass()is false but load-vs-clear tracking applies, and wherefInitialStencilContentisStencilContent::kUserBitsCleared(orkDontCarefalling through) soOpsTaskdecides betweenGrLoadOp::kClearand a load. - Trigger a partial clear
Drive a render pass whose
fClippedContentBoundscovers only part of the stencil attachment, so pre-patch code performs a sub-rectangle clear yet marks the whole attachment cleared viamarkHasPerformedInitialClear(). - Force a load over uncleared pixels
Issue a later render pass over a larger or disjoint region; because the attachment reads as already cleared,
OpsTaskselects a load op and the GPU reads never-initialized stencil memory. - Consume uninitialized stencil values Subsequent draws are gated by the uninitialized stencil contents, producing rendering that depends on stale GPU memory. (inferred)
- Escalate from the uninitialized read Any information disclosure, out-of-bounds access, or further corruption beyond the uninitialized stencil read is not demonstrated by this diff. (inferred)
Impact Assessment
is_tiler_gpu and a render sequence that clears only a sub-region before a wider load; the advisory classifies this as Uninitialized Use in Skia (bug class UninitializedMemory) and does not state exploited-in-the-wild status, and any escalation to information leak or code execution beyond the uninitialized read is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
switchsrc/gpu/ganesh/ops/OpsTask.cpp |
modified |
Files Changed
src/gpu/ganesh/GrAttachment.hsrc/gpu/ganesh/GrCaps.cppsrc/gpu/ganesh/GrCaps.hsrc/gpu/ganesh/gl/GrGLCaps.cppsrc/gpu/ganesh/ops/OpsTask.cpp
Audit Directions
- Boolean-for-region stateLook for single boolean flags (like the former
fHasPerformedInitialClear) that summarize whether a spatial region was initialized; any place that sets such a flag after a partial operation can hide uninitialized sub-regions. - Load-vs-clear decisionsAudit other attachment types and render-pass paths that choose
GrLoadOp::kLoadbased on prior-clear assumptions, verifying the loaded bounds are provably contained in the actually-cleared area rather than in the full attachment. - Caps-gated initialization skipsReview code paths that skip clears/initialization based on capability flags such as
discardStencilValuesAfterRenderPass()orclearsAreFasterThanLoads(), ensuring the skip condition truly guarantees no later read of uninitialized memory.
Patch
From ca9aecfbf4e27de60619a4db811575e3fd1e1af6 Mon Sep 17 00:00:00 2001
From: Greg Daniel <egdaniel@google.com>
Date: Mon, 29 Jun 2026 11:45:26 -0400
Subject: [PATCH] Check clipped renderpass bounds against cleared stencil area
* If only a portion of the stencil attachment is cleared, we should not mark the entire attachment as cleared. This CL makes it such that we check which area has most recently been cleared to make an informed decision.
Bug: b/514010477
Change-Id: I3800ca3125f25341dffd818f6c557692020027be
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1255256
Reviewed-by: Robert Phillips <robertphillips@google.com>
Commit-Queue: Greg Daniel <egdaniel@google.com>
---
diff --git a/src/gpu/ganesh/GrAttachment.h b/src/gpu/ganesh/GrAttachment.h
index a7b9ad5..a7d00d2 100644
--- a/src/gpu/ganesh/GrAttachment.h
+++ b/src/gpu/ganesh/GrAttachment.h
@@ -53,8 +53,11 @@
skgpu::Mipmapped mipmapped() const { return fMipmapped; }
- bool hasPerformedInitialClear() const { return fHasPerformedInitialClear; }
- void markHasPerformedInitialClear() { fHasPerformedInitialClear = true; }
+ SkIRect clearedArea() const { return fClearedArea; }
+ bool hasAreaBeenCleared(SkIRect attachmentArea) const {
+ return fClearedArea.contains(attachmentArea);
+ }
+ void markAreaCleared(SkIRect area) { fClearedArea = area; }
// This unique key is used for attachments of the same dimensions, usage, and sample cnt which
// are shared between multiple render targets at the same time. Only one usage flag may be
@@ -117,7 +120,9 @@
UsageFlags fSupportedUsages;
int fSampleCnt;
skgpu::Mipmapped fMipmapped;
- bool fHasPerformedInitialClear = false;
+ // Track which area of the attachment has already been cleared to cut down on unnecessary clear
+ // operations, which can be more expensive on desktop GPUs than loads.
+ SkIRect fClearedArea = SkIRect::MakeEmpty();
GrMemoryless fMemoryless;
using INHERITED = GrSurface;
diff --git a/src/gpu/ganesh/GrCaps.cpp b/src/gpu/ganesh/GrCaps.cpp
index 38610a4..ae76495 100644
--- a/src/gpu/ganesh/GrCaps.cpp
+++ b/src/gpu/ganesh/GrCaps.cpp
@@ -43,6 +43,8 @@
fUsePrimitiveRestart = false;
fPreferClientSideDynamicBuffers = false;
fPreferFullscreenClears = false;
+ fDiscardStencilValuesAfterRenderPass = false;
+ fClearsAreFasterThanLoads = false;
fTwoSidedStencilRefsAndMasksMustMatch = false;
fMustClearUploadedBufferData = false;
fShouldInitializeTextures = false;
@@ -227,7 +229,10 @@
writer->appendBool("MSAA Resolves Automatically", fMSAAResolvesAutomatically);
writer->appendBool("Use primitive restart", fUsePrimitiveRestart);
writer->appendBool("Prefer client-side dynamic buffers", fPreferClientSideDynamicBuffers);
- writer->appendBool("Prefer fullscreen clears (and stencil discard)", fPreferFullscreenClears);
+ writer->appendBool("Prefer fullscreen clears", fPreferFullscreenClears);
+ writer->appendBool("Discard stencil values after renderpass",
+ fDiscardStencilValuesAfterRenderPass);
+ writer->appendBool("Clears are faster than loads", fClearsAreFasterThanLoads);
writer->appendBool("Two-sided Stencil Refs And Masks Must Match",
fTwoSidedStencilRefsAndMasksMustMatch);
writer->appendBool("Must clear buffer memory", fMustClearUploadedBufferData);
diff --git a/src/gpu/ganesh/GrCaps.h b/src/gpu/ganesh/GrCaps.h
index cf074e9..e86c2f8 100644
--- a/src/gpu/ganesh/GrCaps.h
+++ b/src/gpu/ganesh/GrCaps.h
@@ -114,10 +114,11 @@
bool preferClientSideDynamicBuffers() const { return fPreferClientSideDynamicBuffers; }
- // On tilers, an initial fullscreen clear is an OPTIMIZATION. It allows the hardware to
- // initialize each tile with a constant value rather than loading each pixel from memory.
+ // The following 3 methods provide information that enables performance optimizations for tiler
+ // GPUs. Full clear operations (followed by discarding the content when finished) are more
+ // performant than load operations on these GPUs as they enable the hardware to initialize each
+ // tile with a constant value as opposed to loading each pixel from memory.
bool preferFullscreenClears() const { return fPreferFullscreenClears; }
-
// Should we discard stencil values after a render pass? (Tilers get better performance if we
// always load stencil buffers with a "clear" op, and then discard the content when finished.)
bool discardStencilValuesAfterRenderPass() const {
@@ -126,10 +127,15 @@
#if 0
// This method is actually just a duplicate of preferFullscreenClears(), with a descriptive
// name for the sake of readability.
- return this->preferFullscreenClears();
+ return fDiscardStencilValuesAfterRenderPass;
#endif
}
+ // Returns whether clearing an attachment is faster than loading the attachment. This is useful
+ // when you know the values are already cleared so it doesn't matter if we load or clear at the
+ // start of a render pass.
+ bool clearsAreFasterThanLoads() const { return fClearsAreFasterThanLoads; }
+
// D3D does not allow the refs or masks to differ on a two-sided stencil draw.
bool twoSidedStencilRefsAndMasksMustMatch() const {
return fTwoSidedStencilRefsAndMasksMustMatch;
@@ -605,6 +611,8 @@
bool fUsePrimitiveRestart : 1;
bool fPreferClientSideDynamicBuffers : 1;
bool fPreferFullscreenClears : 1;
+ bool fDiscardStencilValuesAfterRenderPass : 1;
+ bool fClearsAreFasterThanLoads : 1;
bool fTwoSidedStencilRefsAndMasksMustMatch : 1;
bool fMustClearUploadedBufferData : 1;
bool fBuffersAreInitiallyZero : 1;
diff --git a/src/gpu/ganesh/gl/GrGLCaps.cpp b/src/gpu/ganesh/gl/GrGLCaps.cpp
index fe2a182..9dc3137 100644
--- a/src/gpu/ganesh/gl/GrGLCaps.cpp
+++ b/src/gpu/ganesh/gl/GrGLCaps.cpp
@@ -118,6 +118,13 @@
return backend == GrGLANGLEBackend::kMetal;
}
+namespace {
+bool is_tiler_gpu(GrGLVendor vendor) {
+ return vendor == GrGLVendor::kARM ||
+ vendor == GrGLVendor::kImagination ||
+ vendor == GrGLVendor::kQualcomm;
+}
+} // anonymous namespace
void GrGLCaps::init(const GrContextOptions& contextOptions,
const GrGLContextInfo& ctxInfo,
const GrGLInterface* gli) {
@@ -221,10 +228,10 @@
}
}
- if (ctxInfo.vendor() == GrGLVendor::kARM ||
- ctxInfo.vendor() == GrGLVendor::kImagination ||
- ctxInfo.vendor() == GrGLVendor::kQualcomm ) {
+ if (is_tiler_gpu(ctxInfo.vendor())) {
fPreferFullscreenClears = true;
+ fDiscardStencilValuesAfterRenderPass = true;
+ fClearsAreFasterThanLoads = true;
}
if (GR_IS_GR_GL(standard)) {
diff --git a/src/gpu/ganesh/ops/OpsTask.cpp b/src/gpu/ganesh/ops/OpsTask.cpp
index 6dbc866..ddaa705 100644
--- a/src/gpu/ganesh/ops/OpsTask.cpp
+++ b/src/gpu/ganesh/ops/OpsTask.cpp
@@ -585,37 +585,67 @@
stencil = renderTarget->getStencilAttachment(fUsesMSAASurface);
}
- bool markStencilCleared = false;
GrLoadOp stencilLoadOp;
+ // Determine whether the stencil attachment's cleared area should be updated.
+ bool updateClearedStencilArea = false;
+ SkIRect boundsRequiredByStencil = fClippedContentBounds;
switch (fInitialStencilContent) {
case StencilContent::kDontCare:
- 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 {
+ if (!stencil || caps.performStencilClearsAsDraws()) {
// This should only intentionally happen for the AtlasRenderTask which
// immediately inserts a clear.
stencilLoadOp = GrLoadOp::kDiscard;
+ break;
}
- break;
+ // 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 fallthrough to clear it so
+ // that uninitialized data won't creep into the stencil buffer.
+ [[fallthrough]];
case StencilContent::kUserBitsCleared:
SkASSERT(!caps.performStencilClearsAsDraws());
SkASSERT(stencil);
+ // Based upon Caps, determine which stencil load operation to use and perform any
+ // necessary updates to the renderpass's bounds and the stencil's cleared area.
+
+ // If the user bits are meant to be cleared we either do that via an initial clear the
+ // first time the area of the stencil is used, or we assume previous draws left the
+ // stencil cleared so we just load the current values. However, on some devices it is
+ // faster to just clear the stencil each time instead of doing a load.
+ //
+ // Since we'll never end up loading the stencil in this case there is no reason for us
+ // to do the clear rect tracking below, so we just break.
+ if (caps.clearsAreFasterThanLoads()) {
+ stencilLoadOp = GrLoadOp::kClear;
+ break;
+ }
+ // If stencil values are discarded after every renderpass, then we do not need to track
+ // which portion of the stencil attachment has already been cleared.
if (caps.discardStencilValuesAfterRenderPass()) {
- // Always clear the stencil if it is being discarded after render passes. This is
- // also an optimization because we are on a tiler and it avoids loading the values
Original Bug Report
Cross-origin GPU memory disclosure in Skia Ganesh-Vulkan via partial stencil clears
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 logic error in Skia’s Ganesh Vulkan backend allows uninitialized GPU memory to be loaded into the stencil buffer. By exploiting a granularity mismatch between Skia’s initialization tracking and Vulkan’s render area clears, an attacker can leak stale VRAM contents from the GPU process.
Affected files:
third_party/skia/src/gpu/ganesh/ops/OpsTask.cppthird_party/skia/src/gpu/ganesh/GrAttachment.hthird_party/skia/src/gpu/ganesh/vk/GrVkOpsRenderPass.cppthird_party/skia/src/gpu/ganesh/vk/GrVkCommandBuffer.cppthird_party/skia/src/gpu/ganesh/vk/GrVkRenderPass.cppthird_party/skia/src/gpu/ganesh/vk/GrVkImage.cppthird_party/skia/src/gpu/ganesh/GrResourceProvider.cppthird_party/skia/src/gpu/ganesh/SurfaceDrawContext.cpp
Estimated timestamp from git blame: 2019-10-01
Summary
A potential vulnerability exists in Skia’s Ganesh Vulkan backend where the mechanism for tracking the initialization of stencil attachments does not account for the specific render area of the clear operation. This can result in a shared stencil attachment being marked as ‘cleared’ even if only a small sub-rectangle was actually initialized. Subsequent tasks reusing the attachment may skip the necessary clear and instead load uninitialized GPU memory (stale VRAM), which can then be exfiltrated using standard rendering operations.
Technical Analysis
In third_party/skia/src/gpu/ganesh/ops/OpsTask.cpp, Skia tracks whether a stencil attachment has been initialized via a global flag: GrAttachment::fHasPerformedInitialClear. When a task requires stencil bits to be cleared, it checks this flag. If it is false, Skia sets the load operation to kClear and marks the flag as true after execution.
However, in the Vulkan backend, VK_ATTACHMENT_LOAD_OP_CLEAR is strictly limited to the renderArea specified in the VkRenderPassBeginInfo. Skia uses the task’s fClippedContentBounds as this render area. If the task’s bounds are smaller than the full attachment (e.g., a path confined to a corner), only that sub-rectangle is cleared to zero. The rest of the attachment remains uninitialized.
Because GrDirectContext and its GrResourceCache are shared across different renderer processes in the GPU process for OOP-R (Out-of-Process Rasterization), a stencil attachment created by one origin can be recycled and reused by another. If a second task (potentially from a different origin) reuses this attachment, it will observe fHasPerformedInitialClear == true and skip the initial clear, instead performing a VK_ATTACHMENT_LOAD_OP_LOAD. This loads the uninitialized/stale VRAM into the active stencil buffer.
An attacker can visualize this stale data by using the ‘stencil-then-cover’ rendering path. The ‘cover’ pass can be configured to write a specific color wherever the stencil value is non-zero (GrUserStencilTest::kNotEqual 0). By drawing a full-screen path, the resulting color buffer will reflect the state of the uninitialized VRAM, which can then be read back via getImageData().
Potential Reproduction Steps
- Use a configuration with Ganesh-Vulkan enabled (e.g., Chrome on Android or Linux with
--use-vulkan). - Canvas A: Create a 1024x1024 accelerated canvas. Draw a complex path confined to the top-left 10x10 pixels. This triggers an initial clear of the stencil attachment but only for the 10x10
renderArea, while setting the globalfHasPerformedInitialClearlatch to true. - Canvas B: Create a second 1024x1024 accelerated canvas (possibly in a different origin). This context may receive the same cached
GrAttachmentfrom the GPU process. - The Leak: Draw a complex path covering the full canvas. Skia will skip the stencil clear because the latch is set, loading the stale VRAM from the previous attachment state.
- Exfiltration: Use
getImageData()on Canvas B to capture the pixels. Areas where the stale stencil data was non-zero will contain the draw color, revealing the VRAM contents.
Suggested Fix
Ensure that the fHasPerformedInitialClear flag is only set if the clear operation covers the full dimensions of the GrAttachment. If a partial clear is performed, the flag should remain false, or Skia should track the initialized region more granularly (e.g., using a dirty rect approach).
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.