Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in Fonts
DescriptionRace condition in Fonts
ComponentFonts
Sub-componentBlink>Fonts
Bug ClassRace
Tracker492374387
Fix commit7bcc0ec5dd38 (chromium/src) +156/-3
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
cache_directory_
content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc
modified
for
content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc
modified
TestFontUniqueNameLookup
content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
modified
FontUniqueNameLookupCallbackTest
content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
modified
TEST_F
content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
modified
for
content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
modified
BindLambdaForTesting
content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
modified

Files Changed

  • content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc
  • content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
  • content/browser/font_unique_name_lookup/font_unique_name_lookup_android.h
From 7bcc0ec5dd38106192174dc2ad9f59d4217158b5 Mon Sep 17 00:00:00 2001
From: Dominik Röttsches <drott@chromium.org>
Date: Mon, 14 Sep 2026 02:50:38 -0700
Subject: [PATCH] Sequentialize access to callbacks in FontUniqueNameLookup

Serialize callback registrations, iterations, and drains onto a
dedicated base::SequencedTaskRunner (callback_access_runner_) to
eliminate race conditions around access to pending_callbacks_.

Add FontUniqueNameLookupCallbackTest.ConcurrentCallbackQueuing to verify
safe concurrent callback queuing and dispatch across ThreadPool tasks
while asynchronous table generation and drainage are in progress.

Bug: 492374387
Change-Id: Iee5e0d761bf82c38df5d0d0d14945f6ced3e912a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8379663
Commit-Queue: Dominik Röttsches <drott@chromium.org>
Reviewed-by: Francois Pierre Doray <fdoray@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1697142}
---
diff --git a/content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc b/content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc
index f356b3b..d87bb37 100644
--- a/content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc
+++ b/content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc
@@ -124,7 +124,13 @@
 
 FontUniqueNameLookup::FontUniqueNameLookup(
     const base::FilePath& cache_directory)
-    : cache_directory_(cache_directory) {
+    : callback_access_runner_(base::ThreadPool::CreateSequencedTaskRunner(
+          {base::TaskPriority::USER_BLOCKING,
+           base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN})),
+      cache_directory_(cache_directory) {
+  CHECK(callback_access_runner_);
+  DETACH_FROM_SEQUENCE(callback_sequence_checker_);
+
   if (!DirectoryExists(cache_directory_) ||
       !base::PathIsWritable(cache_directory_)) {
     DCHECK(false) << "Error accessing cache directory for writing: "
@@ -145,6 +151,32 @@
     scoped_refptr<base::SequencedTaskRunner> task_runner,
     blink::mojom::FontUniqueNameLookup::GetUniqueNameLookupTableCallback
         callback) {
+  if (proto_storage_ready_.IsSignaled()) {
+    task_runner->PostTask(FROM_HERE, base::BindOnce(std::move(callback),
+                                                    DuplicateMemoryRegion()));
+    return;
+  }
+
+  CHECK(callback_access_runner_);
+  callback_access_runner_->PostTask(
+      FROM_HERE,
+      base::BindOnce(&FontUniqueNameLookup::EnqueueCallbackOnSequence,
+                     base::Unretained(this), std::move(task_runner),
+                     std::move(callback)));
+}
+
+void FontUniqueNameLookup::EnqueueCallbackOnSequence(
+    scoped_refptr<base::SequencedTaskRunner> task_runner,
+    blink::mojom::FontUniqueNameLookup::GetUniqueNameLookupTableCallback
+        callback) {
+  DCHECK_CALLED_ON_VALID_SEQUENCE(callback_sequence_checker_);
+
+  if (proto_storage_ready_.IsSignaled()) {
+    task_runner->PostTask(FROM_HERE, base::BindOnce(std::move(callback),
+                                                    DuplicateMemoryRegion()));
+    return;
+  }
+
   pending_callbacks_.emplace_back(std::move(task_runner), std::move(callback));
 }
 
@@ -210,6 +242,7 @@
 }
 
 void FontUniqueNameLookup::ScheduleLoadOrUpdateTable() {
+  CHECK(callback_access_runner_);
   base::ThreadPool::PostTask(
       FROM_HERE,
       {base::MayBlock(), base::TaskPriority::BEST_EFFORT,
@@ -229,7 +262,11 @@
               instance->PersistToFile();
             }
             instance->proto_storage_ready_.Signal();
-            instance->PostCallbacks();
+            // Safe to use base::Unretained(instance) here because instance
+            // is a never-deleted singleton.
+            instance->callback_access_runner_->PostTask(
+                FROM_HERE, base::BindOnce(&FontUniqueNameLookup::PostCallbacks,
+                                          base::Unretained(instance)));
           },
           base::Unretained(this)));
 }
@@ -283,6 +320,9 @@
 FontUniqueNameLookup::CallbackOnTaskRunner::~CallbackOnTaskRunner() = default;
 
 void FontUniqueNameLookup::PostCallbacks() {
+  DCHECK_CALLED_ON_VALID_SEQUENCE(callback_sequence_checker_);
+  CHECK(proto_storage_ready_.IsSignaled());
+
   for (auto& pending_callback : pending_callbacks_) {
     pending_callback.task_runner->PostTask(
         FROM_HERE, base::BindOnce(std::move(pending_callback.mojo_callback),
diff --git a/content/browser/font_unique_name_lookup/font_unique_name_lookup_android.h b/content/browser/font_unique_name_lookup/font_unique_name_lookup_android.h
index 649659a..cf8ad31 100644
--- a/content/browser/font_unique_name_lookup/font_unique_name_lookup_android.h
+++ b/content/browser/font_unique_name_lookup/font_unique_name_lookup_android.h
@@ -10,8 +10,10 @@
 
 #include "base/files/file_path.h"
 #include "base/memory/read_only_shared_memory_region.h"
+#include "base/sequence_checker.h"
 #include "base/synchronization/waitable_event.h"
 #include "base/task/sequenced_task_runner.h"
+#include "base/thread_annotations.h"
 #include "content/common/content_export.h"
 #include "third_party/blink/public/mojom/font_unique_name_lookup/font_unique_name_lookup.mojom.h"
 
@@ -114,8 +116,15 @@
 
   base::FilePath TableCacheFilePath();
 
+  // Dispatches all queued callbacks; runs exclusively on
+  // callback_access_runner_.
   void PostCallbacks();
 
+  void EnqueueCallbackOnSequence(
+      scoped_refptr<base::SequencedTaskRunner> task_runner,
+      blink::mojom::FontUniqueNameLookup::GetUniqueNameLookupTableCallback
+          callback);
+
   // We have a asynchronous update tasks which need write access to the
   // proto_storage_ MappedReadOnlyRegion after reading the index file from disk,
   // or after scanning and indexing metadata from font files. At the same time,
@@ -126,6 +135,10 @@
   base::WaitableEvent proto_storage_ready_;
   base::MappedReadOnlyRegion proto_storage_;
 
+  // Sequence runner dedicated to serializing pending_callbacks_ access:
+  scoped_refptr<base::SequencedTaskRunner> callback_access_runner_;
+  SEQUENCE_CHECKER(callback_sequence_checker_);
+
   base::FilePath cache_directory_;
   std::string android_build_fingerprint_for_testing_;
   std::vector<base::FilePath> font_file_paths_for_testing_ =
@@ -142,7 +155,8 @@
         mojo_callback;
   };
 
-  std::vector<CallbackOnTaskRunner> pending_callbacks_;
+  std::vector<CallbackOnTaskRunner> pending_callbacks_
+      GUARDED_BY_CONTEXT(callback_sequence_checker_);
 };
 }  // namespace content
 
diff --git a/content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc b/content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
index 4726b7f0..a3c2141 100644
--- a/content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
+++ b/content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
@@ -8,12 +8,18 @@
 #include <memory>
 
 #include "base/android/android_info.h"
+#include "base/barrier_closure.h"
 #include "base/files/file.h"
 #include "base/files/file_enumerator.h"
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/functional/function_ref.h"
+#include "base/run_loop.h"
 #include "base/strings/string_util.h"
+#include "base/task/sequenced_task_runner.h"
+#include "base/task/thread_pool.h"
+#include "base/test/bind.h"
+#include "base/test/task_environment.h"
 #include "content/browser/font_unique_name_lookup/name_table_ffi.rs.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/common/font_unique_name_lookup/font_table_matcher.h"
@@ -79,6 +85,7 @@
         std::make_unique<FontUniqueNameLookup>(temp_dir_.GetPath());
   }
 
+  base::test::TaskEnvironment task_environment_;
   base::ScopedTempDir temp_dir_;
   std::unique_ptr<FontUniqueNameLookup> font_unique_name_lookup_;
 };
@@ -311,6 +318,7 @@
         font_file_corruptor_.GetFontFilesList());
   }
 
+  base::test::TaskEnvironment task_environment_;
   base::ScopedTempDir temp_dir_;
   FontFileCorruptor font_file_corruptor_;
   std::unique_ptr<FontUniqueNameLookup> font_unique_name_lookup_;
@@ -343,6 +351,7 @@
     font_unique_name_lookup_->SetAndroidBuildFingerprintForTesting("A");
   }
 
+  base::test::TaskEnvironment task_environment_;
   base::ScopedTempDir lookup_table_storage_dir;
   std::unique_ptr<FontUniqueNameLookup> font_unique_name_lookup_;
 };
@@ -364,4 +373,94 @@
   ASSERT_TRUE(matcher_initial.FontListIsDisjointFrom(matcher_second_half));
 }
 
+class TestFontUniqueNameLookup : public FontUniqueNameLookup {
+ public:
+  using FontUniqueNameLookup::FontUniqueNameLookup;
+  using FontUniqueNameLookup::ScheduleLoadOrUpdateTable;
+};
+
+class FontUniqueNameLookupCallbackTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
+    lookup_ = std::make_unique<TestFontUniqueNameLookup>(temp_dir_.GetPath());
+    std::vector<base::FilePath> font_files = AndroidFontFilesList();
+    ASSERT_FALSE(font_files.empty());
+    // Restrict to a single font to minimize test execution time and CPU load.
+    lookup_->SetFontFilePathsForTesting({font_files.front()});
+  }
+
+  base::test::TaskEnvironment task_environment_;
+  base::ScopedTempDir temp_dir_;
+  std::unique_ptr<TestFontUniqueNameLookup> lookup_;
+};
+
+TEST_F(FontUniqueNameLookupCallbackTest, ConcurrentCallbackQueuing) {
+  constexpr size_t kPreQueueCount = 10;
+  constexpr size_t kConcurrentThreads = 4;
+  constexpr size_t kPerThreadQueueCount = 5;
+  constexpr size_t kTotalCallbacks =
+      kPreQueueCount + (kConcurrentThreads * kPerThreadQueueCount);
+
+  base::RunLoop run_loop;
+  base::RepeatingClosure barrier =
+      base::BarrierClosure(kTotalCallbacks, run_loop.QuitClosure());
+
+  auto make_callback = [&]() {
+    return base::BindLambdaForTesting(
+        [&, barrier](base::ReadOnlySharedMemoryRegion region) {
+          EXPECT_TRUE(region.IsValid());
+          base::ReadOnlySharedMemoryMapping mapping = region.Map();
+          EXPECT_TRUE(mapping.IsValid());
+          blink::FontTableMatcher matcher(mapping);
+          EXPECT_GT(matcher.AvailableFonts(), 0u);
+          barrier.Run();
+        });
+  };
+
+  // Phase A: Pre-queue callbacks before table build starts.
+  // proto_storage_ready_ is guaranteed to be unsignaled, ensuring all callbacks
+  // are routed to callback_access_runner_ and enqueued in pending_callbacks_.
+  for (size_t i = 0; i < kPreQueueCount; ++i) {
+    scoped_refptr<base::SequencedTaskRunner> caller_runner =
+        base::ThreadPool::CreateSequencedTaskRunner({});
+    lookup_->QueueShareMemoryRegionWhenReady(caller_runner, make_callback());
+  }
+
+  // Start asynchronous table build on ThreadPool.
+  lookup_->ScheduleLoadOrUpdateTable();
+
+  // Phase B: Concurrently queue callbacks from multiple threads while table
+  // build and drain are in-flight.
+  for (size_t i = 0; i < kConcurrentThreads; ++i) {
+    base::ThreadPool::PostTask(
+        FROM_HERE, base::BindLambdaForTesting([&]() {
+          for (size_t j = 0; j < kPerThreadQueueCount; ++j) {
+            scoped_refptr<base::SequencedTaskRunner> caller_runner =
+                base::ThreadPool::CreateSequencedTaskRunner({});
+            lookup_->QueueShareMemoryRegionWhenReady(caller_runner,
+                                                     make_callback());
+          }
+        }));
+  }
+
+  // Wait for all pre-queued and concurrently queued callbacks to complete.
+  run_loop.Run();
+
+  // Phase C: Late / fast-path queueing verification.
+  // Callbacks queued after the table is built should be dispatched directly.
+  base::RunLoop late_run_loop;
+  lookup_->QueueShareMemoryRegionWhenReady(
+      base::SequencedTaskRunner::GetCurrentDefault(),
+      base::BindLambdaForTesting([&](base::ReadOnlySharedMemoryRegion region) {
+        EXPECT_TRUE(region.IsValid());
+        base::ReadOnlySharedMemoryMapping mapping = region.Map();
+        EXPECT_TRUE(mapping.IsValid());
+        blink::FontTableMatcher matcher(mapping);
+        EXPECT_GT(matcher.AvailableFonts(), 0u);
+        late_run_loop.Quit();
+      }));
+  late_run_loop.Run();
+}
+
 }  // namespace content
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc b/content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
index 4726b7f0..a3c2141 100644
--- a/content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
+++ b/content/browser/font_unique_name_lookup/font_unique_name_lookup_android_unittest.cc
@@ -8,12 +8,18 @@
 #include <memory>
 
 #include "base/android/android_info.h"
+#include "base/barrier_closure.h"
 #include "base/files/file.h"
 #include "base/files/file_enumerator.h"
 #include "base/files/file_util.h"
 #include "base/files/scoped_temp_dir.h"
 #include "base/functional/function_ref.h"
+#include "base/run_loop.h"
 #include "base/strings/string_util.h"
+#include "base/task/sequenced_task_runner.h"
+#include "base/task/thread_pool.h"
+#include "base/test/bind.h"
+#include "base/test/task_environment.h"
 #include "content/browser/font_unique_name_lookup/name_table_ffi.rs.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/blink/public/common/font_unique_name_lookup/font_table_matcher.h"
@@ -79,6 +85,7 @@
         std::make_unique<FontUniqueNameLookup>(temp_dir_.GetPath());
   }
 
+  base::test::TaskEnvironment task_environment_;
   base::ScopedTempDir temp_dir_;
   std::unique_ptr<FontUniqueNameLookup> font_unique_name_lookup_;
 };
@@ -311,6 +318,7 @@
         font_file_corruptor_.GetFontFilesList());
   }
 
+  base::test::TaskEnvironment task_environment_;
   base::ScopedTempDir temp_dir_;
   FontFileCorruptor font_file_corruptor_;
   std::unique_ptr<FontUniqueNameLookup> font_unique_name_lookup_;
@@ -343,6 +351,7 @@
     font_unique_name_lookup_->SetAndroidBuildFingerprintForTesting("A");
   }
 
+  base::test::TaskEnvironment task_environment_;
   base::ScopedTempDir lookup_table_storage_dir;
   std::unique_ptr<FontUniqueNameLookup> font_unique_name_lookup_;
 };
@@ -364,4 +373,94 @@
   ASSERT_TRUE(matcher_initial.FontListIsDisjointFrom(matcher_second_half));
 }
 
+class TestFontUniqueNameLookup : public FontUniqueNameLookup {
+ public:
+  using FontUniqueNameLookup::FontUniqueNameLookup;
+  using FontUniqueNameLookup::ScheduleLoadOrUpdateTable;
+};
+
+class FontUniqueNameLookupCallbackTest : public ::testing::Test {
+ protected:
+  void SetUp() override {
+    ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
+    lookup_ = std::make_unique<TestFontUniqueNameLookup>(temp_dir_.GetPath());
+    std::vector<base::FilePath> font_files = AndroidFontFilesList();
+    ASSERT_FALSE(font_files.empty());
+    // Restrict to a single font to minimize test execution time and CPU load.
+    lookup_->SetFontFilePathsForTesting({font_files.front()});
+  }
+
+  base::test::TaskEnvironment task_environment_;
+  base::ScopedTempDir temp_dir_;
+  std::unique_ptr<TestFontUniqueNameLookup> lookup_;
+};
+
+TEST_F(FontUniqueNameLookupCallbackTest, ConcurrentCallbackQueuing) {
+  constexpr size_t kPreQueueCount = 10;
+  constexpr size_t kConcurrentThreads = 4;
+  constexpr size_t kPerThreadQueueCount = 5;
+  constexpr size_t kTotalCallbacks =
+      kPreQueueCount + (kConcurrentThreads * kPerThreadQueueCount);
+
+  base::RunLoop run_loop;
+  base::RepeatingClosure barrier =
+      base::BarrierClosure(kTotalCallbacks, run_loop.QuitClosure());
+
+  auto make_callback = [&]() {
+    return base::BindLambdaForTesting(
+        [&, barrier](base::ReadOnlySharedMemoryRegion region) {
+          EXPECT_TRUE(region.IsValid());
+          base::ReadOnlySharedMemoryMapping mapping = region.Map();
+          EXPECT_TRUE(mapping.IsValid());
+          blink::FontTableMatcher matcher(mapping);
+          EXPECT_GT(matcher.AvailableFonts(), 0u);
+          barrier.Run();
+        });
+  };
+
+  // Phase A: Pre-queue callbacks before table build starts.
+  // proto_storage_ready_ is guaranteed to be unsignaled, ensuring all callbacks
+  // are routed to callback_access_runner_ and enqueued in pending_callbacks_.
+  for (size_t i = 0; i < kPreQueueCount; ++i) {
+    scoped_refptr<base::SequencedTaskRunner> caller_runner =
+        base::ThreadPool::CreateSequencedTaskRunner({});
+    lookup_->QueueShareMemoryRegionWhenReady(caller_runner, make_callback());
+  }
+
+  // Start asynchronous table build on ThreadPool.
+  lookup_->ScheduleLoadOrUpdateTable();
+
+  // Phase B: Concurrently queue callbacks from multiple threads while table
+  // build and drain are in-flight.
+  for (size_t i = 0; i < kConcurrentThreads; ++i) {
+    base::ThreadPool::PostTask(
+        FROM_HERE, base::BindLambdaForTesting([&]() {
+          for (size_t j = 0; j < kPerThreadQueueCount; ++j) {
+            scoped_refptr<base::SequencedTaskRunner> caller_runner =
+                base::ThreadPool::CreateSequencedTaskRunner({});
+            lookup_->QueueShareMemoryRegionWhenReady(caller_runner,
+                                                     make_callback());
+          }
+        }));
+  }
+
+  // Wait for all pre-queued and concurrently queued callbacks to complete.
+  run_loop.Run();
+
+  // Phase C: Late / fast-path queueing verification.
+  // Callbacks queued after the table is built should be dispatched directly.
+  base::RunLoop late_run_loop;
+  lookup_->QueueShareMemoryRegionWhenReady(
+      base::SequencedTaskRunner::GetCurrentDefault(),
+      base::BindLambdaForTesting([&](base::ReadOnlySharedMemoryRegion region) {
+        EXPECT_TRUE(region.IsValid());
+        base::ReadOnlySharedMemoryMapping mapping = region.Map();
+        EXPECT_TRUE(mapping.IsValid());
+        blink::FontTableMatcher matcher(mapping);
+        EXPECT_GT(matcher.AvailableFonts(), 0u);
+        late_run_loop.Quit();
+      }));
+  late_run_loop.Run();
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

reported by je...@gmail.com

Cross-thread race on `FontUniqueNameLookup::pending_callbacks_` leads to browser process use-after-free

Cross-thread race on FontUniqueNameLookup::pending_callbacks_ leads to browser process use-after-free

Summary

The browser-process singleton content::FontUniqueNameLookup on Android accesses its pending_callbacks_ vector from two different threads without synchronization. A Mojo service sequence calls QueueShareMemoryRegionWhenReady() which performs emplace_back(), while a ThreadPool worker calls PostCallbacks() which iterates and clears the same vector. When emplace_back() triggers a vector reallocation during an ongoing PostCallbacks() iteration, the old backing buffer is freed while the iterator still references it, producing a use-after-free in the browser process. This affects Android only.

Bisect

Introducing Commit: a2dece8510578768eda1202047001188639802ba

Root Cause

FontUniqueNameLookup is a NoDestructor singleton instantiated early during browser startup via PostCreateThreadsImpl. Its constructor calls ScheduleLoadOrUpdateTable(), which posts a BEST_EFFORT task to a ThreadPool worker to scan system fonts, build a lookup table, and then dispatch pending callbacks:

// content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc:212-235
void FontUniqueNameLookup::ScheduleLoadOrUpdateTable() {
  base::ThreadPool::PostTask(
      FROM_HERE,
      {base::MayBlock(), base::TaskPriority::BEST_EFFORT,
       base::TaskShutdownBehavior::CONTINUE_ON_SHUTDOWN},
      base::BindOnce(
          [](FontUniqueNameLookup* instance) {
            instance->LoadFromFile();
            if (instance->UpdateTableIfNeeded()) {
              instance->PersistToFile();
            }
            instance->proto_storage_ready_.Signal();
            instance->PostCallbacks();
          },
          base::Unretained(this)));
}

PostCallbacks() runs on that ThreadPool worker thread, iterating pending_callbacks_ via range-for and clearing it afterward:

// content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc:285-292
void FontUniqueNameLookup::PostCallbacks() {
  for (auto& pending_callback : pending_callbacks_) {
    pending_callback.task_runner->PostTask(
        FROM_HERE, base::BindOnce(std::move(pending_callback.mojo_callback),
                                  DuplicateMemoryRegion()));
  }
  pending_callbacks_.clear();
}

Concurrently, renderer processes request the font lookup table through a Mojo interface. The service handler runs on its own SequencedTaskRunner, separate from both the UI thread and the ThreadPool worker above. When the table is not yet ready, it queues a callback:

// content/browser/font_unique_name_lookup/font_unique_name_lookup_service.cc:44-53
void FontUniqueNameLookupService::GetUniqueNameLookupTable(
    GetUniqueNameLookupTableCallback callback) {
  DCHECK(GetTaskRunner()->RunsTasksInCurrentSequence());
  if (font_unique_name_lookup_->IsValid()) {
    std::move(callback).Run(font_unique_name_lookup_->DuplicateMemoryRegion());
  } else {
    font_unique_name_lookup_->QueueShareMemoryRegionWhenReady(
        GetTaskRunner(), std::move(callback));
  }
}

QueueShareMemoryRegionWhenReady() simply appends to the vector:

// content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc:145-149
void FontUniqueNameLookup::QueueShareMemoryRegionWhenReady(
    scoped_refptr<base::SequencedTaskRunner> task_runner,
    blink::mojom::FontUniqueNameLookup::GetUniqueNameLookupTableCallback
        callback) {
  pending_callbacks_.emplace_back(std::move(task_runner), std::move(callback));
}

The class declaration reveals no synchronization protecting pending_callbacks_:

// content/browser/font_unique_name_lookup/font_unique_name_lookup_android.h:134-145
  struct CallbackOnTaskRunner {
    CallbackOnTaskRunner(
        scoped_refptr<base::SequencedTaskRunner>,
        blink::mojom::FontUniqueNameLookup::GetUniqueNameLookupTableCallback);
    CallbackOnTaskRunner(CallbackOnTaskRunner&&);
    ~CallbackOnTaskRunner();
    scoped_refptr<base::SequencedTaskRunner> task_runner;
    blink::mojom::FontUniqueNameLookup::GetUniqueNameLookupTableCallback
        mojo_callback;
  };

  std::vector<CallbackOnTaskRunner> pending_callbacks_;

There is no mutex, no SEQUENCE_CHECKER, and no GUARDED_BY annotation. The WaitableEvent proto_storage_ready_ only synchronizes access to proto_storage_, not to pending_callbacks_.

The race unfolds as a TOCTOU: a service sequence call checks IsValid() and observes false (the table is not ready). Before it executes emplace_back(), the ThreadPool worker finishes building the table, calls Signal(), and enters PostCallbacks(). The service call then executes emplace_back() while PostCallbacks() is iterating. If the emplace_back() exceeds the vector’s capacity, std::vector reallocates its backing buffer and frees the old one. The range-for iterator in PostCallbacks() still points into the freed buffer; the subsequent dereference of pending_callback.task_runner is a use-after-free.

This bug is not mitigated by MiraclePtr. The dangling access occurs through a std::vector range-for iterator/reference, not through a raw_ptr<T> field. MiraclePtr only protects raw_ptr<T> smart pointer fields allocated through PartitionAlloc. STL container iterators and element references are entirely outside its scope.

From a renderer, the attack surface is the FontFace API. new FontFace('name', 'local(...)').load() triggers LocalFontFaceSource::BeginLoadIfNeeded(), which calls through to FontUniqueNameLookupAndroid::PrepareFontUniqueNameLookup(), sending a GetUniqueNameLookupTable Mojo IPC to the browser. With kPrefetchFontLookupTables enabled by default on Android, each new renderer process also sends this IPC automatically during initialization.

Reproduce

Tested at commit 457566e1c0b4197b30d7532c4d7430b2ef738765 on Android (Pixel 7 Pro, HWASAN userdebug build).

The attached patch.diff inserts Sleep() calls to make the race deterministically reproducible under HWASAN. The patch makes four changes:

  1. In the renderer, it removes the IPC deduplication guard in PrepareFontUniqueNameLookup() so that each FontFace.load() call sends a separate GetUniqueNameLookupTable IPC to the browser, simulating a compromised renderer flooding the service.
  2. In the browser service handler (GetUniqueNameLookupTable), it inserts a 2-second sleep between the IsValid() check and the emplace_back() call, widening the TOCTOU window so that Signal() and PostCallbacks() can begin on the ThreadPool worker while the service sequence is still about to append.
  3. In PostCallbacks(), it inserts a 5-second sleep per element iteration, keeping the range-for loop alive long enough for a concurrent emplace_back() to land and trigger vector reallocation mid-iteration.
  4. In ScheduleLoadOrUpdateTable(), it inserts a 6-second initial delay before the font table build begins, ensuring renderer IPCs arrive and queue callbacks before the table becomes valid.

Build configuration (out/android/args.gn):

target_os = "android"
target_cpu = "arm64"
is_debug = false
dcheck_always_on = false
is_component_build = false
is_hwasan = true
android_static_analysis = "off"

Apply the patch and build:

cd ~/chromium/src
git apply patch.diff
autoninja -C out/android chrome_public_apk
out/android/bin/chrome_public_apk install

The --no-first-run --disable-fre flags skip the Android First Run Experience dialog, which would otherwise block page loading and prevent the PoC from executing during the startup window:

adb shell am force-stop org.chromium.chrome
adb logcat -c

python3 -m http.server 8880 --bind 0.0.0.0 &
adb reverse tcp:8880 tcp:8880

out/android/bin/chrome_public_apk launch \
  --args='--no-first-run --disable-fre --enable-logging=stderr' \
  http://localhost:8880/poc.html

The browser process crashes with an HWASAN tag-mismatch within approximately 16 seconds.

Symbolized stack:

#0 scoped_refptr<base::SequencedTaskRunner>::operator->() const
     base/memory/scoped_refptr.h:293
   content::FontUniqueNameLookup::PostCallbacks()
     content/browser/font_unique_name_lookup/font_unique_name_lookup_android.cc:292
#1 base::OnceCallback<void ()>::Run()
     base/functional/callback.h:155
   base::TaskAnnotator::RunTaskImpl()
     base/task/common/task_annotator.cc:229
#2 base::internal::TaskTracker::RunTaskImpl()
     base/task/thread_pool/task_tracker.cc:691
...
#9 base::(anonymous namespace)::ThreadFunc(void*)
     base/threading/platform_thread_posix.cc:102

Full HWASAN report:

==5416==ERROR: HWAddressSanitizer: tag-mismatch on address 0x0043d831ade0 at pc 0x00666bc49608
READ of size 8 at 0x0043d831ade0 tags: 9f/26 (ptr/mem) in thread T113
    #0 0x00666bc49608  (libchrome.so+0x7d6f608)
    #1 0x00666ffb2770  (libchrome.so+0xc0d8770)
    #2 0x0066700116cc  (libchrome.so+0xc1376cc)
    #3 0x006670011504  (libchrome.so+0xc137504)
    #4 0x0066700109cc  (libchrome.so+0xc1369cc)
    #5 0x006670010018  (libchrome.so+0xc136018)
    #6 0x006670022148  (libchrome.so+0xc148148)
    #7 0x00667002181c  (libchrome.so+0xc14781c)
    #8 0x0066700214d8  (libchrome.so+0xc1474d8)
    #9 0x0066700636e0  (libchrome.so+0xc1896e0)
    #10 0x0070c0cfdf2c  (libc.so+0x96f2c)
    #11 0x0070c0ce73d0  (libc.so+0x803d0)

[0x0043d831ade0,0x0043d831ae00) is a small allocated heap chunk; size: 32 offset: 0

Memory tags around the buggy address (one tag corresponds to 16 bytes):
  0x0043d831ac00: 0b  fc  04  00  ed  08  14  00  47  47  08  00  62  06  5a  5a
=>0x0043d831ad00: ea  ea  08  00  64  01  0b  d8  7b  06  df  cf  f6  08 [26] 26
  0x0043d831ae00: 80  00  c3  c3  5d  5d  93  00  65  00  8b  8b  3b  3b  1d  00

SUMMARY: HWAddressSanitizer: tag-mismatch (libchrome.so+0x7d6f608)

Credit

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

References

View on issue tracker