CVE-2026-11675
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/gpu/ganesh/ops/AtlasTextOp.cpp |
modified | |
ifsrc/gpu/graphite/render/SDFTextLCDRenderStep.cpp |
modified |
Files Changed
src/gpu/ganesh/ops/AtlasTextOp.cppsrc/gpu/graphite/render/SDFTextLCDRenderStep.cpp
Patch
From 95dbfa24e0b470253c9c8c35e07635a0e90e433b Mon Sep 17 00:00:00 2001 From: Michael Ludwig <[email protected]> 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 <[email protected]> Commit-Queue: Michael Ludwig <[email protected]> --- 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);
Original Bug Report
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.hthird_party/skia/src/gpu/ganesh/effects/GrDistanceFieldGeoProc.cppthird_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cppthird_party/skia/src/gpu/graphite/render/SDFTextLCDRenderStep.cppthird_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
- An attacker compromises the sandboxed Renderer process.
- The attacker craft a serialized IPC payload containing a
DrawSlugOpwith an excessively scaledcreationMatrix(e.g.,Scale(12, 12)) inside theVertexFillerparameters. - The attacker submits this payload via the OOP-R (Out-of-Process Rasterization) command buffer interface to the GPU process.
- The GPU process deserializes the malicious slug without throwing an error in Release builds.
- 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.
- 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.