CVE-2026-9883
Overview
Background
- `icu::NumberFormat`
- an ICU object that formats numbers into localized strings; it is not safe for concurrent mutation because configuration calls like
setMaximumFractionDigitsalter internal state. - `LazyInstance`
- a Chromium helper that lazily constructs a single process-global instance shared across all threads, used here to hold one
NumberFormatper numeric type. - `base::ThreadLocalOwnedPointer`
- a wrapper that gives each thread its own owned object, so no instance is ever touched by two threads at once.
- `FormatDouble` / `FormatNumber`
basefunctions that format a floating-point or integer value, mutating the shared formatter’s fractional-digit settings before calling it.
Root Cause Analysis
Before the fix, base/i18n/number_formatting.cc held two process-global icu::NumberFormat instances via LazyInstance<NumberFormatWrapper> (g_number_format_int and g_number_format_float), and every call to FormatNumber and FormatDouble fetched and mutated the single shared object without any locking. Because FormatDouble calls setMinimumFractionDigits/setMaximumFractionDigits on the global formatter before formatting, two threads formatting concurrently could interleave their configuration (one thread’s digit settings overwriting the other’s), yielding incorrect output, and more seriously could mutate ICU internal state simultaneously. The commit message states that concurrent mutation can trigger an internal reallocation inside ICU, producing a heap use-after-free in the browser process.
The fix replaces the two globals with base::ThreadLocalOwnedPointer<icu::NumberFormat> storage obtained through GetIntFormatterStorage and GetFloatFormatterStorage, and a new GetFormatter helper that lazily creates one formatter per thread; because each thread mutates only its own instance, the data race and the interleaving both disappear. The invariant restored is that a mutable, non-thread-safe ICU object is never shared across threads.
icu::NumberFormat as a process-global object while every formatting call mutated it; the fix moves the formatter into per-thread storage (base::ThreadLocalOwnedPointer) so concurrent callers can no longer race on the same instance.Attack Path
- Reach the formatting code concurrently
Trigger code paths in the browser process that call
base::FormatDouble/FormatNumberfrom two or more threads at the same time (the shipped regression testFormatDoubleRacespins up 20 threads each callingFormatDoublein a loop). - Interleave mutations on the shared formatter
Each thread mutates the single global
icu::NumberFormat(setting different fractional-digit counts), violating ICU’s requirement that the object not be mutated concurrently. - Provoke an internal reallocation Concurrent mutation can drive ICU to reallocate internal buffers, and a racing thread then accesses memory that has been freed by the reallocation, producing the heap use-after-free described in the advisory.
- Browser-process memory corruption (inferred)
The use-after-free occurs in the privileged browser process; any further read/write primitive or code execution beyond the crash is not demonstrated by the diff and is
(inferred).
Impact Assessment
Base. The diff demonstrates only the race and the resulting use-after-free; any escalation to a controllable read/write primitive or code execution is (inferred). The advisory does not state in-the-wild exploitation, and the preconditions (true concurrency against the same global formatter from the browser process) are what make the condition reachable.Changed Functions
| Function | Change | Notes |
|---|---|---|
NumberFormatWrapperbase/i18n/number_formatting.cc |
modified | |
ifbase/i18n/number_formatting.cc |
modified | |
NumberFormatWorkerThreadbase/i18n/number_formatting_unittest.cc |
modified | |
iterations_base/i18n/number_formatting_unittest.cc |
modified | |
forbase/i18n/number_formatting_unittest.cc |
modified | |
TESTbase/i18n/number_formatting_unittest.cc |
modified |
Files Changed
base/i18n/number_formatting.ccbase/i18n/number_formatting_unittest.cc
Audit Directions
- Mutable process-global ICU/third-party objectsSearch for
LazyInstance/NoDestructorsingletons wrapping library objects (ICU formatters, collators, break iterators) whose methods mutate internal state, and confirm they are either per-thread or externally synchronized. - Configure-then-use on shared stateFlag any code that sets configuration on a shared object immediately before using it; this “mutate then call” pattern is unsafe under concurrency even when individual calls look atomic.
- Thread-safety assumptions on helper utilitiesReview low-level
basehelpers called from many threads for implicit single-threaded assumptions, since callers rarely synchronize around seemingly pure formatting/parsing utilities.
Patch
From 4ee829d343f4c144535d766f23e2be0f3250613b Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
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 <dcheng@chromium.org>
Commit-Queue: Daniel Cheng <dcheng@chromium.org>
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) {
Regression Test / PoC
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
Original Bug Report
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.ccchrome/common/printing/print_media_l10n.ccchrome/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.
- Use a macOS or Linux system configured with the
OopPrintDriversAllowedenterprise policy set tofalse. - Open the Chrome Task Manager and start a large file download. This ensures a steady stream of
base::FormatDoublecalls on the UI thread. - Open the Print Preview dialog (Cmd+P / Ctrl+P). This triggers capability enumeration on a background worker thread, which will also call
base::FormatDoubleif unregistered paper sizes are encountered. - The concurrent execution of
base::FormatDoubleacross 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:
- Thread-Local Storage: Replace the global
LazyInstancewith abase::ThreadLocalOwnedPointerto ensure each thread has its own dedicated ICU formatter instance. - Synchronization: Protect the accesses to
g_number_format_floatwith abase::Lock, though this may introduce performance bottlenecks given the frequency of calls. - Local Instances: Avoid sharing instances altogether and instantiate a new
icu::NumberFormatlocally withinbase::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.