Overview

Critical
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Base
DescriptionUse after free in Base
ComponentBase
Bug ClassUAF
Tracker506477192
Fix commit4ee829d343f4 (chromium/src) +88/-27
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-27

Changed Functions

FunctionChangeNotes
NumberFormatWrapper
base/i18n/number_formatting.cc
modified
if
base/i18n/number_formatting.cc
modified
NumberFormatWorkerThread
base/i18n/number_formatting_unittest.cc
modified
iterations_
base/i18n/number_formatting_unittest.cc
modified
for
base/i18n/number_formatting_unittest.cc
modified
TEST
base/i18n/number_formatting_unittest.cc
modified

Files Changed

  • base/i18n/number_formatting.cc
  • base/i18n/number_formatting_unittest.cc
From 4ee829d343f4c144535d766f23e2be0f3250613b Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <[email protected]>
Date: Mon, 27 Apr 2026 16:29:19 -0700
Subject: [PATCH] Fix data race and interleaved mutation in base::FormatDouble

base::FormatDouble used a process-global ICU NumberFormat instance
without synchronization. This pattern was introduced in 2011
(https://codereview.chromium.org/7189076) and is fundamentally broken
for two reasons:

1. Interleaved Mutations: Because the global formatter was shared,
   concurrent calls from different threads could interleave their
   calls to setMaximumFractionDigits/setMinimumFractionDigits. This
   meant one thread's configuration could be overwritten by another
   before the formatting call occurred, leading to incorrect results.

2. Data Race/UAF: ICU formatters are not thread-safe for mutation.
   Concurrent mutations could trigger internal reallocations in ICU,
   leading to a heap-use-after-free in the browser process.

This CL replaces the global instances with base::ThreadLocalOwnedPointer
to ensure each thread has its own dedicated formatter.

Fixed: 506477192
Change-Id: Ib1649cb6fe320f2b97fd61e19134ba1e53974287
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7796498
Reviewed-by: Daniel Cheng <[email protected]>
Commit-Queue: Daniel Cheng <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1621408}
---

diff --git a/base/i18n/number_formatting.cc b/base/i18n/number_formatting.cc
index cd8bb78d..036768e 100644
--- a/base/i18n/number_formatting.cc
+++ b/base/i18n/number_formatting.cc
@@ -12,10 +12,12 @@
 #include "base/format_macros.h"
 #include "base/i18n/message_formatter.h"
 #include "base/i18n/unicodestring.h"
-#include "base/lazy_instance.h"
+#include "base/memory/ptr_util.h"
+#include "base/no_destructor.h"
 #include "base/strings/string_util.h"
 #include "base/strings/stringprintf.h"
 #include "base/strings/utf_string_conversions.h"
+#include "base/threading/thread_local.h"
 #include "third_party/icu/source/common/unicode/ustring.h"
 #include "third_party/icu/source/i18n/unicode/numfmt.h"
 
@@ -23,34 +25,45 @@
 
 namespace {
 
-// A simple wrapper around icu::NumberFormat that allows for resetting it
-// (as LazyInstance does not).
-struct NumberFormatWrapper {
-  NumberFormatWrapper() { Reset(); }
+// ICU formatters are not thread-safe for mutation. Using thread-local storage
+// ensures that each thread has its own instance, preventing data races and
+// potential Use-After-Free when multiple threads concurrently call
+// FormatDouble with different digit requirements. See crbug.com/506477192.
+base::ThreadLocalOwnedPointer<icu::NumberFormat>& GetIntFormatterStorage() {
+  static base::NoDestructor<base::ThreadLocalOwnedPointer<icu::NumberFormat>>
+      instance;
+  return *instance;
+}
 
-  void Reset() {
-    // There's no ICU call to destroy a NumberFormat object other than
-    // operator delete, so use the default Delete, which calls operator delete.
-    // This can cause problems if a different allocator is used by this file
-    // than by ICU.
-    UErrorCode status = U_ZERO_ERROR;
-    number_format.reset(icu::NumberFormat::createInstance(status));
-    DCHECK(U_SUCCESS(status));
+base::ThreadLocalOwnedPointer<icu::NumberFormat>& GetFloatFormatterStorage() {
+  static base::NoDestructor<base::ThreadLocalOwnedPointer<icu::NumberFormat>>
+      instance;
+  return *instance;
+}
+
+icu::NumberFormat* GetFormatter(
+    base::ThreadLocalOwnedPointer<icu::NumberFormat>& storage) {
+  icu::NumberFormat* formatter = storage.Get();
+  if (formatter) {
+    return formatter;
   }
-
-  std::unique_ptr<icu::NumberFormat> number_format;
-};
-
-LazyInstance<NumberFormatWrapper>::DestructorAtExit g_number_format_int =
-    LAZY_INSTANCE_INITIALIZER;
-LazyInstance<NumberFormatWrapper>::DestructorAtExit g_number_format_float =
-    LAZY_INSTANCE_INITIALIZER;
+  // There's no ICU call to destroy a NumberFormat object other than
+  // operator delete, so use the default Delete, which calls operator delete.
+  // This can cause problems if a different allocator is used by this file
+  // than by ICU.
+  UErrorCode status = U_ZERO_ERROR;
+  std::unique_ptr<icu::NumberFormat> instance(
+      icu::NumberFormat::createInstance(status));
+  DCHECK(U_SUCCESS(status));
+  formatter = instance.get();
+  storage.Set(std::move(instance));
+  return formatter;
+}
 
 }  // namespace
 
 std::u16string FormatNumber(int64_t number) {
-  icu::NumberFormat* number_format =
-      g_number_format_int.Get().number_format.get();
+  icu::NumberFormat* number_format = GetFormatter(GetIntFormatterStorage());
 
   if (!number_format) {
     // As a fallback, just return the raw number in a string.
@@ -69,8 +82,7 @@
 std::u16string FormatDouble(double number,
                             int min_fractional_digits,
                             int max_fractional_digits) {
-  icu::NumberFormat* number_format =
-      g_number_format_float.Get().number_format.get();
+  icu::NumberFormat* number_format = GetFormatter(GetFloatFormatterStorage());
 
   if (!number_format) {
     // As a fallback, just return the raw number in a string.
@@ -90,8 +102,8 @@
 }
 
 void ResetFormattersForTesting() {
-  g_number_format_int.Get().Reset();
-  g_number_format_float.Get().Reset();
+  GetIntFormatterStorage().Set(nullptr);
+  GetFloatFormatterStorage().Set(nullptr);
 }
 
 }  // namespace base
diff --git a/base/i18n/number_formatting_unittest.cc b/base/i18n/number_formatting_unittest.cc
index 78afd6a2..61fc0dd 100644
--- a/base/i18n/number_formatting_unittest.cc
+++ b/base/i18n/number_formatting_unittest.cc
@@ -8,10 +8,14 @@
 #include <stdint.h>
 
 #include <limits>
+#include <vector>
 
 #include "base/i18n/rtl.h"
+#include "base/memory/raw_ptr.h"
 #include "base/strings/utf_string_conversions.h"
+#include "base/synchronization/waitable_event.h"
 #include "base/test/icu_test_util.h"
+#include "base/threading/simple_thread.h"
 #include "build/build_config.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/icu/source/i18n/unicode/usearch.h"
@@ -19,6 +23,29 @@
 namespace base {
 namespace {
 
+class NumberFormatWorkerThread : public base::SimpleThread {
+ public:
+  NumberFormatWorkerThread(base::WaitableEvent* event,
+                           int digits,
+                           int iterations)
+      : SimpleThread("NumberFormatWorkerThread"),
+        event_(event),
+        digits_(digits),
+        iterations_(iterations) {}
+
+  void Run() override {
+    event_->Wait();
+    for (int i = 0; i < iterations_; ++i) {
+      FormatDouble(1.2345678, digits_);
+    }
+  }
+
+ private:
+  const raw_ptr<base::WaitableEvent> event_;
+  const int digits_;
+  const int iterations_;
+};
+
 TEST(NumberFormattingTest, FormatNumber) {
   static const struct {
     int64_t number;
@@ -180,5 +207,27 @@
   }
 }
 
+// Regression test for crbug.com/506477192. Ensure that concurrent calls to
+// FormatDouble with different fractional digits don't cause a data race in
+// the shared ICU number formatter.
+TEST(NumberFormattingTest, FormatDoubleRace) {
+  base::WaitableEvent event(base::WaitableEvent::ResetPolicy::MANUAL,
+                            base::WaitableEvent::InitialState::NOT_SIGNALED);
+  std::vector<std::unique_ptr<NumberFormatWorkerThread>> threads;
+
+  for (int i = 0; i < 20; ++i) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/base/i18n/number_formatting_unittest.cc b/base/i18n/number_formatting_unittest.cc
index 78afd6a2..61fc0dd 100644
--- a/base/i18n/number_formatting_unittest.cc
+++ b/base/i18n/number_formatting_unittest.cc
@@ -8,10 +8,14 @@
 #include <stdint.h>
 
 #include <limits>
+#include <vector>
 
 #include "base/i18n/rtl.h"
+#include "base/memory/raw_ptr.h"
 #include "base/strings/utf_string_conversions.h"
+#include "base/synchronization/waitable_event.h"
 #include "base/test/icu_test_util.h"
+#include "base/threading/simple_thread.h"
 #include "build/build_config.h"
 #include "testing/gtest/include/gtest/gtest.h"
 #include "third_party/icu/source/i18n/unicode/usearch.h"
@@ -19,6 +23,29 @@
 namespace base {
 namespace {
 
+class NumberFormatWorkerThread : public base::SimpleThread {
+ public:
+  NumberFormatWorkerThread(base::WaitableEvent* event,
+                           int digits,
+                           int iterations)
+      : SimpleThread("NumberFormatWorkerThread"),
+        event_(event),
+        digits_(digits),
+        iterations_(iterations) {}
+
+  void Run() override {
+    event_->Wait();
+    for (int i = 0; i < iterations_; ++i) {
+      FormatDouble(1.2345678, digits_);
+    }
+  }
+
+ private:
+  const raw_ptr<base::WaitableEvent> event_;
+  const int digits_;
+  const int iterations_;
+};
+
 TEST(NumberFormattingTest, FormatNumber) {
   static const struct {
     int64_t number;
@@ -180,5 +207,27 @@
   }
 }
 
+// Regression test for crbug.com/506477192. Ensure that concurrent calls to
+// FormatDouble with different fractional digits don't cause a data race in
+// the shared ICU number formatter.
+TEST(NumberFormattingTest, FormatDoubleRace) {
+  base::WaitableEvent event(base::WaitableEvent::ResetPolicy::MANUAL,
+                            base::WaitableEvent::InitialState::NOT_SIGNALED);
+  std::vector<std::unique_ptr<NumberFormatWorkerThread>> threads;
+
+  for (int i = 0; i < 20; ++i) {
+    threads
+        .emplace_back(
+            std::make_unique<NumberFormatWorkerThread>(&event, i % 5, 1000))
+        ->Start();
+  }
+
+  event.Signal();
+
+  for (auto& t : threads) {
+    t->Join();
+  }
+}
+
 }  // namespace
 }  // namespace base
Loading diff…

Original Bug Report

reported by [email protected]

Potential UAF in browser process via unsynchronized `base::FormatDouble`

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: base::FormatDouble mutates a process-global ICU NumberFormat singleton without synchronization. Concurrent calls from different threads, such as during background printer capability fetching and UI updates, can cause ICU to free internal formatting objects while they are in use, leading to a potential Use-After-Free in the unsandboxed browser process.

Affected files:

  • base/i18n/number_formatting.cc
  • chrome/common/printing/print_media_l10n.cc
  • chrome/browser/ui/webui/print_preview/local_printer_handler_default.cc

Estimated timestamp from git blame: 2023-05-15

Summary

A potential Use-After-Free (UAF) vulnerability exists in the browser process due to a data race in base::FormatDouble. The function uses a process-global ICU NumberFormat instance wrapped in a LazyInstance, which it mutates without synchronization. Concurrent calls from the UI thread and background worker threads can lead to ICU freeing internal formatting objects while they are still being accessed, resulting in memory corruption.

Root Cause Analysis

In base/i18n/number_formatting.cc:69, base::FormatDouble accesses a global icu::NumberFormat instance:

LazyInstance<NumberFormatWrapper>::DestructorAtExit g_number_format_float =
    LAZY_INSTANCE_INITIALIZER;

std::u16string FormatDouble(double number, int min_fractional_digits, int max_fractional_digits) {
  icu::NumberFormat* number_format = g_number_format_float.Get().number_format.get();
  // ...
  number_format->setMaximumFractionDigits(max_fractional_digits);
  // ...
  number_format->format(number, ustr);

The LazyInstance ensures thread-safe initialization, but subsequent accesses to the underlying object are entirely unsynchronized.

When setMaximumFractionDigits is called with a new value, ICU’s DecimalFormat implementation reconstructs its internal formatter (third_party/icu/source/i18n/decimfmt.cpp:1631). This invokes a move assignment (LocalizedNumberFormatter::operator=(LNF&& src)) which ultimately executes delete fCompiled; (third_party/icu/source/i18n/number_fluent.cpp:504). fCompiled is a heap-allocated NumberFormatterImpl object.

If Thread A changes the fraction digits (triggering delete fCompiled) while Thread B is concurrently executing number_format->format(), Thread B will dereference the deleted fCompiled object (third_party/icu/source/i18n/number_fluent.cpp:653). This leads to a virtual method call on the freed fMicroPropsGenerator (third_party/icu/source/i18n/number_formatimpl.cpp:78), resulting in an exploitable UAF. Because these pointers reside within the ICU library as raw C++ pointers, they are not protected by MiraclePtr (BackupRefPtr).

Reachability and Impact

This vulnerability is reachable when Out-of-Process (OOP) printing is disabled, forcing printer capability enumeration to occur in the browser process. This can happen on macOS or Linux if the enterprise policy OopPrintDriversAllowed is false, or if the EnableOopPrintDrivers feature flag is disabled.

When a user opens Print Preview, LocalPrinterHandlerDefault::StartGetCapability dispatches a task to a background base::ThreadPool worker. This worker ultimately calls base::FormatDouble through InfoForUnregisteredSize (chrome/common/printing/print_media_l10n.cc:773) to localize custom paper sizes.

Simultaneously, the UI thread frequently calls base::FormatDouble (often via ui::FormatBytes) for components like the Task Manager, Tab Hover Cards, and active Downloads.

Because the race condition occurs in the unsandboxed browser process, exploitation could lead to Remote Code Execution (RCE) and full system compromise.

Suggested Steps to Trigger

Note: These are potential steps as a full working proof-of-concept has not been developed.

  1. Use a macOS or Linux system configured with the OopPrintDriversAllowed enterprise policy set to false.
  2. Open the Chrome Task Manager and start a large file download. This ensures a steady stream of base::FormatDouble calls on the UI thread.
  3. Open the Print Preview dialog (Cmd+P / Ctrl+P). This triggers capability enumeration on a background worker thread, which will also call base::FormatDouble if unregistered paper sizes are encountered.
  4. The concurrent execution of base::FormatDouble across the UI and worker threads triggers the data race, potentially leading to a crash or UAF.

Suggested Fix

To resolve this issue, the shared ICU formatter should not be mutated concurrently. Potential solutions include:

  1. Thread-Local Storage: Replace the global LazyInstance with a base::ThreadLocalOwnedPointer to ensure each thread has its own dedicated ICU formatter instance.
  2. Synchronization: Protect the accesses to g_number_format_float with a base::Lock, though this may introduce performance bottlenecks given the frequency of calls.
  3. Local Instances: Avoid sharing instances altogether and instantiate a new icu::NumberFormat locally within base::FormatDouble, although this could incur significant performance overhead.

Evaluated with Chrome root at commit: a1e33f5848218e21d4a16ae2c1bc94e815c30c7f


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