← WebKit Silent-Fix Report — 2026-W34

7711916200dda7e418b321e34493f66a7af2a0b0  [JSC] NodeMustGenerate incorrectly cleared on CheckOverflow ArithAdd/ArithSub in DFGFixupPhase Inc/Dec handler

severity high class TypeConfusion confidence 0.90 JSC DFG exploitable-grade
Kai Tamkun Sat Aug 22 13:34:48 2026 -0700 full: 7711916200dda7e418b321e34493f66a7af2a0b0 bug report ↗ view on GitHub ↗
Primitive: NodeMustGenerate wrongly cleared on CheckOverflow ArithAdd/ArithSub in DFG Inc/Dec fixup
Triage note: DFGFixupPhase cleared NodeMustGenerate on Inc/Dec ArithAdd/ArithSub unconditionally, letting DCE drop the overflow check even on the Int32 CheckOverflow path (test: inc(2147483647) must return false). Fix moves clearFlags inside the DoubleRepUse branch so the overflow-checking integer node is retained, preventing a JIT soundness miscompile.
Contents

The bug at a glance

The miscompilation is reachable from ordinary JavaScript: a hot ++/– on an integer that reaches its Int32 limit is enough to trigger the wrong-typed result once the function is JIT-compiled, and warm-up is script-driven. Because the DFG drops an integer overflow check it should have preserved, the JIT produces a value that is semantically wrong and, in downstream typed uses, a soundness violation that generalizes to type confusion and memory corruption, justifying a High rating; the CVSS of 8.6 reflects broad reachability but the need to build a full exploit chain on top of the miscompile.

DFG’s Inc/Dec fixup wanted to strip NodeMustGenerate from the ArithAdd/ArithSub it lowers ++/– to, because in the floating-point (DoubleRepUse) case the node genuinely cannot have observable side effects. But the clearFlags() call sat outside the DoubleRepUse branch, so it also fired on the Int32 path where the ArithAdd carries a CheckOverflow. Stripping NodeMustGenerate there told dead-code elimination the overflow-checking node was pure and disposable, so DCE deleted the very check that guards against Int32 wraparound, and the JIT started returning wrong answers for values at the edge of the integer range.

Root cause

In DFGFixupPhase::fixupNode(), the handler for Inc and Dec lowers each into an ArithAdd or ArithSub. Depending on the observed operand types it either fixes the edges as DoubleRepUse and sets the node result to NodeResultDouble (the floating-point path), or leaves it as an integer ArithAdd/ArithSub that carries a CheckOverflow arithmetic mode to detect Int32 wraparound.

The bug is that node->clearFlags(NodeMustGenerate) was placed after the DoubleRepUse if-block, unconditionally clearing the must-generate flag for both paths. NodeMustGenerate marks a node as having effects (or a check) that must not be eliminated. On the double path the ArithAdd is genuinely pure — a double add cannot overflow-trap — so clearing the flag is correct. On the Int32 path, however, the ArithAdd/ArithSub still carries a CheckOverflow: its whole purpose is to bail out (deopt) when the increment overflows the Int32 range.

By clearing NodeMustGenerate on the Int32 CheckOverflow node, the fixup told later phases the node was side-effect-free. Dead-code elimination then saw a node whose result, if unused as a pure value, could be dropped — and eliminated the overflow check entirely. The compiled code no longer detected wraparound: inc(2147483647) computed a value that silently wrapped instead of transitioning to a double, so (y | 0) === y returned true when it must return false. The produced value is wrong-typed relative to what the bytecode semantics and downstream type speculations assume, which is the soundness violation this bug class captures.

The fix moves node->clearFlags(NodeMustGenerate) inside the DoubleRepUse branch, next to fixEdge<DoubleRepUse>() and setResult(NodeResultDouble). Now the flag is cleared only for the genuinely pure double ArithAdd/ArithSub; the Int32 CheckOverflow node retains NodeMustGenerate, DCE is forced to keep it, and the overflow check survives to the compiled code.

Key code

clearFlags(NodeMustGenerate) moved inside the DoubleRepUse branch

                fixEdge<DoubleRepUse>(node->child1());
                fixEdge<DoubleRepUse>(node->child2());
                node->setResult(NodeResultDouble);
                node->clearFlags(NodeMustGenerate);   // now inside the double branch
            }
            // was: node->clearFlags(NodeMustGenerate);  // fired on Int32 CheckOverflow path too
            break;
        }

Patch walkthrough

  • Source/JavaScriptCore/dfg/DFGFixupPhase.cpp — In FixupPhase::fixupNode()’s Inc/Dec handler, the single line node->clearFlags(NodeMustGenerate) is relocated from after the DoubleRepUse if-block to inside it, immediately following node->setResult(NodeResultDouble). This makes the must-generate flag clearing conditional on the double-representation path. On the Int32 path the ArithAdd/ArithSub keeps NodeMustGenerate, so its CheckOverflow can no longer be considered dead and removed by DCE, restoring correct overflow-to-double behavior.
  • JSTests/stress/inc-dec-int32-overflow-dce.js — A regression test with inc(k) doing ++y and dec(k) doing –y, both noInline and both checking (y|0)===y. After 1e6 warm-up iterations at Int32 values (forcing FTL/DFG compilation), it asserts inc(2147483647) === false and dec(-2147483648) === false. These edge values overflow Int32; a correct compile must produce a double so the identity check fails, catching the dropped overflow check.

Background

NodeMustGenerate — A DFG node flag marking a node as having effects or a required check (such as an overflow bail-out) that must be preserved. Dead-code elimination is not allowed to remove a node while this flag is set, so incorrectly clearing it lets DCE delete safety-critical nodes.

CheckOverflow arithmetic mode — An ArithAdd/ArithSub in Int32 mode can carry CheckOverflow, meaning the operation checks whether the integer result overflows and, if so, triggers an OSR exit / recompilation to a form that produces a double. This is how JSC keeps ++ on 2^31-1 semantically correct as a transition to double.

DoubleRepUse fixup path — When operands are profiled as doubles, the Inc/Dec handler fixes edges as DoubleRepUse and sets the result to NodeResultDouble. A double add has no overflow trap and no side effects, so clearing NodeMustGenerate is safe only on this path.

DFGFixupPhase — The DFG compiler phase that specializes generic nodes based on type speculation, choosing representations (Int32 vs double) and array modes. Errors here directly shape what later optimization phases believe about node purity and legality.

Vulnerability window

  1. Bytecode — A ++ or – produces an Inc/Dec node in the DFG graph over an integer-profiled operand.
  2. Fixup — FixupPhase lowers Inc/Dec to an ArithAdd/ArithSub. On the Int32 path the node carries CheckOverflow, but the misplaced clearFlags strips its NodeMustGenerate flag.
  3. DCE — Dead-code elimination sees the overflow-checking node as pure and, when its value is not otherwise pinned, removes the overflow check.
  4. Compilation — After ~1e6 warm-up iterations the function tiers up; the compiled code performs the increment without the overflow bail-out.
  5. Trigger — inc(2147483647) increments past Int32_MAX; without the check it wraps or keeps an int representation instead of transitioning to a double, so (y|0)===y wrongly returns true.
  6. Impact — The JIT emits a wrong-typed value that violates the invariants downstream speculations rely on, a soundness bug exploitable as a stepping stone to type confusion.

Proof of concept

Directly from the shipped stress test (uses noInline and heavy warm-up to force DFG/FTL compilation). It demonstrates the observable soundness failure: on a vulnerable build the overflow check is eliminated so the increment does not transition to a double, and the (y|0)===y identity wrongly holds. This proves the miscompilation but is not itself a memory-corruption exploit; building type confusion on top of it requires additional wrong-typed-value plumbing not shown here.

function inc(k) {
    let y = k;
    ++y;
    return (y | 0) === y;
}
noInline(inc);
function dec(k) {
    let y = k;
    --y;
    return (y | 0) === y;
}
noInline(dec);
for (let i = 0; i < 1e6; ++i) { inc(1); dec(1); }
// Correct engine: false. Buggy engine: true (overflow check DCE'd).
print(inc(2147483647));  // expect false
print(dec(-2147483648)); // expect false

Exploitation

  1. Force compilation — Attacker warms a small increment/decrement function with Int32 inputs until the DFG/FTL compiles it, at which point the overflow check has been eliminated by DCE.
  2. Manufacture a wrong-typed value — Feeding an Int32-boundary value produces a result the engine treats as one type while its actual value is inconsistent with speculation, the seed for a type confusion where later code assumes a proven-but-false integer range.
  3. Escalate — As with other DFG integer-range/soundness bugs, the wrong-typed value would be threaded into an array-bounds or structure speculation to obtain out-of-bounds access or a fake object, then leveraged for read/write primitives. This step is nontrivial and specific to the surrounding code shape.

Detection & hunting

For defenders and SOC / detection engineers:

  • Identity-check anomaly
  • DFG graph inspection
  • OSR-exit absence

Audit directions

  • clearFlags placement in FixupPhase
  • Other Inc/Dec-like lowerings
  • DCE interaction with checks

Before / after

Loading diff…