CVE-2026-14427
Overview
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 viaSlug::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.
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
- Obtain a valid serialized Slug
Produce a
Slug(e.g. viaSlug::ConvertBlob) that is forced down the SDFT path and serialize it withslug->serialize()to get a well-formed byte buffer. - Forge a perspective creationMatrix
Overwrite the serialized
creationMatrix(in the test, theVertexFillermatrix 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 wherehasPerspective()is true. - Deserialize the corrupted Slug
Feed the forged bytes to
Slug::Deserialize; pre-patch,SubRunContainer.cppaccepted the perspectivecreationMatrixand returned a usable subrun instead of failing. - Trigger the stride mismatch on draw
When the subrun is drawn,
vertexStridecomputed from the perspective matrix diverges from the allocated vertex-buffer stride, so CPU and GPU buffers are sized inconsistently.(inferred) - 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
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
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/ganesh/ops/AtlasTextOp.cpp |
modified | |
iftests/SlugTest.cpp |
modified |
Files Changed
src/gpu/ganesh/ops/AtlasTextOp.cppsrc/text/gpu/SubRunContainer.cpptests/SlugTest.cpp
Audit Directions
- Deserialized matricesAudit every
buffer.readMatrix/SkReadBuffermatrix read in serialization paths for missinghasPerspective()(or affine/scale) validation before the matrix is used to size buffers or index memory. - Debug-only invariants on untrusted dataLook for
SkASSERT/SkASSERTF/SkDEBUGCODEchecks 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 agreementReview 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.
Patch
From a145861ad1c2650c58dd3ff0dbd92ae6f41f1e1b Mon Sep 17 00:00:00 2001
From: Kaylee Lubick <kjlubick@google.com>
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 <kjlubick@google.com>
Reviewed-by: Thomas Smith <thomsmit@google.com>
---
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);
+}
Original Bug Report
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.cppthird_party/skia/src/gpu/ganesh/text/GlyphData.cppthird_party/skia/src/gpu/ganesh/effects/GrDistanceFieldGeoProc.cppthird_party/skia/src/text/gpu/SubRunContainer.cppthird_party/skia/src/text/gpu/VertexFiller.cppthird_party/skia/src/gpu/ganesh/Device.cppcc/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
-
GeometryProcessor Stride Calculation: In
third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp:195, the perspective flagkPerspective_DistanceFieldEffectFlagis derived fromviewDiffMatrix.hasPerspective():DFGPFlags |= viewDiffMatrix.hasPerspective() ? kPerspective_DistanceFieldEffectFlag : 0;Here,
viewDiffMatrixis calculated as the difference between the drawing-timepositionMatrixand the serializedcreationMatrix(positionMatrix · creationMatrix⁻¹).If the perspective flag is not set,
GrDistanceFieldA8TextGeoProcselects 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). -
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 onpositionMatrix.hasPerspective(), rather than theviewDifferencematrix: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
positionMatrixcontains perspective,fill3Dwrites vertices asMask3DVertexstructures, which are 20 bytes each. -
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, thecreationMatrixis 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.
- A compromised renderer updates the canvas CTM to an arbitrary invertible perspective matrix $P$ via
SetMatrixOp(P). - The renderer crafts and sends a serialized
DrawSlugOpcarrying a forgedSDFTSubRunwherecreationMatrixis also set to $P$. - During playback in the GPU process, the drawing context evaluates
positionMatrixas $P$ (has perspective). - The GPU process computes
viewDiff = positionMatrix * creationMatrix^-1 = P * P^-1 = Identity(no perspective). - Consequently,
calculate_sdf_parametersdoes not apply the perspective flag, setting the GP vertex stride to 16 bytes.target->makeVertexSpaceallocates $16 \times 4 \times N = 64 \times N$ bytes (e.g., 32,768 bytes for $N=512$). - However,
GlyphData::fillVertexDataevaluatespositionMatrix.hasPerspective() == trueand enters theelseblock, writing vertices asMask3DVertex(20-byte stride) at $80 \times N$ bytes (e.g., 40,960 bytes). - This results in up to an 8,192-byte heap out-of-bounds write past the allocated buffer. The written data (coordinates mapped through
viewDifferenceand 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.