Critical watch ✔︎ verified CVSS 8.3 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Critical
Severity
8.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactHeap buffer overflow in Skia
DescriptionHeap buffer overflow in Skia
ComponentSkia
Bug ClassOOB
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable — revised by independent review by AI
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.
Independent review by AIConfirmed (tier revised from patch-now)
Reviewer notes: Overflow itself inferred: diff shows perspective-matrix rejection plus stride-mismatch runtime bail, not the OOB write. Requires forged/corrupted serialized Slug (commit: 'shouldn't happen during normal use'), so default web reachability unproven.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker520113415
Fix commita145861ad1c2 (skia) +100/-5
CWECWE-122 (Heap buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-06-04 (per vendor advisory)
Fix landed2026-06-05 (public git)
Disclosed2026-06-30
Patch gap25 days — fix public in source before the advisory

Background

Slug
a serializable Skia object (sktext::gpu::Slug) that captures pre-processed GPU text as a set of subruns so a text blob can be re-drawn later, and which is reconstructed from bytes via Slug::Deserialize.
`creationMatrix`
the per-subrun transform read during deserialization in SubRunContainer.cpp, which downstream rendering code assumes is affine (contains no perspective component).
`vertexStride`
the per-vertex byte size of a GPU glyph vertex buffer, computed at draw time from the mask format and draw matrix via glyphData.vertexStride(subRun.maskFormat(), geo->fDrawMatrix), and which must match the stride the vertex buffer was allocated with.
`SkReadBuffer::validate`
a deserialization guard that marks the read buffer invalid and causes the parse to fail when the supplied boolean condition is false.

Root Cause Analysis

When a Slug is deserialized, the subrun-reading code in SubRunContainer.cpp called buffer.readMatrix(&creationMatrix) and accepted the resulting creationMatrix without checking that it lacked a perspective component. Rendering code assumes creationMatrix is non-perspective, so a corrupted or forged serialized Slug carrying a perspective matrix violated that invariant. Because the mask format / stride derived from a perspective matrix at draw time (glyphData.vertexStride(subRun.maskFormat(), geo->fDrawMatrix)) could differ from the stride the vertex buffer was actually allocated with, the CPU and GPU sides would disagree on buffer size, producing a heap buffer overflow when vertices were written.

The fix adds if (!buffer.validate(!creationMatrix.hasPerspective())) { return std::nullopt; } so any deserialized subrun with a perspective creationMatrix is rejected before it can be drawn. As defense in depth, the previously debug-only SkASSERTF stride check in AtlasTextOp.cpp was promoted to a real runtime check that logs and returns instead of proceeding when strideCheck != vertexStride.

Key insight
The core mistake was trusting an attacker-influenced creationMatrix from a serialized Slug while every consumer assumed it was affine; the fix restores the invariant by rejecting any deserialized subrun whose creationMatrix hasPerspective(), and hardens the mismatched-stride case with a runtime bail-out.

Attack Path

  1. Obtain a valid serialized Slug Produce a Slug (e.g. via Slug::ConvertBlob) that is forced down the SDFT path and serialize it with slug->serialize() to get a well-formed byte buffer.
  2. Forge a perspective creationMatrix Overwrite the serialized creationMatrix (in the test, the VertexFiller matrix inside the SDFT subrun located 216 bytes into the data) so the bottom row encodes perspective (e.g. f[6] = 0.0078125f), yielding a matrix where hasPerspective() is true.
  3. Deserialize the corrupted Slug Feed the forged bytes to Slug::Deserialize; pre-patch, SubRunContainer.cpp accepted the perspective creationMatrix and returned a usable subrun instead of failing.
  4. Trigger the stride mismatch on draw When the subrun is drawn, vertexStride computed from the perspective matrix diverges from the allocated vertex-buffer stride, so CPU and GPU buffers are sized inconsistently. (inferred)
  5. Heap buffer overflow Writing glyph vertices under the mismatched stride overruns the heap allocation. The specific out-of-bounds write sink and any further read/write primitive or escalation are not shown by the diff. (inferred)

Impact Assessment

An attacker who can supply a corrupted or forged serialized Slug can violate the non-perspective creationMatrix invariant and cause a heap buffer overflow in Skia, in whatever process performs the GPU text draw (per the advisory, an OOB heap buffer overflow in the Skia component). Preconditions are that the process deserializes attacker-controlled Slug data and reaches the SDFT draw path; the advisory states no exploited-in-the-wild status. Any escalation from the overflow to a controlled read/write, sandbox escape, or code execution is not demonstrated by the patch. (inferred)

Changed Functions

FunctionChangeNotes
if
src/gpu/ganesh/ops/AtlasTextOp.cpp
modified
if
tests/SlugTest.cpp
modified

Files Changed

  • src/gpu/ganesh/ops/AtlasTextOp.cpp
  • src/text/gpu/SubRunContainer.cpp
  • tests/SlugTest.cpp

Audit Directions

  • Deserialized matrices
    Audit every buffer.readMatrix/SkReadBuffer matrix read in serialization paths for missing hasPerspective() (or affine/scale) validation before the matrix is used to size buffers or index memory.
  • Debug-only invariants on untrusted data
    Look for SkASSERT/SkASSERTF/SkDEBUGCODE checks that guard buffer sizes or strides on data that can originate from serialization, since these vanish in release builds and should be real runtime bail-outs.
  • Stride/size agreement
    Review paths where a stride or vertex size is recomputed at draw time from a matrix or mask format and compared against a previously allocated buffer, and ensure a mismatch aborts the draw rather than proceeding.
From a145861ad1c2650c58dd3ff0dbd92ae6f41f1e1b Mon Sep 17 00:00:00 2001
From: Kaylee Lubick <[email protected]>
Date: Fri, 05 Jun 2026 20:51:04 +0000
Subject: [PATCH] Reject Slugs that have creationMatrix with perspective

This shouldn't happen during normal use [1] but if the data
is corrupted, there are some assumptions that can can cause
issues, like the ones linked in the bug.

This rejects those and turns one assert into an actual runtime
check to provide defense in depth.

[1] https://github.com/google/skia/blob/9eecbdc30f7da675edab96974b23174a9d521e0c/src/text/gpu/SubRunContainer.cpp#L1578-L1581

Bug: 520113415
Fixed: 520113415
Change-Id: I6ec23df9a23ea588fa89f7a62dc1f197fe3905fd
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1256016
Commit-Queue: Kaylee Lubick <[email protected]>
Reviewed-by: Thomas Smith <[email protected]>
---

diff --git a/src/gpu/ganesh/ops/AtlasTextOp.cpp b/src/gpu/ganesh/ops/AtlasTextOp.cpp
index 27e919e..51ae890 100644
--- a/src/gpu/ganesh/ops/AtlasTextOp.cpp
+++ b/src/gpu/ganesh/ops/AtlasTextOp.cpp
@@ -565,11 +565,16 @@
 
         auto& glyphData = subRun.glyphVector().accessBackendData<GlyphData>();
 
-        SkDEBUGCODE(int strideCheck = SkToInt(glyphData.vertexStride(subRun.maskFormat(),
-                                                                     geo->fDrawMatrix)));
-        SkASSERTF(strideCheck == vertexStride,
-                   "subRun stride: %d vertex buffer stride: %d\n",
-                   strideCheck, vertexStride);
+        int strideCheck = SkToInt(glyphData.vertexStride(subRun.maskFormat(), geo->fDrawMatrix));
+        if (strideCheck != vertexStride) {
+            // We (unexpectedly) have buffers of different sizes between CPU and GPU. Bail out.
+            SKIA_LOG_D(
+                    "Warning: stride mismatch detected (subrun stride: %d vertex buffer stride: "
+                    "%d). Aborting draw.\n",
+                    strideCheck,
+                    vertexStride);
+            return;
+        }
 
         const int subRunEnd = subRun.glyphCount();
 
diff --git a/src/text/gpu/SubRunContainer.cpp b/src/text/gpu/SubRunContainer.cpp
index bd5f827..6d2a7e3 100644
--- a/src/text/gpu/SubRunContainer.cpp
+++ b/src/text/gpu/SubRunContainer.cpp
@@ -169,6 +169,11 @@
 
     SkMatrix creationMatrix;
     buffer.readMatrix(&creationMatrix);
+    // The only valid creationMatrices do not have perspective and many rendering parts assume
+    // they are non-affine, so reject any malformed matrices here.
+    if (!buffer.validate(!creationMatrix.hasPerspective())) {
+        return std::nullopt;
+    }
 
     SkSpan<SkPoint> leftTop = make_points_from_buffer(buffer, alloc);
     if (leftTop.empty()) {
diff --git a/tests/SlugTest.cpp b/tests/SlugTest.cpp
index cc1ef79..1c81925 100644
--- a/tests/SlugTest.cpp
+++ b/tests/SlugTest.cpp
@@ -5,6 +5,8 @@
  * found in the LICENSE file.
  */
 
+#include "include/core/SkBitmap.h"
+#include "include/core/SkCanvas.h"
 #include "include/core/SkFont.h"
 #include "include/core/SkFontStyle.h"
 #include "include/core/SkFontTypes.h"
@@ -20,6 +22,8 @@
 #include "include/gpu/ganesh/SkSurfaceGanesh.h"
 #include "include/private/base/SkTDArray.h"
 #include "include/private/chromium/Slug.h"
+#include "src/text/gpu/SlugImpl.h"
+#include "src/utils/SkFloatUtils.h"
 #include "tests/CtsEnforcement.h"
 #include "tests/Test.h"
 #include "tools/ToolUtils.h"
@@ -65,3 +69,84 @@
     sk_sp<sktext::gpu::Slug> slug = sktext::gpu::Slug::ConvertBlob(canvas, *blob, {10, 10}, p);
     REPORTER_ASSERT(reporter, slug == nullptr);
 }
+
+static void set_sdf_options(GrContextOptions* options) {
+    options->fMinDistanceFieldFontSize = 4;
+    options->fGlyphsAsPathsFontSize = 256;
+    options->fSupportBilerpFromGlyphAtlas = true;
+}
+
+DEF_GANESH_TEST_FOR_CONTEXTS(Slug_b520113415,
+                             skgpu::IsRenderingContext,
+                             reporter,
+                             ctxInfo,
+                             set_sdf_options,
+                             CtsEnforcement::kNextRelease) {
+    auto dContext = ctxInfo.directContext();
+
+    SkImageInfo info = SkImageInfo::MakeN32Premul(256, 256);
+    auto surface = SkSurfaces::RenderTarget(dContext, skgpu::Budgeted::kNo, info);
+    REPORTER_ASSERT(reporter, surface);
+    auto canvas = surface->getCanvas();
+
+    // This matrix is big enough such that with the font below...
+    SkMatrix canvasMatrix = SkMatrix::Scale(1.2345f, 6.7890f);
+
+    canvas->save();
+    canvas->setMatrix(canvasMatrix);
+
+    //  ... it will force SDFT (6.789 x 24 = ~162.9).
+    auto typeface = ToolUtils::CreatePortableTypeface("serif", SkFontStyle());
+    SkFont font(typeface);
+    font.setSubpixel(true);
+    font.setSize(24);
+
+    static const char* kText = "A";
+    int glyphCount = font.countText(kText, 1, SkTextEncoding::kUTF8);
+    SkTDArray<SkGlyphID> glyphs;
+    glyphs.append(glyphCount);
+    font.textToGlyphs(kText, 1, SkTextEncoding::kUTF8, glyphs);
+
+    SkTextBlobBuilder builder;
+    const SkTextBlobBuilder::RunBuffer& buf = builder.allocRun(font, glyphs.size(), 0, 0);
+    memcpy(buf.glyphs, glyphs.begin(), glyphs.size() * sizeof(SkGlyphID));
+    auto blob = builder.make();
+
+    SkPaint paint;
+    paint.setAntiAlias(true);
+
+    sk_sp<sktext::gpu::Slug> slug = sktext::gpu::Slug::ConvertBlob(canvas, *blob, {0, 0}, paint);
+    canvas->restore();
+
+    if (!slug) {
+        return;
+    }
+
+    sk_sp<SkData> data = slug->serialize();
+    if (!data || data->size() == 0) {
+        return;
+    }
+
+    // Copy data to a writable buffer so we can corrupt it
+    size_t size = data->size();
+    std::unique_ptr<uint8_t[]> writableData(new uint8_t[size]);
+    memcpy(writableData.get(), data->data(), size);
+
+
+    // The creationMatrix of VertexFiller inside SDFTSubRun is located 216 bytes in.
+    // We can overwrite it to have a matrix with perspective.
+    if (size < 216 + 9 * sizeof(float)) {
+        ERRORF(reporter, "Serialized Slug is too small to contain creationMatrix!");
+        return;
+    }
+    float* f = reinterpret_cast<float*>(writableData.get() + 216);
+    f[0] = 1.0f; f[1] = 0.0f; f[2] = 0.0f;
+    f[3] = 0.0f; f[4] = 1.0f; f[5] = 0.0f;
+    f[6] = 0.0078125f; f[7] = 0.0f; f[8] = 1.0f;
+
+    // Deserialize the forged slug. This should return nullptr. Previously, when we went to draw it,
+    // the perspective would cause issues.
+    sk_sp<sktext::gpu::Slug> forgedSlug =
+            sktext::gpu::Slug::Deserialize(writableData.get(), size, nullptr);
+    REPORTER_ASSERT(reporter, forgedSlug == nullptr);
+}
Loading diff…

Original Bug Report

reported by [email protected]

Out-of-bounds heap write in Ganesh SDF text rendering path via mismatched predicates

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 potential heap out-of-bounds write exists in Ganesh’s signed distance field (SDF) text rendering pipeline. A compromised renderer can decouple the perspective predicates used for vertex buffer allocation and CPU-side vertex filling by forging a serialized Slug. This mismatch can cause the GPU process to allocate a vertex buffer with a 16-byte stride but fill it using a 20-byte stride, writing up to 8KB past the buffer boundary.

Affected files:

  • third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp
  • third_party/skia/src/gpu/ganesh/text/GlyphData.cpp
  • third_party/skia/src/gpu/ganesh/effects/GrDistanceFieldGeoProc.cpp
  • third_party/skia/src/text/gpu/SubRunContainer.cpp
  • third_party/skia/src/text/gpu/VertexFiller.cpp
  • third_party/skia/src/gpu/ganesh/Device.cpp
  • cc/paint/paint_op_reader.cc

Estimated timestamp from git blame: 2022-10-13

Description

A potential heap out-of-bounds write vulnerability has been identified in Skia’s Ganesh rendering backend, specifically within the signed distance field (SDF) text rendering path. The vulnerability stems from an inconsistency between the perspective predicates used to compute the GeometryProcessor’s vertex-attribute layout and those used to determine the structure size during the CPU-side vertex-fill process.

Root Cause Analysis

  1. GeometryProcessor Stride Calculation: In third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp:195, the perspective flag kPerspective_DistanceFieldEffectFlag is derived from viewDiffMatrix.hasPerspective():

    DFGPFlags |= viewDiffMatrix.hasPerspective() ? kPerspective_DistanceFieldEffectFlag : 0;
    

    Here, viewDiffMatrix is calculated as the difference between the drawing-time positionMatrix and the serialized creationMatrix (positionMatrix · creationMatrix⁻¹).

    If the perspective flag is not set, GrDistanceFieldA8TextGeoProc selects a 2D position attribute (kFloat2_GrVertexAttribType, 8 bytes), resulting in a vertex stride of 16 bytes (8 bytes position + 4 bytes color + 4 bytes UV).

  2. CPU-Side Vertex Fill Structure Selection: In third_party/skia/src/gpu/ganesh/text/GlyphData.cpp:299-320, the structure used to write the vertex data is chosen based directly on positionMatrix.hasPerspective(), rather than the viewDifference matrix:

    SkMatrix viewDifference = vf.viewDifference(positionMatrix);
    if (!positionMatrix.hasPerspective()) {
        using Quad = Mask2DVertex[4];   // 16B/vertex
        fill2D(quadData((Quad*)vertexBuffer), color, viewDifference);
    } else {
        using Quad = Mask3DVertex[4];   // 20B/vertex
        fill3D(quadData((Quad*)vertexBuffer), color, viewDifference);
    }
    

    If positionMatrix contains perspective, fill3D writes vertices as Mask3DVertex structures, which are 20 bytes each.

  3. De-synchronization of Predicates: A compromised renderer can manipulate both matrices across the IPC boundary. During Slug deserialization in third_party/skia/src/text/gpu/SubRunContainer.cpp:171, the creationMatrix is read raw from the shared buffer without validation to verify if it contains perspective:

    SkMatrix creationMatrix;
    buffer.readMatrix(&creationMatrix);
    

Potential Attack Scenario

Note: Our tooling does not currently have the capability to execute code, so this represents a suggested potential attack flow based on static analysis of the codebase.

  1. A compromised renderer updates the canvas CTM to an arbitrary invertible perspective matrix $P$ via SetMatrixOp(P).
  2. The renderer crafts and sends a serialized DrawSlugOp carrying a forged SDFTSubRun where creationMatrix is also set to $P$.
  3. During playback in the GPU process, the drawing context evaluates positionMatrix as $P$ (has perspective).
  4. The GPU process computes viewDiff = positionMatrix * creationMatrix^-1 = P * P^-1 = Identity (no perspective).
  5. Consequently, calculate_sdf_parameters does not apply the perspective flag, setting the GP vertex stride to 16 bytes. target->makeVertexSpace allocates $16 \times 4 \times N = 64 \times N$ bytes (e.g., 32,768 bytes for $N=512$).
  6. However, GlyphData::fillVertexData evaluates positionMatrix.hasPerspective() == true and enters the else block, writing vertices as Mask3DVertex (20-byte stride) at $80 \times N$ bytes (e.g., 40,960 bytes).
  7. This results in up to an 8,192-byte heap out-of-bounds write past the allocated buffer. The written data (coordinates mapped through viewDifference and the color) is fully specified by the attacker.

Suggested Remediation

To remediate this issue, the CPU-side vertex-filling logic should be aligned with the GP-side stride logic by keying the structure selection in GlyphData::fillVertexData on viewDifference.hasPerspective() rather than positionMatrix.hasPerspective(). Alternatively, strict matrix validation can be added during Slug deserialization to reject perspective creationMatrix inputs.

Evaluated with Chrome root at commit: 57b021e1fdae94a215627d29aeb1ccf2eb5b3e91


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