High chrome OOB ⚠️ Exploited in the wild 🔧 Commit mapped

Overview

High
Severity
CVSS
Yes
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in Skia
DescriptionOut of bounds write in Skia
ComponentSkia
Bug ClassOOB
Tracker491421267
Fix commit0cab3e4ee34b (skia) +298/-45
CISA KEVNot listed
CreditedGoogle Threat Analysis Group
Disclosed2026-03-13

Background

Ganesh
Skia’s GPU backend that renders glyphs by caching their rasterized masks in a texture atlas.
`MaskFormat`
an enum describing a glyph mask’s pixel layout (kA8 1 byte, kA565 2 bytes, kARGB 4 bytes per pixel), which determines the atlas it belongs to and its byte stride.
`SkPackedGlyphID`
a compact identifier encoding a glyph index plus sub-pixel position, historically used as the sole atlas cache key.
`GrAtlasManager`
the component that allocates atlas space and copies a glyph’s raster image into the correct format-specific atlas texture.

Root Cause Analysis

Skia’s Ganesh text atlas identified cached glyphs by SkPackedGlyphID alone: GlyphEntry stored only fPackedID, and both TextStrike::getGlyph and GlyphData::makeGlyphFromID ignored the glyph’s MaskFormat. Because a single glyph can be requested from different AtlasSubRuns under different mask formats, the first lookup would cache a GlyphEntry bound to one format’s atlas location, and a later lookup with a different maskFormat would retrieve that same entry rather than allocating a new one. In GrAtlasManager::addGlyphToAtlas, the destination atlas and its bytesPerPixel stride were derived independently from skGlyph.maskFormat(), so a stale entry whose atlas location and byte layout were sized for one format could be written using the byte stride and dimensions of another, violating the invariant that a glyph’s cache entry, its atlas location, and the pixel stride used to fill it all share one MaskFormat. When the resolved bytesPerPixel exceeds what the target atlas region was allocated for, the image copy runs past the allocated bounds, producing the out-of-bounds write.

The fix makes MaskFormat part of the cache key via a new GlyphEntryKey, so distinct formats can no longer collide on the same entry, and addGlyphToAtlas now resolves the format from the entry’s stored fFormat so allocation and copy stay consistent.

Key insight
The single core mistake was omitting MaskFormat from the glyph atlas cache key, letting glyphs of one pixel format alias entries allocated for another; the fix folds the format into a composite GlyphEntryKey and threads it through lookup, allocation, and the byte-per-pixel calculation so the entry, its atlas slot, and its copy stride always agree.

Attack Path

  1. Render mixed-format text A page draws glyphs (e.g. via a font mixing bitmap-color and alpha coverage masks) such that the same SkPackedGlyphID is emitted across AtlasSubRuns carrying different MaskFormat values.
  2. Prime the cache The first subrun calls TextStrike::getGlyph and creates a GlyphEntry and GrAtlasLocator sized for its MaskFormat.
  3. Alias with a second format A second subrun with a different maskFormat looks up the same SkPackedGlyphID, retrieves the stale entry, and reuses its atlas location under the mismatched format.
  4. Mismatched copy GrAtlasManager::addGlyphToAtlas computes bytesPerPixel from the wrong MaskFormat, so the glyph image is copied into the atlas with a stride that overruns the allocated region.
  5. Out-of-bounds write The overrun corrupts memory adjacent to the atlas backing store, giving a controllable heap write during text rendering.

Impact Assessment

An attacker who controls page content gains a heap out-of-bounds write in whatever process runs Skia’s Ganesh GPU text rasterization (the GPU process, or the renderer where GPU rasterization is unavailable). The write’s offset and content are influenced by attacker-chosen glyphs and mask formats, making it a memory-corruption primitive suitable for chaining toward code execution. The precondition is rendering text that forces the same glyph ID through more than one MaskFormat, which is reachable from ordinary web-controlled fonts and text.

Changed Functions

FunctionChangeNotes
for
src/gpu/ganesh/text/GlyphData.cpp
modified
TextStrike
src/gpu/ganesh/text/GlyphData.h
modified
if
src/gpu/ganesh/text/TextStrike.cpp
modified

Files Changed

  • bench/GlyphQuadFillBench.cpp
  • gn/tests.gni
  • src/gpu/ganesh/ops/AtlasTextOp.cpp
  • src/gpu/ganesh/text/GlyphData.cpp
  • src/gpu/ganesh/text/GlyphData.h
  • src/gpu/ganesh/text/GrAtlasManager.cpp
  • src/gpu/ganesh/text/TextStrike.cpp
  • src/gpu/ganesh/text/TextStrike.h

Audit Directions

  • Composite cache keys
    Audit every cache or hash map keyed by a glyph or resource ID for other attributes (format, size, color type) that change memory layout but were omitted from the key, since such omissions let differently-shaped objects alias.
  • Format re-derivation
    Flag places that recompute a MaskFormat or bytesPerPixel from a source object (skGlyph.maskFormat()) rather than from the authoritative allocation record, because independent derivations can diverge and desynchronize stride from buffer size.
  • Stride-versus-allocation
    Review atlas and texture copy paths where the fill stride and the allocated region’s dimensions come from different code paths, and confirm both are governed by one shared MaskFormat value.
From 0cab3e4ee34b3bca6ba7df676639d73ffe4b2135 Mon Sep 17 00:00:00 2001
From: Greg Daniel <egdaniel@google.com>
Date: Tue, 10 Mar 2026 16:22:54 -0400
Subject: [PATCH] Make sure we are getting the correct atlas for glyph mask format.

Bug: b/491421267
Change-Id: I4eacd46599eca2df8c10a3fc894b9ce890fae1e2
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/1184076
Commit-Queue: Greg Daniel <egdaniel@google.com>
Reviewed-by: Michael Ludwig <michaelludwig@google.com>
---

diff --git a/bench/GlyphQuadFillBench.cpp b/bench/GlyphQuadFillBench.cpp
index c478602..b926c6f 100644
--- a/bench/GlyphQuadFillBench.cpp
+++ b/bench/GlyphQuadFillBench.cpp
@@ -73,7 +73,7 @@
                 sktext::gpu::TextBlobTools::FirstSubRun(fBlob.get());
         SkASSERT_RELEASE(subRun);
         if (!subRun->glyphVector().hasBackendData()) {
-            subRun->glyphVector().initBackendData<GlyphData>(&fCache);
+            subRun->glyphVector().initBackendData<GlyphData>(&fCache, subRun->maskFormat());
         }
         const auto& glyphData = subRun->glyphVector().accessBackendData<GlyphData>();
         fVertices.reset(new char[glyphData.vertexStride(subRun->maskFormat(), drawMatrix) *
diff --git a/gn/tests.gni b/gn/tests.gni
index 902f44c..ba21a70 100644
--- a/gn/tests.gni
+++ b/gn/tests.gni
@@ -437,6 +437,7 @@
 ganesh_tests_sources = [
   "$_tests/AdvancedBlendTest.cpp",
   "$_tests/ApplyGammaTest.cpp",
+  "$_tests/AtlasOobTest.cpp",
   "$_tests/BackendAllocationTest.cpp",
   "$_tests/BackendSurfaceMutableStateTest.cpp",
   "$_tests/BlendTest.cpp",
diff --git a/src/gpu/ganesh/ops/AtlasTextOp.cpp b/src/gpu/ganesh/ops/AtlasTextOp.cpp
index eee7f52..26d10a4 100644
--- a/src/gpu/ganesh/ops/AtlasTextOp.cpp
+++ b/src/gpu/ganesh/ops/AtlasTextOp.cpp
@@ -538,7 +538,7 @@
         const sktext::gpu::AtlasSubRun& subRun = geo->fSubRun;
 
         if (!subRun.glyphVector().hasBackendData()) {
-            subRun.glyphVector().initBackendData<GlyphData>(target->strikeCache());
+            subRun.glyphVector().initBackendData<GlyphData>(target->strikeCache(), maskFormat);
         }
 
         auto& glyphData = subRun.glyphVector().accessBackendData<GlyphData>();
diff --git a/src/gpu/ganesh/text/GlyphData.cpp b/src/gpu/ganesh/text/GlyphData.cpp
index c1416af..503ac353 100644
--- a/src/gpu/ganesh/text/GlyphData.cpp
+++ b/src/gpu/ganesh/text/GlyphData.cpp
@@ -171,7 +171,9 @@
 
 GlyphData::~GlyphData() = default;
 
-Glyph GlyphData::makeGlyphFromID(SkPackedGlyphID id) { return Glyph{fTextStrike->getGlyph(id)}; }
+Glyph GlyphData::makeGlyphFromID(SkPackedGlyphID id, MaskFormat format) {
+    return Glyph{fTextStrike->getGlyph(id, format)};
+}
 
 std::tuple<bool, int> GlyphData::regenerateAtlas(int begin,
                                                  int end,
@@ -199,7 +201,7 @@
         bool success = true;
         for (int i = begin; i < end; i++) {
             const Glyph& glyph = glyphSpan[i];
-
+            SkASSERT(glyph.entry().fGlyphEntryKey.fFormat == maskFormat);
             if (!atlasManager->hasGlyph(maskFormat, glyph.entry())) {
                 const SkGlyph& skGlyph = *metricsAndImages.glyph(glyph.packedID());
                 auto code = atlasManager->addGlyphToAtlas(skGlyph,
diff --git a/src/gpu/ganesh/text/GlyphData.h b/src/gpu/ganesh/text/GlyphData.h
index 7715492..7c57d42 100644
--- a/src/gpu/ganesh/text/GlyphData.h
+++ b/src/gpu/ganesh/text/GlyphData.h
@@ -34,13 +34,31 @@
 namespace skgpu::ganesh {
 class TextStrike;
 
+struct GlyphEntryKey {
+    explicit GlyphEntryKey(SkPackedGlyphID id, MaskFormat format) : fPackedID(id), fFormat(format) {}
+
+    const SkPackedGlyphID fPackedID;
+    MaskFormat fFormat;
+
+    bool operator==(const GlyphEntryKey& that) const {
+        return fPackedID == that.fPackedID && fFormat == that.fFormat;
+    }
+    bool operator!=(const GlyphEntryKey& that) const {
+        return !(*this == that);
+    }
+
+    uint32_t hash() const {
+        return fPackedID.hash();
+    }
+};
+
 /**
  * Ganesh-specific glyph type with atlas location information.
  */
 struct GlyphEntry {
-    explicit GlyphEntry(SkPackedGlyphID id) : fPackedID(id) {}
+    explicit GlyphEntry(SkPackedGlyphID id, MaskFormat format) : fGlyphEntryKey(id, format) {}
 
-    const SkPackedGlyphID fPackedID;
+    const GlyphEntryKey fGlyphEntryKey;
     GrAtlasLocator fAtlasLocator;
 };
 
@@ -56,7 +74,7 @@
 
 public:
     explicit Glyph(GlyphEntry* entry) : fEntry{entry} { SkASSERT(entry); }
-    SkPackedGlyphID packedID() const { return fEntry->fPackedID; }
+    SkPackedGlyphID packedID() const { return fEntry->fGlyphEntryKey.fPackedID; }
     GlyphEntry& entry() const { return *fEntry; }
 };
 
@@ -74,7 +92,7 @@
 
     ~GlyphData();
 
-    Glyph makeGlyphFromID(SkPackedGlyphID);
+    Glyph makeGlyphFromID(SkPackedGlyphID, MaskFormat);
 
     // Regenerate atlas entries for glyphs in range [begin, end).
     // Returns {success, glyphs_placed_in_atlas}.
diff --git a/src/gpu/ganesh/text/GrAtlasManager.cpp b/src/gpu/ganesh/text/GrAtlasManager.cpp
index f5dc77b..f1a2bd3 100644
--- a/src/gpu/ganesh/text/GrAtlasManager.cpp
+++ b/src/gpu/ganesh/text/GrAtlasManager.cpp
@@ -177,8 +177,7 @@
     }
     SkASSERT(glyph != nullptr);
 
-    MaskFormat glyphFormat = sktext::gpu::FormatFromSkGlyph(skGlyph.maskFormat());
-    MaskFormat expectedMaskFormat = this->resolveMaskFormat(glyphFormat);
+    MaskFormat expectedMaskFormat = this->resolveMaskFormat(glyph->fGlyphEntryKey.fFormat);
     int bytesPerPixel = MaskFormatBytesPerPixel(expectedMaskFormat);
 
     int padding;
diff --git a/src/gpu/ganesh/text/TextStrike.cpp b/src/gpu/ganesh/text/TextStrike.cpp
index 8771fca..94b3521 100644
--- a/src/gpu/ganesh/text/TextStrike.cpp
+++ b/src/gpu/ganesh/text/TextStrike.cpp
@@ -28,20 +28,21 @@
     return newStrike;
 }
 
-GlyphEntry* TextStrike::getGlyph(SkPackedGlyphID packedGlyphID) {
-    GlyphEntry* glyph = fCache.findOrNull(packedGlyphID);
+GlyphEntry* TextStrike::getGlyph(SkPackedGlyphID packedGlyphID, MaskFormat format) {
+    GlyphEntryKey localKey(packedGlyphID, format);
+    GlyphEntry* glyph = fCache.findOrNull(localKey);
     if (glyph == nullptr) {
-        glyph = fAlloc.make<GlyphEntry>(packedGlyphID);
+        glyph = fAlloc.make<GlyphEntry>(packedGlyphID, format);
         fCache.set(glyph);
         this->addMemoryUsed(sizeof(GlyphEntry));
     }
     return glyph;
 }
 
-const SkPackedGlyphID& TextStrike::HashTraits::GetKey(const GlyphEntry* glyph) {
-    return glyph->fPackedID;
+const GlyphEntryKey& TextStrike::HashTraits::GetKey(const GlyphEntry* glyph) {
+    return glyph->fGlyphEntryKey;
 }
 
-uint32_t TextStrike::HashTraits::Hash(SkPackedGlyphID key) { return key.hash(); }
+uint32_t TextStrike::HashTraits::Hash(GlyphEntryKey key) { return key.hash(); }
 
 }  // namespace skgpu::ganesh
diff --git a/src/gpu/ganesh/text/TextStrike.h b/src/gpu/ganesh/text/TextStrike.h
index 8222eff..de4f421 100644
--- a/src/gpu/ganesh/text/TextStrike.h
+++ b/src/gpu/ganesh/text/TextStrike.h
@@ -10,6 +10,7 @@
 
 #include "include/core/SkRefCnt.h"
 #include "src/core/SkTHash.h"
+#include "src/gpu/MaskFormat.h"
 #include "src/text/gpu/StrikeCache.h"
 
 struct SkPackedGlyphID;
@@ -18,6 +19,7 @@
 namespace skgpu::ganesh {
 
 struct GlyphEntry;
+struct GlyphEntryKey;
 
 /**
  * Ganesh-specific text strike cache entry.
@@ -34,14 +36,15 @@
     static sk_sp<TextStrike> GetOrCreate(sktext::gpu::StrikeCache* strikeCache,
                                          const SkStrikeSpec& strikeSpec);
 
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.