Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Fonts
DescriptionUse after free in Fonts
ComponentFonts
Bug ClassUAF
Tracker492374380
Fix commitdd21b7b159ee (chromium/src) +575/-65
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
WeakValueTable
base/containers/weak_value_table.h
modified
RefCountedWeakValue
base/containers/weak_value_table.h
modified
if
base/containers/weak_value_table.h
modified

Files Changed

  • base/BUILD.gn
  • base/containers/weak_value_table.h
From dd21b7b159ee1575fe6e21a82d9ff2a60f6feca0 Mon Sep 17 00:00:00 2001
From: Daniel Cheng <dcheng@chromium.org>
Date: Wed, 22 Apr 2026 15:43:36 -0700
Subject: [PATCH] Implement helper for a thread-safe shared cache of weakly-owned values

The core logic is taken from blink::AtomicStringTable; the key insight
is the final decrement to zero needs to be coordinated with the table to
avoid a race where one thread acquires a new reference just as another
thread is trying to drop the final reference. In the future, this may be
rewritten to take advantage of primitives from the standard library,
i.e. `std::shared_ptr` and `std::weak_ptr`.

Migrate the font service to use this new helper; in theory, it might be
possible to layer `blink::AtomicStringTable` on top of this helper as
well, but `base::AtomicStringTable` has a lot of additional complexity
to ensure atomicity of strings, so that is left as an exercise for a
future reader.

gemini-cli was used to expand test TODOs into actual tests as well as
suggest and implement additional test coverage.

Bug: 492374380
Change-Id: If903b247109e11be2fb6440297a59e78d20d4ac7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7706875
Commit-Queue: Daniel Cheng <dcheng@chromium.org>
Reviewed-by: Sean Maher <spvm@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1619150}
---

diff --git a/base/BUILD.gn b/base/BUILD.gn
index c9967c8..9b20760 100644
--- a/base/BUILD.gn
+++ b/base/BUILD.gn
@@ -276,6 +276,7 @@
     "containers/to_vector.h",
     "containers/unique_ptr_adapters.h",
     "containers/vector_buffer.h",
+    "containers/weak_value_table.h",
     "critical_closure.h",
     "debug/alias.cc",
     "debug/alias.h",
@@ -3575,6 +3576,7 @@
     "containers/to_vector_unittest.cc",
     "containers/unique_ptr_adapters_unittest.cc",
     "containers/vector_buffer_unittest.cc",
+    "containers/weak_value_table_unittest.cc",
     "cpu_unittest.cc",
     "debug/alias_unittest.cc",
     "debug/asan_service_unittest.cc",
diff --git a/base/containers/weak_value_table.h b/base/containers/weak_value_table.h
new file mode 100644
index 0000000..a13f476
--- /dev/null
+++ b/base/containers/weak_value_table.h
@@ -0,0 +1,210 @@
+// 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.
+
+#ifndef BASE_CONTAINERS_WEAK_VALUE_TABLE_H_
+#define BASE_CONTAINERS_WEAK_VALUE_TABLE_H_
+
+#include <atomic>
+#include <type_traits>
+
+#include "base/check.h"
+#include "base/dcheck_is_on.h"
+#include "base/memory/raw_ptr.h"
+#include "base/memory/ref_counted.h"
+#include "base/memory/scoped_refptr.h"
+#include "base/synchronization/lock.h"
+#include "base/thread_annotations.h"
+#include "third_party/abseil-cpp/absl/container/flat_hash_map.h"
+
+// A table that weakly owns thread-safe ref-counted values. The primary
+// supported operation is `FindOrCreate()`, which either:
+// - returns an owning reference to the value already associated with the key
+//   in the table if the key is already present or
+// - creates a new value, associates it with key in the table, and returns an
+//   owning reference to the new value.
+//
+// Value type implementations must:
+// - inherit from `RefCountedWeakValue<Key, Value>` using the CRTP.
+// - provide an idempotent `Getkey()` method that returns the key associated
+//   with the given value in the table–this is needed to remove the entry from
+//   the table when the refcount reaches zero.
+// - be constructed with `base::MakeRefCounted`, since the initial refcount
+//   starts at 1.
+
+namespace base::subtle {
+
+template <typename Key, typename ValueT>
+class WeakValueTable;
+
+template <typename Key, typename Value>
+class RefCountedWeakValue {
+ public:
+  REQUIRE_ADOPTION_FOR_REFCOUNTED_TYPE();
+
+  RefCountedWeakValue() = default;
+  ~RefCountedWeakValue() = default;
+
+  RefCountedWeakValue(const RefCountedWeakValue&) = delete;
+  RefCountedWeakValue& operator=(const RefCountedWeakValue&) = delete;
+
+  [[nodiscard]] bool HasOneRef() const {
+    return count_.load(std::memory_order_acquire) == 1;
+  }
+
+  void AddRef() {
+#if DCHECK_IS_ON()
+    DCHECK(!needs_adoption_);
+#endif
+    CHECK(count_.fetch_add(1, std::memory_order_relaxed) != 0);
+  }
+
+  void Release() {
+    size_t current = count_.load(std::memory_order_relaxed);
+    do {
+      // The last refcount needs to be released in coordination with `table_`
+      // to avoid races with `AddRef()`.
+      if (current == 1) {
+        static_cast<Value*>(this)->InternalTestHookForReleaseRace();
+        if (table_) {
+          // CRTP promises this cast is safe.
+          if (table_->ReleaseAndRemoveIfUnused(static_cast<Value*>(this))) {
+            // No race to acquire a new reference, so it's safe to delete
+            // `this`.
+            DeleteThis();
+          }
+        } else {
+          // Not inserted into the table yet, so no coordination needed; just
+          // delete `this` directly.
+          DeleteThis();
+        }
+        return;
+      }
+      // If the compare-and-swap fails, it updates `current` so no need to
+      // reload `current`.
+    } while (!count_.compare_exchange_weak(current, current - 1,
+                                           std::memory_order_acq_rel));
+  }
+
+  void Adopted() {
+#if DCHECK_IS_ON()
+    DCHECK(needs_adoption_);
+    needs_adoption_ = false;
+#endif
+  }
+
+  void InternalTestHookForReleaseRace() {}
+
+ private:
+  friend class WeakValueTable<Key, Value>;
+
+  // Returns `true` if the last ref was released.
+  [[nodiscard]] bool MaybeReleaseLastRef() {
+    return count_.fetch_sub(1, std::memory_order_acq_rel) == 1;
+  }
+
+  void DeleteThis() { delete static_cast<Value*>(this); }
+
+  std::atomic<size_t> count_ = 1;
+  raw_ptr<WeakValueTable<Key, Value>> table_;
+#if DCHECK_IS_ON()
+  bool needs_adoption_ = true;
+#endif
+};
+
+template <typename Key, typename ValueT>
+class WeakValueTable {
+  static_assert(std::is_base_of_v<RefCountedWeakValue<Key, ValueT>, ValueT>);
+
+ public:
+  WeakValueTable() = default;
+  ~WeakValueTable() { CHECK(empty()); }
+
+  WeakValueTable(const WeakValueTable&) = delete;
+  WeakValueTable& operator=(const WeakValueTable&) = delete;
+
+  // Finds and returns the value keyed to `key` in the table, or invokes
+  // `callable` to create a new value to associate with `key` in the table and
+  // returns the newly-created value. Creation can race on different threads,
+  // since the lock is dropped while invoking `callable`.
+  template <typename K, typename Callable>
+    requires requires(Callable&& callable) {
+      // The constraint is intentionally somewhat strict, since `ValueT*` is
+      // also convertible to `scoped_refptr<ValueT>`.
+      { callable() } -> std::same_as<scoped_refptr<ValueT>>;
+    }
+  [[nodiscard]] scoped_refptr<ValueT> FindOrCreate(K&& key,
+                                                   Callable&& callable) {
+    {
+      AutoLock lock(table_lock_);
+      auto it = table_.find(key);
+      if (it != table_.end()) {
+        return scoped_refptr<ValueT>(it->second.get());
+      }
+    }
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/base/containers/weak_value_table_unittest.cc b/base/containers/weak_value_table_unittest.cc
new file mode 100644
index 0000000..b2c20eb
--- /dev/null
+++ b/base/containers/weak_value_table_unittest.cc
@@ -0,0 +1,340 @@
+// 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.
+
+#include "base/containers/weak_value_table.h"
+
+#include <optional>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "base/functional/callback.h"
+#include "base/functional/callback_helpers.h"
+#include "base/memory/raw_ptr.h"
+#include "base/memory/scoped_refptr.h"
+#include "base/synchronization/waitable_event.h"
+#include "base/test/bind.h"
+#include "base/test/gtest_util.h"
+#include "base/threading/simple_thread.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace base {
+
+using subtle::RefCountedWeakValue;
+using subtle::WeakValueTable;
+
+class TestValue : public RefCountedWeakValue<std::string, TestValue> {
+ public:
+  explicit TestValue(std::string key, OnceClosure on_destroy = NullCallback())
+      : key_(std::move(key)), on_destroy_(std::move(on_destroy)) {}
+
+  const std::string& GetKey() const { return key_; }
+
+  void set_on_release_race_closure(base::OnceClosure closure) {
+    on_release_race_ = std::move(closure);
+  }
+
+  void InternalTestHookForReleaseRace() {
+    if (on_release_race_) {
+      std::move(on_release_race_).Run();
+    }
+  }
+
+ private:
+  friend RefCountedWeakValue;
+
+  ~TestValue() {
+    if (on_destroy_) {
+      std::move(on_destroy_).Run();
+    }
+  }
+
+  std::string key_;
+  OnceClosure on_release_race_;
+  OnceClosure on_destroy_;
+};
+
+TEST(WeakValueTableTest, FindOrCreate) {
+  WeakValueTable<std::string, TestValue> table;
+
+  bool hello_was_destroyed = false;
+  bool world_was_destroyed = false;
+  scoped_refptr<TestValue> hello;
+
+  {
+    bool called = false;
+    hello = table.FindOrCreate("hello", [&] {
+      called = true;
+      return MakeRefCounted<TestValue>(
+          "hello", BindLambdaForTesting([&] { hello_was_destroyed = true; }));
+    });
+    EXPECT_TRUE(called);
+    EXPECT_EQ("hello", hello->GetKey());
+  }
+
+  // The callable should not be invoked when trying to insert another value with
+  // the same key; the value already in the table should be returned instead.
+  {
+    bool called = false;
+    scoped_refptr<TestValue> hello2 =
+        table.FindOrCreate("hello", [&] -> scoped_refptr<TestValue> {
+          called = true;
+          return nullptr;
+        });
+    EXPECT_FALSE(called);
+    EXPECT_EQ(hello.get(), hello2.get());
+  }
+
+  {
+    bool called = false;
+    scoped_refptr<TestValue> world = table.FindOrCreate("world", [&] {
+      called = true;
+      return MakeRefCounted<TestValue>(
+          "world", BindLambdaForTesting([&] { world_was_destroyed = true; }));
+    });
+    EXPECT_TRUE(called);
+    EXPECT_EQ("world", world->GetKey());
+
+    EXPECT_FALSE(table.empty());
+    EXPECT_FALSE(world_was_destroyed);
+    world = nullptr;
+    EXPECT_FALSE(table.empty());
+    EXPECT_TRUE(world_was_destroyed);
+  }
+
+  EXPECT_FALSE(table.empty());
+  EXPECT_FALSE(hello_was_destroyed);
+  hello = nullptr;
+  EXPECT_TRUE(table.empty());
+  EXPECT_TRUE(hello_was_destroyed);
+}
+
+TEST(WeakValueTableTest, FindOrCreateNullptr) {
+  WeakValueTable<std::string, TestValue> table;
+
+  {
+    bool called = false;
+    scoped_refptr<TestValue> value =
+        table.FindOrCreate("hello", [&] -> scoped_refptr<TestValue> {
+          called = true;
+          return nullptr;
+        });
+    EXPECT_TRUE(called);
+    EXPECT_EQ(nullptr, value);
+  }
+
+  // The first insert failed since the callable returned nullptr, so the second
+  // insert should invoke the callable.
+  {
+    bool called = false;
+    scoped_refptr<TestValue> value = table.FindOrCreate("hello", [&] {
+      called = true;
+      return MakeRefCounted<TestValue>("hello");
+    });
+    EXPECT_TRUE(called);
+    EXPECT_EQ("hello", value->GetKey());
+  }
+}
+
+TEST(WeakValueTableTest, DestroyNeverInsertedValue) {
+  base::MakeRefCounted<TestValue>("hello");
+}
+
+TEST(WeakValueTableTest, RaceToCreate) {
+  WeakValueTable<std::string, TestValue> table;
+
+  bool first_destroyed = false;
+  bool second_destroyed = false;
+  scoped_refptr<TestValue> keep_alive;
+
+  scoped_refptr<TestValue> value = table.FindOrCreate("hello", [&] {
+    auto first = MakeRefCounted<TestValue>(
+        "hello", BindLambdaForTesting([&] { first_destroyed = true; }));
+    // While inserting the first element, reentrantly insert the second one.
+    auto second = table.FindOrCreate("hello", [&] {
+      return MakeRefCounted<TestValue>(
+          "hello", BindLambdaForTesting([&] { second_destroyed = true; }));
+    });
+    // Both objects are still live and should have distinct addresses.
+    EXPECT_NE(first, second);
+    // Allow the reference to escape; otherwise, `second` will be destroyed
+    // and removed from the table before returning to the first
+    // `FindOrCreate()` call.
+    keep_alive = std::move(second);
+    return first;
+  });
+
+  EXPECT_FALSE(table.empty());
+  EXPECT_TRUE(first_destroyed);
+  EXPECT_FALSE(second_destroyed);
+  // At this point, both `keep_alive` and `value` should be the same object.
+  EXPECT_EQ(keep_alive, value);
+  EXPECT_FALSE(keep_alive->HasOneRef());
+  value = nullptr;
+  EXPECT_TRUE(keep_alive->HasOneRef());
+  keep_alive = nullptr;
+  EXPECT_TRUE(second_destroyed);
+  EXPECT_TRUE(table.empty());
+}
+
+TEST(WeakValueTableTest, RaceRemovalAndFind) {
+  WeakValueTable<std::string, TestValue> table;
+
+  bool was_destroyed = false;
+  scoped_refptr<TestValue> keep_alive;
+
+  scoped_refptr<TestValue> value = table.FindOrCreate("hello", [&] {
+    auto value = base::MakeRefCounted<TestValue>(
+        "hello", base::BindLambdaForTesting([&] { was_destroyed = true; }));
+    value->set_on_release_race_closure(base::BindLambdaForTesting([&] {
+      bool called = false;
+      keep_alive = table.FindOrCreate("hello", [&] -> scoped_refptr<TestValue> {
+        called = true;
+        return nullptr;
+      });
+      EXPECT_FALSE(called);
+    }));
+    return value;
+  });
+
+  EXPECT_FALSE(table.empty());
+  EXPECT_FALSE(was_destroyed);
+  EXPECT_TRUE(value->HasOneRef());
+  EXPECT_EQ(nullptr, keep_alive);
+  // Trigger the race by releasing the last reference. The on release race
+  // callback bound earlier will take a reference right before the value tries
+  // to release it's last reference, so the end result should be that the value
+  // is not destroyed yet.
+  value = nullptr;
+  EXPECT_FALSE(table.empty());
+  EXPECT_FALSE(was_destroyed);
+  ASSERT_TRUE(!!keep_alive);
+  EXPECT_TRUE(keep_alive->HasOneRef());
+  keep_alive = nullptr;
+  EXPECT_TRUE(table.empty());
+  EXPECT_TRUE(was_destroyed);
+}
+
+TEST(WeakValueTableTest, CreateDuringValueDestruction) {
+  WeakValueTable<std::string, TestValue> table;
+
+  bool first_destroyed = false;
+  bool second_destroyed = false;
+  scoped_refptr<TestValue> second;
+
+  scoped_refptr<TestValue> first = table.FindOrCreate("hello", [&] {
+    return MakeRefCounted<TestValue>(
+        "hello", BindLambdaForTesting([&] {
+          first_destroyed = true;
+          second = table.FindOrCreate("hello", [&] {
+            return MakeRefCounted<TestValue>("hello", BindLambdaForTesting([&] {
+                                               second_destroyed = true;
+                                             }));
+          });
+        }));
+  });
+
+  EXPECT_FALSE(first_destroyed);
+  EXPECT_FALSE(second_destroyed);
+  first = nullptr;
+  EXPECT_TRUE(first_destroyed);
+  EXPECT_FALSE(second_destroyed);
+  second = nullptr;
+  EXPECT_TRUE(second_destroyed);
+}
+
+TEST(WeakValueTableTest, DestroyNonEmptyTable) {
+  std::optional<WeakValueTable<std::string, TestValue>> table(std::in_place);
+  scoped_refptr<TestValue> value = table->FindOrCreate(
+      "hello", [] { return MakeRefCounted<TestValue>("hello"); });
+
+  // `value` is still live
+  EXPECT_FALSE(table->empty());
+  // Destroying a non-empty table is unsafe so crash instead.
+  EXPECT_CHECK_DEATH(table.reset());
+}
+
+TEST(WeakValueTableTest, CallableReturnsPointerWithMultipleRefs) {
+  WeakValueTable<std::string, TestValue> table;
+  EXPECT_CHECK_DEATH({
+    scoped_refptr<TestValue> extra_ref;
+    (void)table.FindOrCreate("hello", [&] {
+      auto value = MakeRefCounted<TestValue>("hello");
+      extra_ref = value;
+      return value;
+    });
+  });
+}
+
+TEST(WeakValueTableTest, ReentrantCreateDifferentKey) {
+  WeakValueTable<std::string, TestValue> table;
+
+  scoped_refptr<TestValue> first = table.FindOrCreate("hello", [&] {
+    // Re-entrantly insert a different key while constructing "hello"
+    scoped_refptr<TestValue> second = table.FindOrCreate(
+        "world", [] { return MakeRefCounted<TestValue>("world"); });
+    EXPECT_EQ("world", second->GetKey());
+    return MakeRefCounted<TestValue>("hello");
+  });
+
+  EXPECT_EQ("hello", first->GetKey());
+  EXPECT_FALSE(table.empty());
+}
+
+TEST(WeakValueTableTest, MismatchedKey) {
+  WeakValueTable<std::string, TestValue> table;
+  // This should crash because TestValue::GetKey() will return "world", which
+  // does not match with the originally-requested key "hello".
+  EXPECT_CHECK_DEATH((void)table.FindOrCreate(
+      "hello", [] { return MakeRefCounted<TestValue>("world"); }));
+}
+
+namespace {
+
... (truncated)
Loading diff…

Original Bug Report

reported by je...@gmail.com

[Linux] Cross-Thread Use-After-Free in FontLoader::openStream via Non-Owning MappedFontFile Cache

[Linux] Cross-Thread Use-After-Free in FontLoader::openStream via Non-Owning MappedFontFile Cache

Summary

A use-after-free vulnerability exists in the Linux font service’s FontLoader::openStream method. The mapped_font_files_ map stores non-owning raw pointers to MappedFontFile objects, and the cleanup of stale entries relies on a destructor-driven observer callback that acquires the same lock protecting the map. When one thread finds a dying MappedFontFile in the map and calls CreateMemoryStream on it, a concurrent thread can complete the object’s destruction and deallocation before the first thread’s SkData release callback fires, resulting in a heap-use-after-free. The vulnerability is reachable from JavaScript through the FontFace local font loading API in Web Workers and affects Linux only.

Bisect

Introducing Commit: ae33b4cefb07476b0c28be444221436954797e31

The non-owning pointer pattern in mapped_font_files_ dates back to 4196dfefc131b (2015), but the vulnerability became practically exploitable when this commit restored the 42-entry typeface_cache_ LRU. Cache eviction during makeTypeface now provides a reliable path to MappedFontFile destruction while other threads race to access the same entries through openStream.

Root Cause

FontLoader maintains two caches that interact unsafely across threads. The typeface_cache_ is a 42-entry LRU cache of sk_sp<SkTypeface>, and mapped_font_files_ is an std::unordered_map storing non-owning raw_ptr<MappedFontFile> pointers keyed by font ID:

// font_loader.h
std::unordered_map<uint32_t,
                   raw_ptr<internal::MappedFontFile, CtnExperimental>>
    mapped_font_files_ GUARDED_BY(mapped_font_files_lock_);

MappedFontFile inherits from RefCountedThreadSafe and uses an observer pattern to remove itself from the map during destruction:

// mapped_font_file.cc
MappedFontFile::~MappedFontFile() {
  if (observer_)
    observer_->OnMappedFontFileDestroyed(this);
}

The observer callback acquires mapped_font_files_lock_ and erases the entry:

// font_loader.cc
void FontLoader::OnMappedFontFileDestroyed(internal::MappedFontFile* f) {
  base::AutoLock lock(mapped_font_files_lock_);
  mapped_font_files_.erase(f->font_id());
}

The race manifests when openStream finds an entry in the map for a MappedFontFile whose refcount is concurrently reaching zero on another thread. The critical sequence is:

Thread A (racer Worker) calls openStream, acquires mapped_font_files_lock_, finds the entry, and calls CreateMemoryStream:

// font_loader.cc
SkStreamAsset* FontLoader::openStream(const FontIdentity& identity) {
  {
    base::AutoLock lock(mapped_font_files_lock_);
    auto mapped_font_files_it = mapped_font_files_.find(identity.fID);
    if (mapped_font_files_it != mapped_font_files_.end())
      return mapped_font_files_it->second->CreateMemoryStream();
  }
  // ...
}

CreateMemoryStream captures this as the SkData release callback context and increments the refcount:

// mapped_font_file.cc
SkMemoryStream* MappedFontFile::CreateMemoryStream() {
  sk_sp<SkData> data =
      SkData::MakeWithProc(mapped_font_file_.data(), mapped_font_file_.length(),
                           &MappedFontFile::ReleaseProc, this);
  AddRef();
  return new SkMemoryStream(std::move(data));
}

Thread B (loader Worker shutting down) simultaneously destroys the last SkTypeface that held a reference to the same MappedFontFile. The SkData release callback calls MappedFontFile::Release(), which decrements the refcount to zero and enters the destructor. The destructor calls OnMappedFontFileDestroyed, which blocks on mapped_font_files_lock_ (held by Thread A). Thread A completes CreateMemoryStream, releasing the lock. Thread B then proceeds to erase the map entry and deallocate the MappedFontFile.

The SkMemoryStream returned to Thread A now contains an SkData whose release context points to freed memory. When Thread A’s SkTypeface is eventually destroyed (during Worker shutdown or cache eviction), ReleaseProc executes an atomic decrement on the freed MappedFontFile’s refcount field:

// mapped_font_file.cc
void MappedFontFile::ReleaseProc(const void* ptr, void* context) {
  static_cast<MappedFontFile*>(context)->Release();
}

This constitutes a heap-use-after-free write. Additionally, the SkData data pointer references the MappedFontFile’s MemoryMappedFile backing, which is also destroyed, so any font data reads between CreateMemoryStream and the eventual crash access unmapped memory.

The raw_ptr<MappedFontFile, CtnExperimental> wrapper in the map does not prevent this UAF. MiraclePtr detects accesses to quarantined (freed) memory, but during the race the object is still mid-destruction (the destructor is blocked on the lock), so the memory has not yet been freed when Thread A dereferences the pointer. MiraclePtr only triggers after deallocation.

RefCountedThreadSafe with StartRefCountFromZeroTag does not enforce that AddRef is called on a live object (refcount > 0). In Release builds, AddRef from zero succeeds silently, allowing CreateMemoryStream to increment a dead object’s refcount.

Reproduce

Platform Requirements

This vulnerability is Linux-only. The entire FontLoader / MappedFontFile / FontServiceThread stack under components/services/font/ is compiled exclusively for Linux (guarded by BUILDFLAG(IS_LINUX)). On macOS, Windows, and Android, Chromium uses platform-native font APIs (CoreText, DirectWrite, Android Skia fontmgr respectively) that never instantiate FontLoader or touch mapped_font_files_. ChromeOS uses a different font service path. There is no way to reach the vulnerable code on any non-Linux platform.

Font Requirements

The PoC relies on FontFace with local() sources, which resolve to system-installed fonts by PostScript name through FontUniqueNameLookupLinux. The race requires loading more than 42 distinct fonts (the typeface_cache_ LRU capacity) so that cache eviction triggers MappedFontFile destruction on one thread while another thread races to access the same entry through openStream.

The PoC’s font list (65 fonts for loading, 45 for flushing) was verified on Ubuntu 22.04 with the following font packages installed:

sudo apt install -y fonts-dejavu fonts-freefont-ttf fonts-liberation fonts-lato \
  fonts-urw-base35 fonts-indic fonts-kacst fonts-tlwg-garuda fonts-khmeros \
  fonts-tlwg-kinnari fonts-tlwg-laksaman fonts-sil-abyssinica

The fonts-indic meta-package pulls in fonts-aakar, fonts-anjalioldlipi, fonts-chilanka, fonts-dyuthi, fonts-gayathri, fonts-gubbi, and other Indic script fonts used by the PoC. On a minimal Linux installation where fewer than 43 of the listed PostScript names resolve to installed fonts, the PoC will not trigger because the typeface cache will not evict entries. Install additional font packages or adjust the font list in the PoC to match available local fonts if needed.

Steps

Tested at commit e256102970bf347f2cc827935dbcb09ee18a3b60. No source modifications are required.

Build:

autoninja -C out/asan-release chrome

Run:

ASAN_OPTIONS=detect_odr_violation=0 out/asan-release/chrome \
  --no-sandbox --disable-gpu \
  --user-data-dir=/tmp/poc-$(date +%s) \
  issue_fontloader_openstream_uaf/poc.html

The renderer process crashes with a heap-use-after-free within the first few rounds (typically rounds 0-5 out of 30). Reproduction rate is 100% across repeated runs on the tested configuration.

=================================================================
==3754133==ERROR: AddressSanitizer: heap-use-after-free on address 0x7bcb975c4ea0 at pc 0x7f4c078f2586 bp 0x7b4998d5aa10 sp 0x7b4998d5aa08
WRITE of size 4 at 0x7bcb975c4ea0 thread T13 (DedicatedWorker)
    #0 0x7f4c078f2585 in font_service::internal::MappedFontFile::ReleaseProc(void const*, void*) gen/third_party/libc++/src/include/__atomic/support/c11.h:207:10
    #1 0x7f4c11f09f9b in SkTypeface_Fontations::~SkTypeface_Fontations() third_party/skia/include/core/SkRefCnt.h:181:13
    #2 0x7f4c11f09ffd in SkTypeface_Fontations::~SkTypeface_Fontations() third_party/skia/src/ports/SkTypeface_fontations_priv.h:188:7
    #3 0x7f4bb5bf0f0a in cppgc::internal::HeapVisitor<cppgc::internal::(anonymous namespace)::MutatorThreadSweeper>::Traverse(cppgc::internal::BasePage&) v8/src/heap/cppgc/sweeper.cc:277:13
    #4 0x7f4bb5befd11 in cppgc::internal::(anonymous namespace)::MutatorThreadSweeper::Sweep(cppgc::internal::(anonymous namespace)::SweepingState&) v8/src/heap/cppgc/sweeper.cc:653:36
    #5 0x7f4bb5bef6f3 in cppgc::internal::Sweeper::SweeperImpl::Finish() v8/src/heap/cppgc/sweeper.cc:1311:13
    #6 0x7f4bb5be352f in cppgc::internal::Sweeper::SweeperImpl::FinishIfRunning() v8/src/heap/cppgc/sweeper.cc:1236:7
    #7 0x7f4bb5ba475e in cppgc::internal::HeapBase::Terminate() v8/src/heap/cppgc/heap-base.cc:275:15
    #8 0x7f4bb3b13d42 in v8::internal::CppHeap::~CppHeap() v8/src/heap/cppgc-js/cpp-heap.cc:551:13
    #9 0x7f4bb3b13f37 in non-virtual thunk to v8::internal::CppHeap::~CppHeap() v8/src/heap/cppgc-js/cpp-heap.cc:539:21
    #10 0x7f4bb3c6eab5 in v8::internal::Heap::TearDown() v8/src/heap/heap.cc:6512:15
    #11 0x7f4bb397c8c5 in v8::internal::Isolate::Deinit() v8/src/execution/isolate.cc:4863:9
    #12 0x7f4bb397b8cf in v8::internal::Isolate::Deinitialize(v8::internal::Isolate*) v8/src/execution/isolate.cc:4427:12
    #13 0x7f4bb397b74d in v8::internal::Isolate::Delete(v8::internal::Isolate*) v8/src/execution/isolate.cc:4408:3
    #14 0x7f4bd2dcd912 in gin::IsolateHolder::~IsolateHolder() gin/isolate_holder.cc:152:13
    #15 0x7f4bba70f042 in blink::V8PerIsolateData::~V8PerIsolateData() third_party/blink/renderer/platform/bindings/v8_per_isolate_data.cc:133:37
    #16 0x7f4bba7101cf in blink::V8PerIsolateData::Destroy(v8::Isolate*) third_party/blink/renderer/platform/bindings/v8_per_isolate_data.cc:199:3
    #17 0x7f4bc5e62eed in blink::WorkerBackingThread::ShutdownOnBackingThread() third_party/blink/renderer/core/workers/worker_backing_thread.cc:168:3
    #18 0x7f4bc5e8f163 in blink::WorkerThread::PerformShutdownOnWorkerThread() third_party/blink/renderer/core/workers/worker_thread.cc:854:30
    #19 0x7f4bc5e9452c in base::internal::Invoker<...>::RunOnce(base::internal::BindStateBase*) base/functional/bind_internal.h:740:12
    #20 0x7f4c15d614f2 in base::TaskAnnotator::RunTaskImpl(base::PendingTask&) base/functional/callback.h:155:12
    #21 0x7f4c15de29de in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWorkImpl(base::LazyNow*) base/task/common/task_annotator.h:112:5
    #22 0x7f4c15de19b6 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::DoWork() base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:346:40
    #23 0x7f4c15c03591 in base::MessagePumpDefault::Run(base::MessagePump::Delegate*) base/message_loop/message_pump_default.cc:42:55
    #24 0x7f4c15de4058 in base::sequence_manager::internal::ThreadControllerWithMessagePumpImpl::Run(bool, base::TimeDelta) base/task/sequence_manager/thread_controller_with_message_pump_impl.cc:650:12
    #25 0x7f4c15ccbb52 in base::RunLoop::Run(base::Location const&) base/run_loop.cc:135:14
    #26 0x7f4bbb47000c in blink::scheduler::NonMainThreadImpl::SimpleThreadImpl::Run() third_party/blink/renderer/platform/scheduler/worker/non_main_thread_impl.cc:178:14
    #27 0x7f4c15ede6fc in base::(anonymous namespace)::ThreadFunc(void*) base/threading/platform_thread_posix.cc:102:13
    #28 0x557c12ce4936 in asan_thread_start(void*) asan_interceptors.cpp

0x7bcb975c4ea0 is located 0 bytes inside of 88-byte region [0x7bcb975c4ea0,0x7bcb975c4ef8)
freed by thread T19 (DedicatedWorker) here:
    #0 0x557c12d213f2 in operator delete(void*, unsigned long)
    #1 0x7f4c078f2510 in font_service::internal::MappedFontFile::ReleaseProc(void const*, void*) base/memory/ref_counted.h:438:5
    #2 0x7f4c11f09f9b in SkTypeface_Fontations::~SkTypeface_Fontations() third_party/skia/include/core/SkRefCnt.h:181:13
    #3 0x7f4c11f09ffd in SkTypeface_Fontations::~SkTypeface_Fontations() third_party/skia/src/ports/SkTypeface_fontations_priv.h:188:7
    #4 0x7f4bb5bf0f0a in cppgc::internal::HeapVisitor<cppgc::internal::(anonymous namespace)::MutatorThreadSweeper>::Traverse(cppgc::internal::BasePage&) v8/src/heap/cppgc/sweeper.cc:277:13
    #5 0x7f4bb5befd11 in cppgc::internal::(anonymous namespace)::MutatorThreadSweeper::Sweep(cppgc::internal::(anonymous namespace)::SweepingState&) v8/src/heap/cppgc/sweeper.cc:653:36
    #6 0x7f4bb5bef6f3 in cppgc::internal::Sweeper::SweeperImpl::Finish() v8/src/heap/cppgc/sweeper.cc:1311:13
    #7 0x7f4bb5be352f in cppgc::internal::Sweeper::SweeperImpl::FinishIfRunning() v8/src/heap/cppgc/sweeper.cc:1236:7
    #8 0x7f4bb5ba475e in cppgc::internal::HeapBase::Terminate() v8/src/heap/cppgc/heap-base.cc:275:15
    #9 0x7f4bb3b13d42 in v8::internal::CppHeap::~CppHeap() v8/src/heap/cppgc-js/cpp-heap.cc:551:13
    #10 0x7f4bb3b13f37 in non-virtual thunk to v8::internal::CppHeap::~CppHeap() v8/src/heap/cppgc-js/cpp-heap.cc:539:21
    #11 0x7f4bb3c6eab5 in v8::internal::Heap::TearDown() v8/src/heap/heap.cc:6512:15
    #12 0x7f4bb397c8c5 in v8::internal::Isolate::Deinit() v8/src/execution/isolate.cc:4863:9
    #13 0x7f4bb397b8cf in v8::internal::Isolate::Deinitialize(v8::internal::Isolate*) v8/src/execution/isolate.cc:4427:12
    #14 0x7f4bb397b74d in v8::internal::Isolate::Delete(v8::internal::Isolate*) v8/src/execution/isolate.cc:4408:3
    #15 0x7f4bd2dcd912 in gin::IsolateHolder::~IsolateHolder() gin/isolate_holder.cc:152:13
    #16 0x7f4bba70f042 in blink::V8PerIsolateData::~V8PerIsolateData() third_party/blink/renderer/platform/bindings/v8_per_isolate_data.cc:133:37
    #17 0x7f4bba7101cf in blink::V8PerIsolateData::Destroy(v8::Isolate*) third_party/blink/renderer/platform/bindings/v8_per_isolate_data.cc:199:3
    #18 0x7f4bc5e62eed in blink::WorkerBackingThread::ShutdownOnBackingThread() third_party/blink/renderer/core/workers/worker_backing_thread.cc:168:3
    #19 0x7f4bc5e8f163 in blink::WorkerThread::PerformShutdownOnWorkerThread() third_party/blink/renderer/core/workers/worker_thread.cc:854:30
    #20 0x7f4bc5e9452c in base::internal::Invoker<...>::RunOnce(base::internal::BindStateBase*) base/functional/bind_internal.h:740:12
    #21 0x7f4c15d614f2 in base::TaskAnnotator::RunTaskImpl(base::PendingTask&) base/functional/callback.h:155:12

previously allocated by thread T19 (DedicatedWorker) here:
    #0 0x557c12d207ed in operator new(unsigned long)
    #1 0x7f4c078e5cf6 in font_service::internal::FontServiceThread::OpenStream(SkFontConfigInterface::FontIdentity const&) components/services/font/public/cpp/font_service_thread.cc:178:7
    #2 0x7f4c078daab8 in font_service::FontLoader::openStream(SkFontConfigInterface::FontIdentity const&) components/services/font/public/cpp/font_loader.cc:78:16
    #3 0x7f4c078db6ac in font_service::FontLoader::makeTypeface(SkFontConfigInterface::FontIdentity const&, sk_sp<SkFontMgr>) components/services/font/public/cpp/font_loader.cc:112:44
    #4 0x7f4bba8fb055 in blink::SkTypeface_Factory::FromFontConfigInterfaceIdAndTtcIndex(int, int) third_party/blink/renderer/platform/fonts/skia/sktypeface_factory.cc:30:15
    #5 0x7f4bbb0d67b8 in blink::FontUniqueNameLookupLinux::MatchUniqueName(blink::String const&) third_party/blink/renderer/platform/fonts/linux/font_unique_name_lookup_linux.cc:34:10
    #6 0x7f4bba8f8448 in blink::FontCache::CreateFontPlatformData(...) third_party/blink/renderer/platform/fonts/skia/font_cache_skia.cc:323:16
    #7 0x7f4bba7efc92 in blink::FontPlatformDataCache::GetOrCreateFontPlatformData(...) third_party/blink/renderer/platform/fonts/font_platform_data_cache.cc:68:52
    #8 0x7f4bba79bfc9 in blink::FontCache::GetFontPlatformData(...) third_party/blink/renderer/platform/fonts/font_cache.cc:144:36
    #9 0x7f4bba79cd2d in blink::FontCache::IsPlatformFontUniqueNameMatchAvailable(...) third_party/blink/renderer/platform/fonts/font_cache.cc:191:10
    #10 0x7f4bc28730fb in blink::LocalFontFaceSource::IsValid() const third_party/blink/renderer/core/css/local_font_face_source.cc:46:42
    #11 0x7f4bc24c38bb in blink::CSSFontFace::Load(blink::FontDescription const&) third_party/blink/renderer/core/css/css_font_face.cc:257:17
    #12 0x7f4bc24c0fec in blink::CSSFontFace::Load() third_party/blink/renderer/core/css/css_font_face.cc:246:3
    #13 0x7f4bc27b1cbf in blink::FontFace::load(blink::ScriptState*) third_party/blink/renderer/core/css/font_face.cc:622:21

SUMMARY: AddressSanitizer: heap-use-after-free gen/third_party/libc++/src/include/__atomic/support/c11.h:207:10 in font_service::internal::MappedFontFile::ReleaseProc(void const*, void*)
Shadow bytes around the buggy address:
  0x7bcb975c4c00: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fd
  0x7bcb975c4c80: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fd
  0x7bcb975c4d00: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fd
  0x7bcb975c4d80: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fd
  0x7bcb975c4e00: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fa
=>0x7bcb975c4e80: fa fa f7 fa[fd]fd fd fd fd fd fd fd fd fd fd fa
  0x7bcb975c4f00: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fd
  0x7bcb975c4f80: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fa
  0x7bcb975c5000: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fa
  0x7bcb975c5080: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fa
  0x7bcb975c5100: fa fa f7 fa fd fd fd fd fd fd fd fd fd fd fd fa

MiraclePtr Status: NOT PROTECTED
No raw_ptr<T> access to this region was detected prior to this crash.
This crash is still exploitable with MiraclePtr.

Credit

Please use c6eed09fc8b174b0f3eebedcceb1e792 as the credit for this vulnerability. Thank you.

View on issue tracker