CVE-2026-16410
Overview
Background
- Range Analysis
- an Ion JIT optimization pass that tracks the possible numeric range of
MIRvalues 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
floatvalue whose smallest normal magnitude (2**-126) is far larger than a 64-bitdouble’s (2**-1022). - `Range::setDouble`
- the
RangeAnalysishelper that initializes aRangefrom double boundslandh, 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.
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
- Trigger flush-to-zero mode Run in an environment where main-thread denormals are disabled so subnormal values flush to zero at runtime.
- Feed a float32 denormal
Call a hot function with
Math.fround(-1e-40), a value inside the float32 denormal range but above the olddoublethreshold. - Provoke bad range analysis
Let Ion warm up and analyze the value, where
Range::setDoubleincorrectly decides the range cannot cross zero. - Miscompile the comparison
Ion emits code that assumes the value is nonzero (e.g. selecting
xinstead of0inx ? x : 0), diverging from the flushed-to-zero runtime semantics. - Observe wrong result The compiled function returns an incorrect value versus the interpreter, evidencing the JIT miscompilation.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
withjs/src/jit-test/tests/ion/bug2053680.js |
modified | |
forjs/src/jit-test/tests/ion/bug2053680.js |
modified |
Files Changed
js/src/jit-test/tests/ion/bug2053680.jsjs/src/jit/RangeAnalysis.cpp
Audit Directions
- Float32 range assumptionsReview every
RangeAnalysispath that reasons about magnitudes or thresholds to confirm it accounts forRangeobjects describingFloat32rather than onlydoublevalues. - Denormal flush-to-zero handlingAudit 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 divergenceLook 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.
Patch
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();
Regression Test / PoC
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();