High watch CVSS 8.8 chrome OOB Exploitability: High (assessed) 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in V8
DescriptionOut of bounds read in V8
ComponentV8
Sub-componentConversions
Bug ClassOOB
ExploitabilityHigh (assessed by PatchGap · plausible)
DoubleToRadixString's delta clamp fails under denormal-flushing FPU mode so the fractional loop writes past the fixed stack buffer (OOB write) via Number.toString(radix); grade High but gating on FPU flush mode isn't clearly default from the truncated diff.
PatchGap's own analysis from the fixing commit, not a vendor rating. Absence of this line means not yet assessed, not "not exploitable".
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker397731718
Fix commit00b8fba79ed2 (v8/v8) +13/-5
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedZhenghang Xiao (@Kipreyyy) and Nan Wang (@eternalsakura13)
Reported2025-02-20 · bounty $7000 (per vendor advisory)
Fix landed2025-02-20 (public git)
Disclosed2025-03-04
Patch gap12 days — fix public in source before the advisory

Background

`DoubleToRadixString`
V8 helper in src/numbers/conversions.cc that converts a double to 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.0 instead of being represented, controlled here via base::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 for delta so 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.

Key insight
The single mistake was relying on 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

  1. 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 to 0.0.
  2. Trigger radix conversion Invoke Number.prototype.toString(radix) (or an equivalent path into DoubleToRadixString) on a value with a nonzero fractional part.
  3. Collapse the delta The std::max clamp fails to lift delta above zero because the minimum denormal is flushed, leaving delta == 0.0 while the removed release-mode DCHECK gives no protection.
  4. Run the unbounded loop With fraction >= delta always true, the do { ... } while loop keeps emitting fractional digits and advances fraction_cursor past the end of the stack buffer.
  5. 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

An attacker gains an out-of-bounds read (and write via the digit-emitting loop) on the stack buffer inside the V8/renderer process, achievable from untrusted script through a standard 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

FunctionChangeNotes
if
src/numbers/conversions.cc
modified

Files Changed

  • src/numbers/conversions.cc

Audit Directions

  • Denormal-flush assumptions
    Search for code that relies on the smallest positive double (e.g. NextDouble(), std::numeric_limits<double>::denorm_min()) or on std::max clamps to guarantee a strictly positive value, since flush-to-zero mode can silently violate that assumption.
  • DCHECK-only invariants
    Flag 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 loops
    Review 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.
From 00b8fba79ed2d14ec8e7fb1e2bcc38f3d59afaef Mon Sep 17 00:00:00 2001
From: Leszek Swirski <[email protected]>
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 <[email protected]>
Auto-Submit: Leszek Swirski <[email protected]>
Reviewed-by: Jakob Kummerow <[email protected]>
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 {
Loading diff…

Original Bug Report

reported by [email protected]

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.

View on issue tracker