Low firefox Logic Error 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionJIT miscompilation in the JavaScript Engine: JIT component
ComponentSpiderMonkey
Bug ClassLogic Error
Tracker2053680
Fix commitcacd0898986e (firefox) +29/-7
CISA KEVNot listed
CreditedAmy Burnett of OpenAI
Disclosed2026-07-21

Background

Range Analysis
an Ion JIT optimization pass that tracks the possible numeric range of MIR values to prove bounds and enable faster code.
Denormal (subnormal)
a floating-point value with a zero exponent whose magnitude is below the smallest normal number, which flushes to zero when denormals are disabled.
`Float32` vs `double`
SpiderMonkey ranges may describe a 32-bit float value whose smallest normal magnitude (2**-126) is far larger than a 64-bit double’s (2**-1022).
`Range::setDouble`
the RangeAnalysis helper that initializes a Range from double bounds l and h, including reasoning about whether the range can straddle zero.

Root Cause Analysis

When denormals-are-disabled mode is active, any value in the denormal range is flushed to zero, so Range::setDouble must treat the whole denormal band as potentially equal to zero and conservatively report crossesZero when a range touches it. The original code computed the flush threshold doubleMin using the smallest normal double (2**-1022) via SpecificFloatingPointBits<double, 0, 1, 0>, which is correct only for actual double values. However, a Range can describe a Float32 value whose denormal range extends all the way up to the smallest normal binary32 (2**-126) — much wider than the double threshold — so a Float32 value like Math.fround(-1e-40) sits in the float32 denormal band but above the double threshold, and the pass wrongly concluded the range did not cross zero. This let the JIT miscompile comparisons and zero-related folds (e.g. the ternary x ? x : 0) because it assumed the value could not compare equal to zero when, after flush-to-zero on the executing thread, it actually did.

The fix widens doubleMin to the smallest normal binary32 threshold via BitwiseCast<float> of SpecificFloatingPointBits<float, 0, 1, 0>, keeping the analysis conservative for both float32 and double ranges. Using the wider float32 threshold ensures any value that could flush to zero on the main thread is included in the zero-crossing check.

Key insight
The single mistake was using the double smallest-normal magnitude as the denormal flush threshold for ranges that may actually hold Float32 values, whose denormal band is far wider; the fix substitutes the smallest-normal binary32 value so the zero-crossing test stays conservative for both float widths.

Attack Path

  1. Trigger flush-to-zero mode Run in an environment where main-thread denormals are disabled so subnormal values flush to zero at runtime.
  2. Feed a float32 denormal Call a hot function with Math.fround(-1e-40), a value inside the float32 denormal range but above the old double threshold.
  3. Provoke bad range analysis Let Ion warm up and analyze the value, where Range::setDouble incorrectly decides the range cannot cross zero.
  4. Miscompile the comparison Ion emits code that assumes the value is nonzero (e.g. selecting x instead of 0 in x ? x : 0), diverging from the flushed-to-zero runtime semantics.
  5. Observe wrong result The compiled function returns an incorrect value versus the interpreter, evidencing the JIT miscompilation.

Impact Assessment

An attacker gains a JIT miscompilation producing incorrect numeric/comparison results in the content-process JavaScript engine, rated low impact and classified as a logic error. Preconditions are narrow: denormals-are-disabled mode must be active and the code must exercise a Float32 value in the float32 denormal range under Ion compilation. The diff supports only observable correctness divergence; no memory-safety or exploit primitive is demonstrated.

Changed Functions

FunctionChangeNotes
with
js/src/jit-test/tests/ion/bug2053680.js
modified
for
js/src/jit-test/tests/ion/bug2053680.js
modified

Files Changed

  • js/src/jit-test/tests/ion/bug2053680.js
  • js/src/jit/RangeAnalysis.cpp

Audit Directions

  • Float32 range assumptions
    Review every RangeAnalysis path that reasons about magnitudes or thresholds to confirm it accounts for Range objects describing Float32 rather than only double values.
  • Denormal flush-to-zero handling
    Audit optimizations that special-case zero, nonzero, or sign around denormal boundaries to ensure they remain sound when denormals-are-disabled flushes values to zero on the executing thread.
  • Compiler/main-thread divergence
    Look for analysis that assumes the compiler thread and main thread interpret floating-point identically, and verify conservative bounds are used wherever runtime flushing can differ.
diff --git a/js/src/jit/RangeAnalysis.cpp b/js/src/jit/RangeAnalysis.cpp
index 5d538b81875..6ff34ea874e 100644
--- a/js/src/jit/RangeAnalysis.cpp
+++ b/js/src/jit/RangeAnalysis.cpp
@@ -718,14 +718,19 @@ void Range::setDouble(double l, double h) {
   canHaveFractionalPart_ = ExcludesFractionalParts;
   canBeNegativeZero_ = ExcludesNegativeZero;
 
-  // If denormals are disabled, any value with exponent 0 will be immediately
-  // flushed to 0. This gives 2**53 bit patterns that compare equal to zero.
+  // If denormals are disabled, any denormal value will be immediately flushed
+  // to 0, so any bit pattern in the denormal range compares equal to zero.
   //
-  // Check whether the range [l .. h] can cross any of the 2^53 zeros. We have
-  // to be conservative as the main thread might not interpret doubles the same
-  // way as the compiler thread.
-  const double doubleMin = mozilla::BitwiseCast<double>(
-      mozilla::SpecificFloatingPointBits<double, 0, 1, 0>::value);
+  // Check whether the range [l .. h] can cross any of these zeros. We have to
+  // be conservative as the main thread might not interpret floating point
+  // values the same way as the compiler thread.
+  //
+  // This Range may describe a Float32 value, whose denormal range begins at
+  // the smallest normal binary32 (2**-126) rather than the smallest normal
+  // binary64 (2**-1022). Use the (wider) binary32 threshold so we stay
+  // conservative for both float32 and double values.
+  const double doubleMin = double(mozilla::BitwiseCast<float>(
+      mozilla::SpecificFloatingPointBits<float, 0, 1, 0>::value));
   bool includesNegative = std::isnan(l) || l < doubleMin;
   bool includesPositive = std::isnan(h) || h > -doubleMin;
   bool crossesZero = includesNegative && includesPositive;
diff --git a/js/src/jit-test/tests/ion/bug2053680.js b/js/src/jit-test/tests/ion/bug2053680.js
new file mode 100644
index 00000000000..02969289f87
--- /dev/null
+++ b/js/src/jit-test/tests/ion/bug2053680.js
@@ -0,0 +1,17 @@
+// |jit-test| --disable-main-thread-denormals; --fast-warmup; --ion-check-range-analysis; skip-if: !getBuildConfiguration("can-disable-main-thread-denormals")
+function f(x) {
+  x = Math.fround(x);
+  if (x >= Math.fround(-1e-40)) {
+    return x ? x : 0;
+  }
+  return 7;
+}
+function test() {
+  with ({}) {} // Don't Ion-compile this function.
+  var res = 0;
+  for (var i = 0; i < 1000; i++) {
+    res += f(Math.fround(-1e-40));
+  }
+  assertEq(res, 0);
+}
+test();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/js/src/jit-test/tests/ion/bug2053680.js b/js/src/jit-test/tests/ion/bug2053680.js
new file mode 100644
index 00000000000..02969289f87
--- /dev/null
+++ b/js/src/jit-test/tests/ion/bug2053680.js
@@ -0,0 +1,17 @@
+// |jit-test| --disable-main-thread-denormals; --fast-warmup; --ion-check-range-analysis; skip-if: !getBuildConfiguration("can-disable-main-thread-denormals")
+function f(x) {
+  x = Math.fround(x);
+  if (x >= Math.fround(-1e-40)) {
+    return x ? x : 0;
+  }
+  return 7;
+}
+function test() {
+  with ({}) {} // Don't Ion-compile this function.
+  var res = 0;
+  for (var i = 0; i < 1000; i++) {
+    res += f(Math.fround(-1e-40));
+  }
+  assertEq(res, 0);
+}
+test();
Loading diff…