CVE-2026-14414
Overview
Background
- `VertexFiller`
- the Skia GPU text object that holds a subrun’s per-glyph vertex positions plus the bounds used to clip that subrun when its masks are drawn.
- `SubRunContainer`
- the code (
SubRunContainer.cpp) that deserializes text subruns from an untrustedSkReadBuffer, such as a cross-process serialized picture or text blob. - `GlyphVector`
- holds a subrun’s packed glyph IDs and the
SkStrikePromise(exposed viastrikePromise) needed to look up each glyph’s metrics from a strike. - scratch/atlas texture
- the shared GPU texture that glyph masks are rendered into, where a subrun’s bounds act as a clip keeping it sampling only its own region.
Root Cause Analysis
In DirectMaskSubRun::MakeFromBuffer, the old path deserialized the subrun’s VertexFiller via VertexFiller::MakeFromBuffer, which read the subrun’s bounds directly out of the untrusted SkReadBuffer and only validated that glyphVector->glyphCount() equaled vertexFiller->count(). Because the bounds were attacker-controlled and never checked against the actual glyph positions, a VertexFiller could be constructed whose creation bounds did not contain its glyphs yet were entirely inside the current clip. That violated the invariant that a subrun’s bounds contain its glyphs, so the glyphs escaped the creation-bounds clip and could sample from stale scratch textures.
The fix stops trusting the serialized bounds: make_vertex_filler_from_buffer reads only the MaskFormat, the canDrawDirect flag, the creationMatrix, and the left-top positions, then calls reconstruct_safe_bounds to recompute the bounding rect from the real per-glyph digests obtained via digestFor and glyph_bounds_from_left_top. The VertexFiller is then built with this reconstructed safeBounds rather than any value from the buffer, and doFlatten/flatten_vertex_filler correspondingly stop writing the bounds and serialize the GlyphVector first so the glyph IDs are available for reconstruction on read.
VertexFiller’s serialized clip/creation bounds as a trusted invariant instead of deriving them from the glyphs; the fix recomputes the bounds from the actual glyph digests at deserialization time in reconstruct_safe_bounds, so the bounds can no longer be forged to disagree with the glyphs.Attack Path
- Craft a malicious serialized subrun
Produce an
SkReadBuffer(e.g. a serialized picture or text blob) whoseDirectMaskSubRunencodes aVertexFillerwith bounds that lie inside the active clip but do not actually contain the glyph positions. - Satisfy the count check
Encode a glyph count that matches
glyphVector->glyphCount()so the oldbuffer.validate(glyphVector->glyphCount() == vertexFiller->count())guard passes and the forged bounds are accepted verbatim. - Bypass the bounds clip Since the deserialized bounds are used as-is, the glyphs draw outside the region their bounds describe, ignoring the creation-bounds clip that was supposed to constrain them.
- Sample stale scratch textures Per the advisory, the escaped glyphs then sample from stale scratch textures rather than the region validated for this subrun.
-
Disclose leftover GPU texture contents
(inferred)— Reading previously rendered scratch/atlas memory could leak data the subrun was never authorized to see; the patch does not demonstrate the concrete read sink.
Impact Assessment
(inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forsrc/text/gpu/SubRunContainer.cpp |
modified | |
DirectMaskSubRunsrc/text/gpu/SubRunContainer.cpp |
modified |
Files Changed
src/text/gpu/GlyphVector.hsrc/text/gpu/SubRunContainer.cpp
Audit Directions
- Trusting serialized geometryAudit other
*SubRunand*::MakeFromBufferdeserializers that read bounds, rects, or offsets straight fromSkReadBufferwithout re-deriving them from the underlying primitive data. - Bounds-vs-content invariantsLook for places where a bounding rect is assumed to contain its contents but is not recomputed after crossing the serialization trust boundary.
- Clip-as-security against shared texturesReview code where subrun bounds double as a clip against shared scratch/atlas textures, confirming that sampling cannot exceed a region validated from real glyph data.
Patch
From f93ed13d77fb28b1ab5c058c10bda3049ccbc5f5 Mon Sep 17 00:00:00 2001
From: Thomas Smith <thomsmit@google.com>
Date: Thu, 28 May 2026 16:48:15 -0400
Subject: [PATCH] Reconstruct subRun bounds from glyphs
* Reconstruct the bounds of a subRun after deserialization instead of packaging onto the VertexFiller.
* An attacker could create a VertexFiller with creation bounds that did not contain its glyphs but were entirely contained within the current clip, enabling to the glyphs to ignore the creation bounds clip and sample from stale scratch textures
Bug: b/513948227
Change-Id: Ib4902657e6a50dd5675db4d73a1576b77c4ce88e
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1239916
Commit-Queue: Thomas Smith <thomsmit@google.com>
Reviewed-by: Michael Ludwig <michaelludwig@google.com>
---
diff --git a/src/text/gpu/GlyphVector.h b/src/text/gpu/GlyphVector.h
index 3543212..327fc22 100644
--- a/src/text/gpu/GlyphVector.h
+++ b/src/text/gpu/GlyphVector.h
@@ -147,6 +147,18 @@
// the sub runs.
int unflattenSize() const { return Size(fGlyphs.size()); }
+ SkPackedGlyphID getPackedGlyphID(int index) const {
+ SkASSERT(index >= 0 && index < this->glyphCount());
+ if (this->hasBackendData()) {
+ return fGetGlyphID(fGlyphs[index].data());
+ } else {
+ return *reinterpret_cast<const SkPackedGlyphID*>(fGlyphs[index].data());
+ }
+ }
+
+ SkStrikePromise& strikePromise() { return fStrikePromise; }
+ const SkStrikePromise& strikePromise() const { return fStrikePromise; }
+
bool hasBackendData() const {
SkASSERT(SkToBool(fBackendDataReleaser) == SkToBool(fGetGlyphID));
return SkToBool(fBackendDataReleaser);
diff --git a/src/text/gpu/SubRunContainer.cpp b/src/text/gpu/SubRunContainer.cpp
index c59ff2f..bd5f827 100644
--- a/src/text/gpu/SubRunContainer.cpp
+++ b/src/text/gpu/SubRunContainer.cpp
@@ -101,11 +101,89 @@
return accepted.get<0>();
}
-template <typename U>
-SkSpan<const SkPoint> get_positions(SkZip<U, const SkPoint> accepted) {
+template <typename U> SkSpan<const SkPoint> get_positions(SkZip<U, const SkPoint> accepted) {
return accepted.template get<1>();
}
+SkGlyphRect glyph_bounds(SkGlyphDigest digest, int inset, SkPoint origin) {
+ return digest.bounds().inset(inset, inset).offset(origin);
+}
+
+SkGlyphRect glyph_bounds_from_left_top(SkGlyphDigest digest, int inset, SkPoint leftTop) {
+ SkGlyphRect bounds = digest.bounds();
+ SkPoint offset = leftTop - bounds.leftTop() - SkPoint{SkIntToScalar(inset),
+ SkIntToScalar(inset)};
+ return glyph_bounds(digest, inset, offset);
+}
+
+// Returns the empty span if there is a problem reading the positions.
+SkSpan<SkPoint> make_points_from_buffer(SkReadBuffer& buffer, SubRunAllocator* alloc) {
+ uint32_t glyphCount = buffer.getArrayCount();
+
+ // Zero indicates a problem with serialization.
+ if (!buffer.validate(glyphCount != 0)) { return {}; }
+
+ // Check that the count will not overflow the arena.
+ if (!buffer.validate(glyphCount <= INT_MAX &&
+ BagOfBytes::WillCountFit<SkPoint>(glyphCount))) { return {}; }
+
+ SkPoint* positionsData = alloc->makePODArray<SkPoint>(glyphCount);
+ if (!buffer.readPointArray({positionsData, glyphCount})) { return {}; }
+ return {positionsData, glyphCount};
+}
+
+SkRect reconstruct_safe_bounds(SkSpan<const SkPoint> leftTop,
+ GlyphVector* glyphVector,
+ skglyph::ActionType actionType,
+ int inset) {
+ int count = leftTop.size();
+
+ StrikeForGPU* strike = glyphVector->strikePromise().strike();
+ StrikeMutationMonitor m{strike};
+
+ SkGlyphRect boundingRect = skglyph::empty_rect();
+ for (int i = 0; i < count; ++i) {
+ SkPackedGlyphID packedID = glyphVector->getPackedGlyphID(i);
+ SkGlyphDigest digest = strike->digestFor(actionType, packedID);
+ boundingRect = skglyph::rect_union(
+ boundingRect, glyph_bounds_from_left_top(digest, inset, leftTop[i]));
+ }
+ return boundingRect.empty() ? SkRect::MakeEmpty() : boundingRect.rect();
+}
+
+void flatten_vertex_filler(SkWriteBuffer &buffer, const VertexFiller& vf) {
+ buffer.writeInt(static_cast<int>(vf.maskFormat()));
+ buffer.writeBool(vf.canDrawDirect());
+ buffer.writeMatrix(vf.creationMatrix());
+ buffer.writePointArray(vf.topLefts());
+}
+
+std::optional<VertexFiller> make_vertex_filler_from_buffer(SkReadBuffer& buffer,
+ SubRunAllocator* alloc,
+ GlyphVector* glyphVector,
+ skglyph::ActionType actionType,
+ int inset) {
+ MaskFormat maskFormat = buffer.read32LE(MaskFormat::kLast);
+
+ const bool canDrawDirect = buffer.readBool();
+
+ SkMatrix creationMatrix;
+ buffer.readMatrix(&creationMatrix);
+
+ SkSpan<SkPoint> leftTop = make_points_from_buffer(buffer, alloc);
+ if (leftTop.empty()) {
+ return std::nullopt;
+ }
+
+ if (!buffer.validate(glyphVector->glyphCount() == (int)leftTop.size())) {
+ return std::nullopt;
+ }
+
+ SkRect safeBounds = reconstruct_safe_bounds(leftTop, glyphVector, actionType, inset);
+
+ return VertexFiller{maskFormat, creationMatrix, safeBounds, leftTop, canDrawDirect};
+}
+
// -- PathOpSubmitter ------------------------------------------------------------------------------
// PathOpSubmitter holds glyph ids until ready to draw. During drawing, the glyph ids are
// converted to SkPaths. PathOpSubmitter can only be serialized when it is holding glyph ids;
@@ -196,7 +274,7 @@
SkScalar strikeToSourceScale = buffer.readScalar();
if (!buffer.validate(0 < strikeToSourceScale)) { return std::nullopt; }
- SkSpan<SkPoint> positions = MakePointsFromBuffer(buffer, alloc);
+ SkSpan<SkPoint> positions = make_points_from_buffer(buffer, alloc);
if (positions.empty()) { return std::nullopt; }
const int glyphCount = SkCount(positions);
@@ -453,7 +531,7 @@
SkScalar strikeToSourceScale = buffer.readScalar();
if (!buffer.validate(0 < strikeToSourceScale)) { return std::nullopt; }
- SkSpan<SkPoint> positions = MakePointsFromBuffer(buffer, alloc);
+ SkSpan<SkPoint> positions = make_points_from_buffer(buffer, alloc);
if (positions.empty()) { return std::nullopt; }
const int glyphCount = SkCount(positions);
@@ -594,6 +672,8 @@
// -- DirectMaskSubRun -----------------------------------------------------------------------------
class DirectMaskSubRun final : public AtlasSubRun {
public:
+ static constexpr int kGlyphInsetting = 0;
+
DirectMaskSubRun(VertexFiller&& vertexFiller, GlyphVector&& glyphVector)
: AtlasSubRun{std::move(vertexFiller), std::move(glyphVector)} {}
@@ -619,14 +699,12 @@
static SubRunOwner MakeFromBuffer(SkReadBuffer& buffer,
SubRunAllocator* alloc,
const SkStrikeClient* client) {
- auto vertexFiller = VertexFiller::MakeFromBuffer(buffer, alloc);
- if (!buffer.validate(vertexFiller.has_value())) { return nullptr; }
-
auto glyphVector = GlyphVector::MakeFromBuffer(buffer, client, alloc);
if (!buffer.validate(glyphVector.has_value())) { return nullptr; }
- if (!buffer.validate(glyphVector->glyphCount() == vertexFiller->count())) {
- return nullptr;
- }
+
+ auto vertexFiller = make_vertex_filler_from_buffer(buffer, alloc, &glyphVector.value(),
+ skglyph::kDirectMask, kGlyphInsetting);
+ if (!buffer.validate(vertexFiller.has_value())) { return nullptr; }
SkASSERT(buffer.isValid());
return alloc->makeUnique<DirectMaskSubRun>(std::move(*vertexFiller),
@@ -651,7 +729,7 @@
+ fVertexFiller.unflattenSize();
}
- int glyphSrcPadding() const override { return 0; }
+ int glyphSrcPadding() const override { return kGlyphInsetting; }
std::tuple<bool, SkRect> deviceRectAndNeedsTransform(
const SkMatrix &positionMatrix) const override {
@@ -680,14 +758,16 @@
}
void doFlatten(SkWriteBuffer& buffer) const override {
- fVertexFiller.flatten(buffer);
fGlyphVector.flatten(buffer);
+ flatten_vertex_filler(buffer, fVertexFiller);
}
};
Original Bug Report
Cross-origin GPU texture leak via inconsistent Slug bounds 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A lack of consistency checks in Skia’s Slug deserialization allows a compromised renderer to potentially trigger a cross-origin information leak. By providing undersized bounds for glyphs located elsewhere, an attacker can bypass clipping and sample from uninitialized recycled GPU scratch textures in the GPU process.
Affected files:
third_party/skia/src/text/gpu/VertexFiller.cppthird_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cppthird_party/skia/src/gpu/ganesh/SurfaceDrawContext.cppthird_party/skia/src/text/gpu/SubRunContainer.cppthird_party/skia/src/gpu/ganesh/text/GlyphData.cppthird_party/skia/src/gpu/ganesh/glsl/GrGLSLProgramBuilder.cppthird_party/skia/src/gpu/ganesh/GrSurfaceProxy.cpp
Estimated timestamp from git blame: 2022-08-25
Summary
A potential vulnerability in Skia’s Ganesh backend, specifically within the VertexFiller class used for text rendering, may allow for a cross-origin information leak. A compromised renderer can provide a serialized sktext::gpu::Slug payload where the declared creationBounds are inconsistent with the actual glyph vertex positions. This desynchronization can lead to the elision of clipping and the subsequent allocation of an undersized destination-copy texture during advanced blend mode operations. Because these destination copies may use recycled scratch textures without clearing them, fragments rendered outside the intended region can sample stale pixel data from other origins.
Root Cause
The potential issue is located in VertexFiller::MakeFromBuffer within third_party/skia/src/text/gpu/VertexFiller.cpp. The method deserializes creationBounds and glyph positions (leftTop) from a renderer-supplied buffer without enforcing any relationship or consistency between them:
// third_party/skia/src/text/gpu/VertexFiller.cpp
std::optional<VertexFiller> VertexFiller::MakeFromBuffer(SkReadBuffer &buffer, ...) {
// ...
SkRect creationBounds = buffer.readRect();
SkSpan<SkPoint> leftTop = MakePointsFromBuffer(buffer, alloc);
// ...
return VertexFiller{..., creationBounds, leftTop, ...};
}
A compromised renderer could set creationBounds to a very small area (e.g., 1x1) while placing glyphs far outside those bounds.
Potential Exploitation Path
The following steps are theorized based on a code review of the Ganesh backend:
- Clip Elision: In
AtlasTextOp::Make(third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp),calculate_clipis called using the providedcreationBounds. If these bounds are small and within the current user clip, theGrClipmay be dropped (clip = nullptr), and the operation’s bounds are set to the tiny, incorrect rect. - Undersized Destination-Copy: When an advanced blend mode (like
kDifference) is used,SurfaceDrawContext::addDrawOp(third_party/skia/src/gpu/ganesh/SurfaceDrawContext.cpp) triggers a destination copy.setupDstProxyViewuses the operation’s bounds (the lied 1x1 rect) to determine the copy size, resulting in a tiny copy region. - Recycled Scratch Textures: The destination copy is allocated with
SkBackingFit::kApprox, which pulls a recycled scratch texture from theGrResourceCache. This cache is shared across origins in the GPU process. Stale content from other origins is typically preserved in these textures as they are not cleared before use. - Unclipped Sampling: Although the destination copy is tiny, the actual glyphs are rendered at their true positions because clipping was dropped in Step 1. The fragment shader calculates destination texture coordinates relative to the backing dimensions of the scratch texture but does not clamp them to the copied region. Consequently, fragments outside the 1x1 area sample stale pixels from the recycled texture.
- Information Exfiltration: By using a blend mode like
kDifferenceand a known source color, an attacker can potentially recover the stale pixel data from the rendered results, leaking cross-origin GPU memory.
Suggested Fix
Enforce consistency during deserialization in VertexFiller::MakeFromBuffer. After reading the glyph positions, compute their actual bounding box and verify that it is contained within the deserialized creationBounds. Alternatively, the creationBounds should be recomputed from the actual glyph positions rather than relying on the potentially malicious serialized value.
Note: These are suggested/potential steps identified via static analysis; a functional proof-of-concept has not yet been executed.
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.