CVE-2026-9883
Overview
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
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.