CVE-2025-1914
Overview
Background
- `DoubleToRadixString`
- V8 helper in
src/numbers/conversions.ccthat converts adoubleto its string representation in an arbitrary radix (e.g.Number.prototype.toString(radix)). - Denormal flushing
- a CPU/FPU mode (“flush-to-zero”) in which subnormal floating-point results are rounded to
0.0instead of being represented, controlled here viabase::FPU::GetFlushDenormals(). - `delta`
- the per-iteration precision threshold (half the gap to the next representable
double) that bounds how many fractional digits the loop emits before stopping. - `base::Double(0.0).NextDouble()`
- the smallest positive (denormal)
double, used as a floor fordeltaso the fractional-digit loop can still make progress.
Root Cause Analysis
The fractional-digit path of DoubleToRadixString computes delta as half the distance to the next double and then clamps it upward with delta = std::max(base::Double(0.0).NextDouble(), delta), relying on the invariant that delta is always strictly positive so the digit loop terminates and stays within the fixed-size stack buffer. When the FPU is in denormal-flushing mode, the smallest positive double returned by NextDouble() is itself flushed to 0.0, so the std::max clamp does not actually raise delta above zero, and the compiler may further optimize away the delta > 0.0 guard (the old code only asserted this with a DCHECK_IMPLIES, which is compiled out in release builds). With delta effectively zero, the fraction >= delta loop condition never becomes false, so the loop keeps writing fractional digits past the end of buffer, producing an out-of-bounds read/write.
The fix stops trusting the arithmetic clamp: it explicitly checks if (delta <= 0), and when denormals are being flushed it sets delta_is_positive = false to skip the fractional loop entirely, otherwise it manually assigns the minimum denormal value; the loop now runs only when delta_is_positive.
std::max(NextDouble(), delta) to guarantee a strictly positive delta, an invariant that silently breaks when the FPU flushes denormals to zero; the fix replaces the implicit arithmetic guarantee with an explicit denormal-flush check that either supplies the minimum delta or skips the digit loop, so the loop can never run with delta == 0.Attack Path
- Enter flush-to-zero mode
Reach an execution context where the FPU has denormal flushing enabled, so
base::Double(0.0).NextDouble()and other subnormal results collapse to0.0. - Trigger radix conversion
Invoke
Number.prototype.toString(radix)(or an equivalent path intoDoubleToRadixString) on a value with a nonzero fractional part. - Collapse the delta
The
std::maxclamp fails to liftdeltaabove zero because the minimum denormal is flushed, leavingdelta == 0.0while the removed release-modeDCHECKgives no protection. - Run the unbounded loop
With
fraction >= deltaalways true, thedo { ... } whileloop keeps emitting fractional digits and advancesfraction_cursorpast the end of the stackbuffer. - Out-of-bounds access The overflowed cursor reads/writes adjacent stack memory, corrupting or leaking data that an attacker can leverage for information disclosure or further exploitation.
Impact Assessment
Number.prototype.toString conversion. The main precondition is that the FPU is in denormal-flushing mode, which makes the smallest positive double unrepresentable; given that, the corruption occurs deterministically and can be used for memory disclosure or as a primitive toward sandboxed code execution.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/numbers/conversions.cc |
modified |
Files Changed
src/numbers/conversions.cc
Audit Directions
- Denormal-flush assumptionsSearch for code that relies on the smallest positive
double(e.g.NextDouble(),std::numeric_limits<double>::denorm_min()) or onstd::maxclamps to guarantee a strictly positive value, since flush-to-zero mode can silently violate that assumption. - DCHECK-only invariantsFlag loop-termination or bounds invariants enforced only by
DCHECK/DCHECK_IMPLIES, which are compiled out in release builds and offer no runtime protection against optimized-away zero checks. - Precision-bounded write loopsReview numeric-to-string and formatting loops in
src/numbers/whose termination depends on a floating-point threshold compared against a fixed-size stack buffer, ensuring the threshold cannot reach zero.
Patch
From 00b8fba79ed2d14ec8e7fb1e2bcc38f3d59afaef Mon Sep 17 00:00:00 2001
From: Leszek Swirski <leszeks@chromium.org>
Date: Thu, 20 Feb 2025 14:19:24 +0100
Subject: [PATCH] [conversions] Check for denormal flushing in DoubleToRadixString
Explicitly check the denormal flushing flag in DoubleToRadixString, to
avoid the compiler optimizing away checks against zero.
Bug: 382005099
Fixed: 397731718
Change-Id: If9298e68827bf2e5a1fd987b04b2536bf937a19f
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/6286167
Commit-Queue: Leszek Swirski <leszeks@chromium.org>
Auto-Submit: Leszek Swirski <leszeks@chromium.org>
Reviewed-by: Jakob Kummerow <jkummerow@chromium.org>
Cr-Commit-Position: refs/heads/main@{#98833}
---
diff --git a/src/numbers/conversions.cc b/src/numbers/conversions.cc
index 95e0036..c8534b7 100644
--- a/src/numbers/conversions.cc
+++ b/src/numbers/conversions.cc
@@ -1251,11 +1251,19 @@
double fraction = value - integer;
// We only compute fractional digits up to the input double's precision.
double delta = 0.5 * (base::Double(value).NextDouble() - value);
- delta = std::max(base::Double(0.0).NextDouble(), delta);
- // Delta should always be greater than zero, so long as we're not flushing
- // denormals to zero.
- DCHECK_IMPLIES(!(delta > 0.0), base::FPU::GetFlushDenormals());
- if (delta > 0.0 && fraction >= delta) {
+ bool delta_is_positive = true;
+ // If the delta rounded down to zero, use the minimum (denormal) delta
+ // value. Be careful around denormal flushing when doing so.
+ if (delta <= 0) {
+ if (base::FPU::GetFlushDenormals()) {
+ // We're flushing the delta value to zero, so the loop below won't
+ // make progress. Skip it instead.
+ delta_is_positive = false;
+ } else {
+ delta = base::Double(0.0).NextDouble();
+ }
+ }
+ if (delta_is_positive && fraction >= delta) {
// Insert decimal point.
buffer[fraction_cursor++] = '.';
do {
Original Bug Report
Debug check failed: index < length_ (2200 vs. 2200).
The issue 395329242 that we previously reported has been merged into issue 394644268.
However, even after applying the patch for issue 394644268 (https://chromium-review.googlesource.com/c/v8/v8/+/6236978),, the DCHECK can still be triggered in the latest d8 (#98715).
Therefore, we kindly request you to investigate this issue further.