Overview

High
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
Tracker516915337
Fix commit95dbfa24e0b4 (skia) +51/-23
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-08

Changed Functions

FunctionChangeNotes
if
src/gpu/ganesh/ops/AtlasTextOp.cpp
modified
if
src/gpu/graphite/render/SDFTextLCDRenderStep.cpp
modified

Files Changed

  • src/gpu/ganesh/ops/AtlasTextOp.cpp
  • src/gpu/graphite/render/SDFTextLCDRenderStep.cpp
From 95dbfa24e0b470253c9c8c35e07635a0e90e433b Mon Sep 17 00:00:00 2001
From: Michael Ludwig <michaelludwig@google.com>
Date: Mon, 01 Jun 2026 10:49:39 -0400
Subject: [PATCH] Turn off LCD in SDF slugs when downscaling too far

LCD text samples the glyph at 1/3 offsets derived from screen-space
derivatives of its local coords. Each SDF atlas only has 2px of
transparent padding that these offsets can read into.

If an LCD slug was downscaled after creating its masks by a scale
of ~0.2, its R and B samples could extend into adjacent glyphs.
At such a downscaling, the SDF is already fairly low quality because
it's not sampled by mipmaps. IMO switching to grayscale SDF looked
better because it approached smooth gray vs. randomized colors.

This applies to both Ganesh and Graphite, although Graphite is able to
continue to use LCD with SDFs when there's perspective. Since Ganesh
only uses SkMatrix and not Transform, there's no easy way to estimate
the perspective scale factors applied over the slug's bounding box.

I tested by modifying GM_slug to use LCD and SDF fonts and interacted
with viewer's dynamic transforms to trigger the switch between modes.

Bug: 516915337
Change-Id: Idfa838cfe4ff9443bf2c15078ff52d34a5a5c8f3
Fixed: 516915337
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1251156
Reviewed-by: Thomas Smith <thomsmit@google.com>
Commit-Queue: Michael Ludwig <michaelludwig@google.com>
---

diff --git a/src/gpu/ganesh/ops/AtlasTextOp.cpp b/src/gpu/ganesh/ops/AtlasTextOp.cpp
index b32cc2f..c918576 100644
--- a/src/gpu/ganesh/ops/AtlasTextOp.cpp
+++ b/src/gpu/ganesh/ops/AtlasTextOp.cpp
@@ -14,6 +14,7 @@
 #include "include/private/base/SkDebug.h"
 #include "include/private/base/SkTArray.h"
 #include "src/base/SkArenaAlloc.h"
+#include "src/core/SkDistanceFieldGen.h"
 #include "src/core/SkMatrixPriv.h"
 #include "src/core/SkPaintPriv.h"
 #include "src/core/SkTraceEvent.h"
@@ -161,23 +162,37 @@
 #if !defined(SK_DISABLE_SDF_TEXT)
 static std::tuple<AtlasTextOp::MaskType, uint32_t, bool> calculate_sdf_parameters(
         const skgpu::ganesh::SurfaceDrawContext& sdc,
-        const SkMatrix& drawMatrix,
+        const SkMatrix& viewDiffMatrix,
         bool useLCDText,
         bool isAntiAliased) {
     const GrColorInfo& colorInfo = sdc.colorInfo();
     const SkSurfaceProps& props = sdc.surfaceProps();
     using MT = AtlasTextOp::MaskType;
     bool isLCD = useLCDText && props.pixelGeometry() != kUnknown_SkPixelGeometry;
+    if (isLCD) {
+        // Must check the scaling ratio of the mask texels vs. screen space since the offset for
+        // R and B samples is based on the derivative. If it gets too big, it would sample outside
+        // the 2px padding of each glyph (SK_DistanceFieldInset). We can't allow offsetting to
+        // go outside of our padding (e.g. 2*SK_DistanceFieldInset if two SDF glyphs were next to
+        // each other is theoretically ok). This is because SDF and regular A8 masks are shared in
+        // the same atlas, so an adjacent glyph may not actually have its own padding.
+        //
+        // Multiply by 3 because the derivative offset is multiplied by 1/3 for R and B offsets.
+        static constexpr float kLCDOffsetLimit = 3.f * (SK_DistanceFieldInset - 0.5f);
+        const float maxLCDOffset = viewDiffMatrix.getMaxScale();
+        isLCD &= (maxLCDOffset > 0.f && maxLCDOffset < kLCDOffsetLimit);
+    }
+
     MT maskType = !isAntiAliased ? MT::kAliasedDistanceField
                                  : isLCD ? MT::kLCDDistanceField
                                          : MT::kGrayscaleDistanceField;
 
     bool useGammaCorrectDistanceTable = colorInfo.isLinearlyBlended();
-    uint32_t DFGPFlags = drawMatrix.isSimilarity() ? kSimilarity_DistanceFieldEffectFlag : 0;
-    DFGPFlags |= drawMatrix.isScaleTranslate() ? kScaleOnly_DistanceFieldEffectFlag : 0;
+    uint32_t DFGPFlags = viewDiffMatrix.isSimilarity() ? kSimilarity_DistanceFieldEffectFlag : 0;
+    DFGPFlags |= viewDiffMatrix.isScaleTranslate() ? kScaleOnly_DistanceFieldEffectFlag : 0;
     DFGPFlags |= useGammaCorrectDistanceTable ? kGammaCorrect_DistanceFieldEffectFlag : 0;
     DFGPFlags |= MT::kAliasedDistanceField == maskType ? kAliased_DistanceFieldEffectFlag : 0;
-    DFGPFlags |= drawMatrix.hasPerspective() ? kPerspective_DistanceFieldEffectFlag : 0;
+    DFGPFlags |= viewDiffMatrix.hasPerspective() ? kPerspective_DistanceFieldEffectFlag : 0;
 
     if (isLCD) {
         bool isBGR = SkPixelGeometryIsBGR(props.pixelGeometry());
@@ -254,18 +269,19 @@
 #if !defined(SK_DISABLE_SDF_TEXT)
     auto glyphParams = subrun->glyphParams();
     if (glyphParams.isSDF) {
-         auto [maskType, DFGPFlags, useGammaCorrectDistanceTable] =
-                 calculate_sdf_parameters(*sdc, viewMatrix, glyphParams.isLCD, glyphParams.isAA);
-         op = GrOp::Make<AtlasTextOp>(rContext,
-                                      maskType,
-                                      true,
-                                      subrun->glyphCount(),
-                                      subRunDeviceBounds,
-                                      SkPaintPriv::ComputeLuminanceColor(paint),
-                                      useGammaCorrectDistanceTable,
-                                      DFGPFlags,
-                                      geometry,
-                                      std::move(grPaint));
+        SkMatrix viewDiff = subrun->vertexFiller().viewDifference(positionMatrix);
+        auto [maskType, DFGPFlags, useGammaCorrectDistanceTable] =
+                 calculate_sdf_parameters(*sdc, viewDiff, glyphParams.isLCD, glyphParams.isAA);
+        op = GrOp::Make<AtlasTextOp>(rContext,
+                                     maskType,
+                                     true,
+                                     subrun->glyphCount(),
+                                     subRunDeviceBounds,
+                                     SkPaintPriv::ComputeLuminanceColor(paint),
+                                     useGammaCorrectDistanceTable,
+                                     DFGPFlags,
+                                     geometry,
+                                     std::move(grPaint));
      } else
 #endif
      {
diff --git a/src/gpu/graphite/render/SDFTextLCDRenderStep.cpp b/src/gpu/graphite/render/SDFTextLCDRenderStep.cpp
index 7086d79..4c9507f5 100644
--- a/src/gpu/graphite/render/SDFTextLCDRenderStep.cpp
+++ b/src/gpu/graphite/render/SDFTextLCDRenderStep.cpp
@@ -18,6 +18,7 @@
 #include "include/private/base/SkAssert.h"
 #include "include/private/base/SkDebug.h"
 #include "src/base/SkEnumBitMask.h"
+#include "src/core/SkDistanceFieldGen.h"
 #include "src/core/SkSLTypeShared.h"
 #include "src/gpu/graphite/AtlasProvider.h"
 #include "src/gpu/graphite/Attribute.h"
@@ -163,13 +164,24 @@
 
     // compute and write pixelGeometry vector
     SkV2 pixelGeometryDelta = {0, 0};
-    if (SkPixelGeometryIsH(subRunData.pixelGeometry())) {
-        pixelGeometryDelta = {1.f/(3*proxies[0]->dimensions().width()), 0};
-    } else if (SkPixelGeometryIsV(subRunData.pixelGeometry())) {
-        pixelGeometryDelta = {0, 1.f/(3*proxies[0]->dimensions().height())};
-    }
-    if (SkPixelGeometryIsBGR(subRunData.pixelGeometry())) {
-        pixelGeometryDelta = -pixelGeometryDelta;
+
+    // There is 2px padding of each glyph (SK_DistanceFieldInset). We can't allow offsetting to go
+    // outside of our padding (e.g. 2*SK_DistanceFieldInset if two SDF glyphs were next to each
+    // other is theoretically ok). This is because SDF and regular A8 masks are shared in the same
+    // atlas, so an adjacent glyph may not actually have its own padding.
+    //
+    // NOTE: kLCDOffsetLimit is multiplied by 3 to account for the scale added to pixelGeometryDelta
+    static constexpr float kLCDOffsetLimit = 3.f * (SK_DistanceFieldInset - 0.5f);
+    float maxLCDOffset = Transform(subRunData.maskToDevice()).localAARadius(subRunData.bounds());
+    if (maxLCDOffset < kLCDOffsetLimit) {
+        if (SkPixelGeometryIsH(subRunData.pixelGeometry())) {
+            pixelGeometryDelta = {1.f/(3*proxies[0]->dimensions().width()), 0};
+        } else if (SkPixelGeometryIsV(subRunData.pixelGeometry())) {
+            pixelGeometryDelta = {0, 1.f/(3*proxies[0]->dimensions().height())};
+        }
+        if (SkPixelGeometryIsBGR(subRunData.pixelGeometry())) {
+            pixelGeometryDelta = -pixelGeometryDelta;
+        }
     }
     gatherer->writeHalf(pixelGeometryDelta);
 
Loading diff…

Original Bug Report

reported by vm...@google.com

Out-of-Bounds Texture Read in Skia SDF-LCD Rendering via Unvalidated Subpixel Offsets

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 out-of-bounds texture read vulnerability exists in Skia’s SDF-LCD text rendering backends (both Ganesh and Graphite). In Release builds, a compromised renderer can deserialize a forged sktext::gpu::Slug containing an excessively scaled creation matrix. This forces the GPU shader to compute an inflated subpixel offset that is not clamped, resulting in sampling outside the active glyph’s bounds and potentially leaking cross-origin adjacent glyph data from the shared atlas.

Affected files:

  • third_party/skia/src/gpu/ganesh/effects/GrAtlasedShaderHelpers.h
  • third_party/skia/src/gpu/ganesh/effects/GrDistanceFieldGeoProc.cpp
  • third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp
  • third_party/skia/src/gpu/graphite/render/SDFTextLCDRenderStep.cpp
  • third_party/skia/src/sksl/sksl_graphite_frag.sksl

Estimated timestamp from git blame: 2024-06-06

Description and Root Cause

In both the Ganesh and Graphite backends of Skia, SDF-LCD text rendering calculates dynamic subpixel offsets to sample neighboring positions (for the red, green, and blue subpixel channels) on the shared kA8 glyph atlas. These offsets scale with the spatial derivative of the texture coordinates (representing the minification ratio).

However, these dynamically computed offsets are never clamped to the glyph’s assigned atlas rectangle during fragment shading. In third_party/skia/src/gpu/ganesh/effects/GrAtlasedShaderHelpers.h (lines 129-138), the adjusted texture coordinates are computed as follows:

half2 uv_adjusted = half2(coord) - offset;
distance.x = texture(atlas, uv_adjusted).r;

Similarly, in Graphite’s fragment shader third_party/skia/src/sksl/sksl_graphite_frag.sksl under $sample_indexed_atlas_lcd (lines 1394-1396):

distance.x = sample(atlas1, textureCoords - offset).r;

Because the SDF-LCD path assumes that the standard 2-texel inset padding (SK_DistanceFieldInset = 2) is always sufficient to absorb subpixel filtering shifts, it does not apply bounds clamping in the shader. However, a compromised renderer can bypass normal Blink-side layout validation and deserialize a forged sktext::gpu::Slug containing an arbitrary, extremely large scale factor in its fCreationMatrix (e.g., Scale(k, k) where k >= 12).

In third_party/skia/src/text/gpu/VertexFiller.cpp (lines 60-61), the creationMatrix is read directly from the untrusted deserialization buffer with no scale checks:

SkMatrix creationMatrix;
buffer.readMatrix(&creationMatrix);

In Release builds, the safety assert in Ganesh’s Device::drawSlug (valid_slug_matrices in third_party/skia/src/gpu/ganesh/Device.cpp:1404-1425) compiles out, and Graphite has no such verification code. As a result, when this mismatched slug is drawn, VertexFiller::boundsAndDeviceMatrix calculates a relative matrix difference (viewDifference = positionMatrix * creationMatrix^-1), which scales down the physical screen bounds of the quad by 1/k while keeping the texture coordinates unscaled.

In the fragment shader, the derivative of the texture coordinates scales up proportionally to k, producing an offset of k/3 texels. When k is sufficiently large (e.g., 12), the offset exceeds the 2-texel padding boundary, causing the shader to sample the texture data of adjacent glyphs belonging to other origins inside the globally shared kA8 atlas.

Note: We have analyzed this vulnerability statically through code inspection and do not yet have a working runtime proof of concept.

Potential Attack Scenario

  1. An attacker compromises the sandboxed Renderer process.
  2. The attacker craft a serialized IPC payload containing a DrawSlugOp with an excessively scaled creationMatrix (e.g., Scale(12, 12)) inside the VertexFiller parameters.
  3. The attacker submits this payload via the OOP-R (Out-of-Process Rasterization) command buffer interface to the GPU process.
  4. The GPU process deserializes the malicious slug without throwing an error in Release builds.
  5. During rasterization, the fragment shader computes an inflated subpixel offset (e.g., 4 texels), sampling beyond the active glyph’s boundary and retrieving adjacent glyph pixels from the shared atlas.
  6. The leaked cross-origin text pixels are blended into the attacker-controlled SharedImage raster tile, which the Renderer reads back via standard command buffer mailbox reads.

Suggested Fix

To remediate this issue, Skia should validate the matrix scale mismatch between the creation matrix and the drawing/position matrix at drawing time, even in production Release builds. Specifically, if the scaling ratio difference exceeds a safe threshold (e.g., a limit that ensures subpixel offsets cannot exceed SK_DistanceFieldInset), the draw call should be rejected or fallback to a safe rendering path.

Alternatively, enforce validation on the creationMatrix scale factor during the deserialization of VertexFiller in third_party/skia/src/text/gpu/VertexFiller.cpp.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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