Medium watch CVSS 5.3 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Skia
DescriptionInsufficient validation of untrusted input in Skia
ComponentSkia
Bug ClassInput Validation
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.
Tracker513948227
Fix commitf93ed13d77fb (skia) +411/-105
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-17 (per vendor advisory)
Fix landed2026-05-28 (public git)
Disclosed2026-06-30
Patch gap33 days — fix public in source before the advisory

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 untrusted SkReadBuffer, such as a cross-process serialized picture or text blob.
`GlyphVector`
holds a subrun’s packed glyph IDs and the SkStrikePromise (exposed via strikePromise) 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.

Key insight
The single mistake was treating a 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

  1. Craft a malicious serialized subrun Produce an SkReadBuffer (e.g. a serialized picture or text blob) whose DirectMaskSubRun encodes a VertexFiller with bounds that lie inside the active clip but do not actually contain the glyph positions.
  2. Satisfy the count check Encode a glyph count that matches glyphVector->glyphCount() so the old buffer.validate(glyphVector->glyphCount() == vertexFiller->count()) guard passes and the forged bounds are accepted verbatim.
  3. 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.
  4. Sample stale scratch textures Per the advisory, the escaped glyphs then sample from stale scratch textures rather than the region validated for this subrun.
  5. 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

An attacker who supplies a crafted serialized text blob to Skia’s GPU text deserialization can make glyphs render outside their claimed bounds and sample stale scratch textures, as the advisory states, in the process performing that deserialization. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 5.3. The advisory does not report exploited-in-the-wild status or a specific severity here. Any escalation to cross-origin information disclosure or memory corruption beyond the stale-texture sampling the advisory describes is (inferred).

Changed Functions

FunctionChangeNotes
for
src/text/gpu/SubRunContainer.cpp
modified
DirectMaskSubRun
src/text/gpu/SubRunContainer.cpp
modified

Files Changed

  • src/text/gpu/GlyphVector.h
  • src/text/gpu/SubRunContainer.cpp

Audit Directions

  • Trusting serialized geometry
    Audit other *SubRun and *::MakeFromBuffer deserializers that read bounds, rects, or offsets straight from SkReadBuffer without re-deriving them from the underlying primitive data.
  • Bounds-vs-content invariants
    Look 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 textures
    Review 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.
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);
     }
 };
 
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cpp
  • third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp
  • third_party/skia/src/gpu/ganesh/SurfaceDrawContext.cpp
  • third_party/skia/src/text/gpu/SubRunContainer.cpp
  • third_party/skia/src/gpu/ganesh/text/GlyphData.cpp
  • third_party/skia/src/gpu/ganesh/glsl/GrGLSLProgramBuilder.cpp
  • third_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:

  1. Clip Elision: In AtlasTextOp::Make (third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp), calculate_clip is called using the provided creationBounds. If these bounds are small and within the current user clip, the GrClip may be dropped (clip = nullptr), and the operation’s bounds are set to the tiny, incorrect rect.
  2. 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. setupDstProxyView uses the operation’s bounds (the lied 1x1 rect) to determine the copy size, resulting in a tiny copy region.
  3. Recycled Scratch Textures: The destination copy is allocated with SkBackingFit::kApprox, which pulls a recycled scratch texture from the GrResourceCache. 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.
  4. 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.
  5. Information Exfiltration: By using a blend mode like kDifference and 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.

View on issue tracker