← WebKit Silent-Fix Report — 2026-W34

8283d4f1273add66f659b203baaae8e56c4fc3f2  [JSC] DFG Uint32Array load should consider about Int32 speculation path

severity high class TypeConfusion confidence 0.90 DFG SpeculativeJIT exploitable-grade
Yusuke Suzuki Sun Aug 23 16:14:52 2026 -0700 full: 8283d4f1273add66f659b203baaae8e56c4fc3f2 bug report ↗ view on GitHub ↗
Primitive: DFG Uint32Array load ignores Int32 speculation path producing wrong boxed value
Triage note: setIntTypedArrayLoadResult now adds an overflow speculationCheck and boxInt32 when shouldSpeculateInt32; test shows the load returned a stale/incorrect value (intArr2[0]!==42), a DFG miscompile / result-type confusion.
Contents

The bug at a glance

Directly reachable from script: any function that reads from a Uint32Array in a hot loop where the loaded values happen to fit in Int32 will drive the DFG to speculate Int32 on the GetByVal, hitting the buggy path. setIntTypedArrayLoadResult boxed the loaded uint32 as a double while the rest of the DFG-compiled code, having proven the node produces SpecInt32Only, consumes the result as a boxed Int32 - a JSValue representation confusion (double bits read where an Int32 tag/payload was promised) that surfaces a wrong, attacker-influenced value. JS-reachable type confusion on a JSValue is a classic exploitation primitive, and the higher 8.8 reflects the controllability of the loaded value and the absence of any special preconditions.

A Uint32Array can hold values up to 2^32-1, which do not fit in a signed Int32, so JSC normally boxes such loads as doubles. But when profiling shows every observed value is small, the DFG’s prediction propagation marks the GetByVal shouldSpeculateInt32() and the rest of the compiled function treats the result as an Int32 JSValue. setIntTypedArrayLoadResult never got the memo: for a Uint32Array it unconditionally did convertUInt32ToDouble + boxDouble, minting a boxed-double JSValue into the very registers a downstream node reads as boxed-Int32. FTL already handled this correctly; DFG did not. The result is a JIT type confusion between the Int32 and Double JSValue encodings, producing a garbage integer that gets stored back into an array as an attacker-visible wrong value.

Root cause

SpeculativeJIT::setIntTypedArrayLoadResult finalizes the result of a GetByVal on an integer typed array. When the result must be boxed into a JSValueRegs (shouldBox) and the array is a Uint32Array (isUInt32), the old code always executed convertUInt32ToDouble(resultReg, resultFPR) followed by boxDouble(resultFPR, resultRegs) - i.e. it produced a boxed double regardless of how the node’s result was typed elsewhere in the graph.

During DFG prediction propagation, a Uint32Array GetByVal whose observed values all fit in a signed Int32 gets node->shouldSpeculateInt32() set. The abstract interpreter then proves the node’s result is SpecInt32Only, and every consumer of that node is compiled assuming an Int32-encoded JSValue in resultRegs (on 64-bit JSC, tag bits Int32Tag with the value in the low 32 bits). Because setIntTypedArrayLoadResult instead wrote a boxDouble encoding into those same registers, the promised Int32 and the actual Double representations disagree: a consumer reading the payload/tag of what it believes is a boxed Int32 sees the bit pattern of a boxed double, yielding a wrong integer. The soundness invariant that a value’s DataFormat/SpeculatedType matches its physical encoding is violated by the code generator itself.

The fix branches on the speculation. When node->shouldSpeculateInt32() && canSpeculate, it first emits speculationCheck(ExitKind::Overflow, JSValueRegs(), nullptr, branch32(LessThan, resultReg, TrustedImm32(0))) - because a uint32 with its high bit set (value >= 2^31) reads as negative when interpreted as a signed Int32 and cannot be represented, so that case OSR-exits with an Overflow exit kind - and otherwise boxInt32(resultReg, resultRegs), producing a true boxed-Int32 JSValue that matches the speculated type. Only when the node is not Int32-speculated (or a speculation check cannot be emitted here) does it fall back to the original convertUInt32ToDouble + boxDouble double path. The commit notes FTL already did exactly this; the change brings DFG to parity.

The test reproduces the miscompile with FTL disabled and a low jitPolicyScale to force the DFG tier. A Uint32Array a with a[0]=42 is read as v = a[i]; the value is used both as a double (d = v - 0.5) and stored back (intArr[0] = v). f1 warms/pollutes and eviction shapes profiling so that f2’s fresh DFG compile speculates Int32 on the load. After warmup the final f2(a,0,false,intArr2) executes the buggy path; with the bug the stored intArr2[0] is not 42 (a stale/wrong value from the double-encoded JSValue read as Int32), which the test flags via intArr2[0] !== 42.

Key code

Int32-speculated Uint32Array load now boxes Int32 with an overflow check (DFGSpeculativeJIT.cpp)

    if (shouldBox) {
        if (isUInt32) {
-            convertUInt32ToDouble(resultReg, resultFPR);
-            boxDouble(resultFPR, resultRegs);
+            if (node->shouldSpeculateInt32() && canSpeculate) {
+                speculationCheck(ExitKind::Overflow, JSValueRegs(), nullptr, branch32(LessThan, resultReg, TrustedImm32(0)));
+                boxInt32(resultReg, resultRegs);
+            } else {
+                convertUInt32ToDouble(resultReg, resultFPR);
+                boxDouble(resultFPR, resultRegs);
+            }
        } else
            boxInt32(resultRegs.payloadGPR(), resultRegs);
        if (outOfBounds.isSet())
            ...

Patch walkthrough

  • Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp — In setIntTypedArrayLoadResult, the Uint32Array boxing branch is split. Previously it unconditionally did convertUInt32ToDouble(resultReg, resultFPR); boxDouble(resultFPR, resultRegs). Now, when node->shouldSpeculateInt32() && canSpeculate, it emits a speculationCheck(ExitKind::Overflow, …) on branch32(LessThan, resultReg, TrustedImm32(0)) - guarding against uint32 values that do not fit in a signed Int32 - and then boxInt32(resultReg, resultRegs), producing an Int32 JSValue that matches the node’s speculated SpecInt32Only type. The original double-boxing path is kept as the else branch for the non-Int32-speculated case. This mirrors the existing FTL behavior.
  • JSTests/stress/uint32-array-result-int32-dfg.js — Regression test run with –useFTLJIT=false –useConcurrentJIT=false –jitPolicyScale=0.1 to force a DFG compile. It builds two identical functions from source (via new Function) reading v = a[i] from a Uint32Array, computing d = v - 0.5 and storing intArr[0] = v; f1 plus an eviction step shape profiling so f2’s DFG compile speculates Int32 on the load. The final f2(a,0,false,intArr2) hits the buggy path and the test throws if intArr2[0] !== 42, catching the wrong-typed stored value.

Background

DFG speculation / shouldSpeculateInt32() — The DFG uses value profiles to predict types. When observed Uint32Array loads all fit in a signed Int32, the GetByVal node’s shouldSpeculateInt32() is set; the abstract interpreter then proves the result is SpecInt32Only and downstream code is compiled to that assumption, backed by OSR-exit guards for the unlikely violating case.

JSValue boxing: Int32 vs Double — On 64-bit JSC a boxed Int32 is a tagged JSValue (Int32Tag | value); a boxed double is NaN-encoded (double bits offset by DoubleEncodeOffset). The two encodings are not interchangeable - reading a boxed-double as an Int32 (or the reverse) yields a garbage value or a bogus cell pointer.

Uint32Array representation problem — Uint32Array elements range over 0..2^32-1; values >= 2^31 do not fit in a signed Int32, so a correct Int32 speculation must OSR-exit (ExitKind::Overflow) when the loaded value has its high bit set, which the branch32(LessThan, resultReg, 0) check detects.

setIntTypedArrayLoadResult — The SpeculativeJIT helper that materializes the loaded integer into either a raw GPR result or a boxed JSValue result, handling sign/zero extension and the Uint32 special case; it must produce an encoding consistent with the node’s speculated type.

Vulnerability window

  1. Latent divergence — FTL correctly boxed Int32-speculated Uint32Array loads as Int32; DFG’s setIntTypedArrayLoadResult unconditionally boxed them as double, silently disagreeing with the node’s speculated type.
  2. Trigger — A hot function reading small values from a Uint32Array causes the DFG to set shouldSpeculateInt32() on the GetByVal, and downstream nodes are compiled to consume an Int32 JSValue.
  3. Miscompile — The generated load writes a boxed-double encoding into the registers a consumer reads as boxed-Int32, producing a wrong value that (in the test) is stored back into an array as intArr[0] != 42.
  4. Fix / parity — Yusuke Suzuki (bug 319112, rdar://176792844, reviewed by Mark Lam) added the overflow speculationCheck + boxInt32 path, matching FTL.
  5. Backport — Originally landed as 305413.1114 on safari-7624.5-branch (2bc65ac3560e, rdar://185368553); canonical link 319669@main.

Proof of concept

The upstream test is a faithful trigger PoC (f1/eviction warmup elided here for brevity). It forces a DFG compile of f2 where the Uint32Array load is Int32-speculated; the buggy double-boxing path then causes intArr2[0] to hold a wrong value instead of 42. It demonstrates the type confusion / miscompile but does not build a memory-corruption primitive.

//@ runDefault("--useConcurrentJIT=false", "--useFTLJIT=false", "--jitPolicyScale=0.1")
let a = new Uint32Array(4);
a[0] = 42;
let dummy = [1, 2, 3]; dummy[0] = 1; dummy[0] = {};
const sBodyArgs = ['a', 'idx', 'flag', 'intArr',
  "\n    let i = idx;\n    if (flag) i = 0.5;\n    let v = a[i];\n    let d = v - 0.5;\n    intArr[0] = v;\n    return d;\n"];
let f2 = new Function(...sBodyArgs); noInline(f2);
let intArr2 = [1, 2, 3]; intArr2[0] = 1;
// warm f2's DFG compile so the Uint32Array load speculates Int32
for (let k = 0; k < 100; k++) f2(a, 0, false, intArr2);
f2(a, 0, false, intArr2);
if (intArr2[0] !== 42)
    throw new Error("incorrect value " + intArr2[0]);

Exploitation

  1. Shape the speculation — Repeatedly read small values from a Uint32Array in a hot, non-inlined function so the DFG marks the GetByVal shouldSpeculateInt32() and compiles consumers to expect an Int32 JSValue.
  2. Induce the encoding confusion — On the buggy path the load writes a boxed-double into registers consumed as boxed-Int32; a downstream operation reading the Int32 payload/tag observes double bits, producing an attacker-influenced wrong integer (the loaded array value controls the confused bits).
  3. Escalate — Not shown in the patch. Reliable escalation would require steering the confused JSValue into a context where the mismatched tag yields a controllable cell pointer or index; the test only proves the wrong-value/type-confusion, not a full read/write primitive.

Detection & hunting

For defenders and SOC / detection engineers:

  • DFG vs FTL/interpreter divergence
  • Boxing/format mismatch assertions
  • Uint32Array GetByVal with Int32 speculation

Audit directions

  • Other typed-array element boxings
  • DFG/FTL parity
  • canSpeculate gating

Before / after

Loading diff…