Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in GFX
DescriptionUse after free in GFX
ComponentGFX
Bug ClassUAF
Tracker502282040
Fix commit76f8cf87105f (chromium/src) +165/-13
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
ui/gfx/canvas.cc
modified
IdMutatingMockPlatformFont
ui/gfx/canvas_unittest.cc
modified
TEST_F
ui/gfx/canvas_unittest.cc
modified

Files Changed

  • ui/gfx/canvas.cc
  • ui/gfx/canvas_unittest.cc
  • ui/gfx/platform_font.cc
From 76f8cf87105f96f791c95570ac53d4d7d155d2b5 Mon Sep 17 00:00:00 2001
From: Andres Ricardo Perez <andresrperez@chromium.org>
Date: Tue, 09 Jun 2026 06:53:39 -0700
Subject: [PATCH] Move PlatformFont::Compare() to a static typeface id

The PlatformFont::Compare() operator, used on LRU cache to
gfx:Canvas::GetStringWidth(), uses SkTypeface::uniqueID() as part of the
ordering. In some platforms (MacOS), the underlying typeface might be
purged from other caches. This can cause a future query to not match the
ID originally used in insertion.

This change extracts and caches the Skia unique ID once as an immutable
integer during PlatformFont construction. Sorting logic now evaluates
this locked integer directly. Additionally, if the returned
typeface_unique_id is 0, meaning it was never set, the cache is not
used.

Bug: 502282040
Change-Id: Ia9f276b52f3410f541e4189aed720903be298d3e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7841558
Reviewed-by: Colin Blundell <blundell@chromium.org>
Reviewed-by: Robert Liao <robliao@chromium.org>
Commit-Queue: Andres Ricardo Perez Rojas <andresrperez@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1643923}
---

diff --git a/ui/gfx/canvas.cc b/ui/gfx/canvas.cc
index 98ab73b..20e2e2b 100644
--- a/ui/gfx/canvas.cc
+++ b/ui/gfx/canvas.cc
@@ -124,12 +124,14 @@
       font_list.GetPrimaryFont().platform_font());
 
   // Cache only if there is one single Font, and that Font is already
-  // initialized (has a not-null PlatformFont). Otherwise SizeStringFloat()
-  // might return a different value on subsequent calls.
+  // initialized (has a non-null PlatformFont and a non-zero
+  // typeface_unique_id). Otherwise, unstable font state during initialization
+  // could lead to incorrect cache matches or misses.
   const bool use_cache =
       base::FeatureList::IsEnabled(features::kStringWidthCache) &&
       text.length() <= kMaxStringWidthCacheStringLength &&
-      font_list.GetFonts().size() == 1 && platform_font_ref;
+      font_list.GetFonts().size() == 1 && platform_font_ref &&
+      platform_font_ref->typeface_unique_id() != 0u;
 
   if (use_cache) {
     const StringWidthCacheKey key(std::u16string(text), platform_font_ref);
diff --git a/ui/gfx/canvas_unittest.cc b/ui/gfx/canvas_unittest.cc
index 3c2fed90..fac371b 100644
--- a/ui/gfx/canvas_unittest.cc
+++ b/ui/gfx/canvas_unittest.cc
@@ -4,11 +4,14 @@
 
 #include "ui/gfx/canvas.h"
 
+#include <algorithm>
+#include <array>
 #include <limits>
 
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
 #include "build/build_config.h"
+#include "skia/ext/font_utils.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "ui/base/ui_base_features.h"
 #include "ui/gfx/font_list.h"
@@ -91,4 +94,119 @@
   EXPECT_EQ(3 * 1000 + one_line_size.height(), four_line_size.height());
 }
 
+namespace {
+
+class IdMutatingMockPlatformFont : public PlatformFont {
+ public:
+  explicit IdMutatingMockPlatformFont(sk_sp<SkTypeface> typeface)
+      : typeface_(typeface) {
+    // Lock in the initial Skia ID during simulated platform construction
+    set_typeface_unique_id(typeface_ ? typeface_->uniqueID() : 0);
+  }
+
+  void SimulateBackendIdMutation(sk_sp<SkTypeface> new_typeface) {
+    // Simulate Skia purging and allocating a new SkTypeface with a new ID
+    typeface_ = new_typeface;
+  }
+
+  // Minimal virtual overrides
+  Font DeriveFont(int size_delta,
+                  int style,
+                  Font::Weight weight) const override {
+    return Font();
+  }
+  int GetHeight() override { return 10; }
+  Font::Weight GetWeight() const override { return Font::Weight::NORMAL; }
+  int GetBaseline() override { return 10; }
+  int GetCapHeight() override { return 10; }
+  int GetExpectedTextWidth(int length) override { return length * 5; }
+  int GetStyle() const override { return Font::NORMAL; }
+  const std::string& GetFontName() const override {
+    static const std::string name = "Mock";
+    return name;
+  }
+  std::string GetActualFontName() const override { return "Mock"; }
+  std::vector<std::string> GetActualFontNames() const override {
+    return {"Mock"};
+  }
+  int GetFontSize() const override { return 12; }
+  const FontRenderParams& GetFontRenderParams() override {
+    static FontRenderParams params;
+    return params;
+  }
+  sk_sp<SkTypeface> GetNativeSkTypeface() const override { return typeface_; }
+
+#if BUILDFLAG(IS_APPLE)
+  CTFontRef GetCTFont() const override { return nullptr; }
+#endif
+
+ private:
+  ~IdMutatingMockPlatformFont() override = default;
+  sk_sp<SkTypeface> typeface_;
+};
+
+}  // namespace
+
+TEST_F(CanvasTest, StringWidthCacheIdMutationResilience) {
+  base::test::ScopedFeatureList feature_list(features::kStringWidthCache);
+  auto& cache = Canvas::GetStringWidthCacheForTesting();
+  cache.Clear();
+
+  // Create different typefaces to simulate ID change.
+#if BUILDFLAG(IS_WIN)
+  std::string font1 = "Arial";
+  std::string font2 = "Courier New";
+#else
+  std::string font1 = "sans-serif";
+  std::string font2 = "monospace";
+#endif
+
+  std::array<sk_sp<SkTypeface>, 3> tfs;
+  tfs[0] = skia::MakeTypefaceFromName(font1.c_str(), SkFontStyle());
+  tfs[1] = skia::MakeTypefaceFromName(font2.c_str(), SkFontStyle());
+  tfs[2] = skia::MakeTypefaceFromName("serif", SkFontStyle());
+
+  ASSERT_TRUE(tfs[0]);
+  ASSERT_TRUE(tfs[1]);
+  ASSERT_TRUE(tfs[2]);
+
+  // Sort by unique ID to guarantee tf_low < tf_mid < tf_high
+  std::sort(tfs.begin(), tfs.end(),
+            [](const sk_sp<SkTypeface>& a, const sk_sp<SkTypeface>& b) {
+              return a->uniqueID() < b->uniqueID();
+            });
+
+  sk_sp<SkTypeface> tf_low = tfs[0];
+  sk_sp<SkTypeface> tf_mid = tfs[1];
+  sk_sp<SkTypeface> tf_high = tfs[2];
+
+  ASSERT_NE(tf_low->uniqueID(), tf_mid->uniqueID());
+  ASSERT_NE(tf_mid->uniqueID(), tf_high->uniqueID());
+
+  auto other_font = base::MakeRefCounted<IdMutatingMockPlatformFont>(tf_mid);
+  Canvas::StringWidthCacheKey key_other(u"IdMutationTest", other_font);
+
+  auto mock_font = base::MakeRefCounted<IdMutatingMockPlatformFont>(tf_low);
+  Canvas::StringWidthCacheKey key(u"IdMutationTest", mock_font);
+
+  // Insert key_other first to make it the root
+  cache.Put(key_other, 60.0f);
+  cache.Put(key, 50.0f);
+  EXPECT_EQ(cache.size(), 2U);
+
+  // Simulate Skia cache purging/re-deriving the physical font ID
+  // tf_low -> tf_high.
+  // Now key (tf_high) is left child of key_other (tf_mid), which violates BST
+  // (tf_high > tf_mid).
+  mock_font->SimulateBackendIdMutation(tf_high);
+
+  // Without the fix, the mutated ID will corrupt the RB-tree, causing Get()
+  // to return end(). With the fix, the locked ID ensures it is found perfectly.
+  EXPECT_NE(cache.Get(key), cache.end());
+
+  // Clean up cache for other tests.
+  cache.Clear();
+  EXPECT_EQ(cache.size(), 0U);
+}
+
 }  // namespace gfx
diff --git a/ui/gfx/platform_font.cc b/ui/gfx/platform_font.cc
index d01a6ca..110f9e7 100644
--- a/ui/gfx/platform_font.cc
+++ b/ui/gfx/platform_font.cc
@@ -36,13 +36,21 @@
          user_or_locale_delta;
 }
 
+PlatformFont::PlatformFont() = default;
+PlatformFont::~PlatformFont() = default;
+
+void PlatformFont::set_typeface_unique_id(uint32_t id) {
+  CHECK_EQ(typeface_unique_id_, 0U);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/ui/gfx/canvas_unittest.cc b/ui/gfx/canvas_unittest.cc
index 3c2fed90..fac371b 100644
--- a/ui/gfx/canvas_unittest.cc
+++ b/ui/gfx/canvas_unittest.cc
@@ -4,11 +4,14 @@
 
 #include "ui/gfx/canvas.h"
 
+#include <algorithm>
+#include <array>
 #include <limits>
 
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
 #include "build/build_config.h"
+#include "skia/ext/font_utils.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "ui/base/ui_base_features.h"
 #include "ui/gfx/font_list.h"
@@ -91,4 +94,119 @@
   EXPECT_EQ(3 * 1000 + one_line_size.height(), four_line_size.height());
 }
 
+namespace {
+
+class IdMutatingMockPlatformFont : public PlatformFont {
+ public:
+  explicit IdMutatingMockPlatformFont(sk_sp<SkTypeface> typeface)
+      : typeface_(typeface) {
+    // Lock in the initial Skia ID during simulated platform construction
+    set_typeface_unique_id(typeface_ ? typeface_->uniqueID() : 0);
+  }
+
+  void SimulateBackendIdMutation(sk_sp<SkTypeface> new_typeface) {
+    // Simulate Skia purging and allocating a new SkTypeface with a new ID
+    typeface_ = new_typeface;
+  }
+
+  // Minimal virtual overrides
+  Font DeriveFont(int size_delta,
+                  int style,
+                  Font::Weight weight) const override {
+    return Font();
+  }
+  int GetHeight() override { return 10; }
+  Font::Weight GetWeight() const override { return Font::Weight::NORMAL; }
+  int GetBaseline() override { return 10; }
+  int GetCapHeight() override { return 10; }
+  int GetExpectedTextWidth(int length) override { return length * 5; }
+  int GetStyle() const override { return Font::NORMAL; }
+  const std::string& GetFontName() const override {
+    static const std::string name = "Mock";
+    return name;
+  }
+  std::string GetActualFontName() const override { return "Mock"; }
+  std::vector<std::string> GetActualFontNames() const override {
+    return {"Mock"};
+  }
+  int GetFontSize() const override { return 12; }
+  const FontRenderParams& GetFontRenderParams() override {
+    static FontRenderParams params;
+    return params;
+  }
+  sk_sp<SkTypeface> GetNativeSkTypeface() const override { return typeface_; }
+
+#if BUILDFLAG(IS_APPLE)
+  CTFontRef GetCTFont() const override { return nullptr; }
+#endif
+
+ private:
+  ~IdMutatingMockPlatformFont() override = default;
+  sk_sp<SkTypeface> typeface_;
+};
+
+}  // namespace
+
+TEST_F(CanvasTest, StringWidthCacheIdMutationResilience) {
+  base::test::ScopedFeatureList feature_list(features::kStringWidthCache);
+  auto& cache = Canvas::GetStringWidthCacheForTesting();
+  cache.Clear();
+
+  // Create different typefaces to simulate ID change.
+#if BUILDFLAG(IS_WIN)
+  std::string font1 = "Arial";
+  std::string font2 = "Courier New";
+#else
+  std::string font1 = "sans-serif";
+  std::string font2 = "monospace";
+#endif
+
+  std::array<sk_sp<SkTypeface>, 3> tfs;
+  tfs[0] = skia::MakeTypefaceFromName(font1.c_str(), SkFontStyle());
+  tfs[1] = skia::MakeTypefaceFromName(font2.c_str(), SkFontStyle());
+  tfs[2] = skia::MakeTypefaceFromName("serif", SkFontStyle());
+
+  ASSERT_TRUE(tfs[0]);
+  ASSERT_TRUE(tfs[1]);
+  ASSERT_TRUE(tfs[2]);
+
+  // Sort by unique ID to guarantee tf_low < tf_mid < tf_high
+  std::sort(tfs.begin(), tfs.end(),
+            [](const sk_sp<SkTypeface>& a, const sk_sp<SkTypeface>& b) {
+              return a->uniqueID() < b->uniqueID();
+            });
+
+  sk_sp<SkTypeface> tf_low = tfs[0];
+  sk_sp<SkTypeface> tf_mid = tfs[1];
+  sk_sp<SkTypeface> tf_high = tfs[2];
+
+  ASSERT_NE(tf_low->uniqueID(), tf_mid->uniqueID());
+  ASSERT_NE(tf_mid->uniqueID(), tf_high->uniqueID());
+
+  auto other_font = base::MakeRefCounted<IdMutatingMockPlatformFont>(tf_mid);
+  Canvas::StringWidthCacheKey key_other(u"IdMutationTest", other_font);
+
+  auto mock_font = base::MakeRefCounted<IdMutatingMockPlatformFont>(tf_low);
+  Canvas::StringWidthCacheKey key(u"IdMutationTest", mock_font);
+
+  // Insert key_other first to make it the root
+  cache.Put(key_other, 60.0f);
+  cache.Put(key, 50.0f);
+  EXPECT_EQ(cache.size(), 2U);
+
+  // Simulate Skia cache purging/re-deriving the physical font ID
+  // tf_low -> tf_high.
+  // Now key (tf_high) is left child of key_other (tf_mid), which violates BST
+  // (tf_high > tf_mid).
+  mock_font->SimulateBackendIdMutation(tf_high);
+
+  // Without the fix, the mutated ID will corrupt the RB-tree, causing Get()
+  // to return end(). With the fix, the locked ID ensures it is found perfectly.
+  EXPECT_NE(cache.Get(key), cache.end());
+
+  // Clean up cache for other tests.
+  cache.Clear();
+  EXPECT_EQ(cache.size(), 0U);
+}
+
 }  // namespace gfx
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential UAF in Browser Process via corrupted StringWidthCache std::map on macOS

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 without the Chrome Security team.

Overview: A potential Use-After-Free vulnerability exists in the macOS browser process due to an unstable comparator in gfx::Canvas::StringWidthCache. The cache relies on Skia typeface unique IDs which can change after a Skia cache purge, corrupting the underlying std::map and leaving dangling std::list iterators. An attacker could potentially exploit this by manipulating UI text to trigger cache purges and memory reallocation, leading to remote code execution.

Affected files:

  • ui/gfx/platform_font_mac.mm
  • ui/gfx/platform_font.cc
  • ui/gfx/canvas.cc
  • ui/gfx/canvas.h
  • base/containers/lru_cache.h

Estimated timestamp from git blame: 2025-10-18

Mechanism

gfx::Canvas::StringWidthCache uses base::LRUCache, which internally pairs a std::map (for fast lookups) with a std::list (for MRU ordering).

The map’s key comparator (StringWidthCacheKeyCompare) compares the string text, and if identical, falls back to PlatformFont::Compare. This function compares fonts using Skia’s SkTypeface::uniqueID().

On macOS, PlatformFontMac does not cache the SkTypeface object locally. Instead, it relies on a global Skia cache (gTFCache) via SkMakeTypefaceFromCTFont. This Skia cache has a hard limit of 1024 entries and purges typefaces with a refcount of 1. Because the StringWidthCache keeps the PlatformFontMac object alive, but PlatformFontMac doesn’t hold a strong reference to the SkTypeface, the Skia cache will eventually purge it.

When the same PlatformFontMac is queried again, Skia generates a new SkTypeface with a new, incremented uniqueID. This dynamically changes the key of items already sitting inside the std::map, completely breaking the map’s strict-weak-ordering invariant.

The Use-After-Free

  1. When the LRUCache evicts an item, it calls index_.erase(key) on the map and ordering_.erase(pos) on the list.
  2. Because the map’s binary search tree is corrupted by the shifting IDs, the traversal might fail to find the node, causing index_.erase to silently return 0.
  3. However, ordering_.erase blindly succeeds and frees the std::list node memory. The map now contains a dangling std::list::iterator.
  4. A subsequent cache Get() operation can hit this dangling map entry. LRUCache will then call ordering_.splice() to move the freed node to the front of the MRU list.
  5. In libc++, std::list::splice performs an internal unlink operation (__f->__prev_->__next_ = ...). If an attacker reclaims the freed node memory, they control these pointers, yielding a powerful write-what-where memory corruption primitive. Since these are internal container pointers, they are not protected by MiraclePtr (raw_ptr).

Potential Attacker Steps

Note: Our tooling agent does not yet have the ability to run code, so these are suggested steps an attacker could follow to exploit this issue.

  1. Populate Cache: The attacker manipulates a web page to cause the browser UI (e.g., Tab title via document.title and Hover Card) to measure a short string (e.g., u"A") using two different font sizes. Both enter the cache map.
  2. Purge Skia Cache: The attacker rapidly changes document.title to a long string containing characters from over 1024 distinct Unicode scripts. The browser’s text shaper forces 1024+ font fallbacks, overflowing Skia’s cache and purging the original typefaces.
  3. Invert IDs: The attacker triggers measurements again in a specific order so the new uniqueIDs invert the original ordering of the map nodes.
  4. Trigger Eviction: The attacker measures 32 new strings, forcing the LRUCache to evict its oldest node. The map fails to erase the node, but the list node is freed back to the heap.
  5. Heap Grooming: The attacker uses IPC messages to spray the browser process heap, reclaiming the freed list node and forging the internal __prev_ and __next_ pointers.
  6. Trigger UAF: The attacker measures the original string again. The cache retrieves the dangling iterator and calls splice(), executing the write-what-where primitive to achieve Remote Code Execution in the unsandboxed browser process.

Suggested Fix

The most robust fix is to modify PlatformFontMac to cache its sk_sp<SkTypeface> internally upon creation, exactly like PlatformFontSkia does. This guarantees that as long as the PlatformFontMac object is alive, its Skia typeface (and thus its uniqueID()) remains stable, preserving the std::map ordering invariant.

Alternatively, StringWidthCacheKeyCompare should be rewritten to compare stable metrics (like font size, weight, and font name) instead of relying on the volatile SkTypeface::uniqueID().

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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