Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Skia
DescriptionInsufficient validation of untrusted input in Skia
ComponentSkia
Bug ClassLogic Error
Tracker513948227
Fix commitf93ed13d77fb (skia) +411/-105
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

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
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