← WebKit Silent-Fix Report — 2026-W25

f7026fa96b  [JSC] Profile op_unsigned overflow in LLInt / Baseline

severity medium class IntOverflow confidence 0.60 JSC LLInt/Baseline/DFG exploitable-grade
Yusuke Suzuki Wed Jun 17 21:10:56 2026 -0700 full: f7026fa96bc792879d0e9f2bd3b96bf8284e8f72 bug report ↗ view on GitHub ↗
Primitive: unprofiled op_unsigned overflow into DFG
Triage note: Adds arith profiling for op_unsigned so DFG overflow speculation is sound; same JIT-soundness family as the Int32 overflow fix.
Contents

The bug at a glance

This is JIT profiling soundness for op_unsigned (UInt32ToNumber): without arith profiling, the DFG’s decision about whether the uint32->number conversion can overflow int32 relied only on OSR-exit history, risking either miscompilation or repeated deopt storms. The commit message and rdar framing present it as a soundness/performance correctness fix rather than a demonstrated corruption, so medium; the family (Int32 overflow speculation) is the same one that produces exploitable JIT bugs when speculation is wrong.

The angle is where speculation information comes from: op_unsigned was the odd unary op that carried no UnaryArithProfile, so the DFG could only learn about int32-overflowing results after an OSR exit at the UInt32ToNumber node. This patch adds first-class profiling in LLInt/Baseline and feeds it into the DFG’s overflow-flag decision so speculation is grounded in observed behavior, not just exit sites.

Root cause

OBSERVED: op_unsigned implements JavaScript’s unsigned-right-shift-by-zero / ToUint32-style conversion; at the bytecode level its result is a uint32 that JSC wants to represent as an int32 when it fits and as a double when it exceeds INT32_MAX. In the DFG this is the UInt32ToNumber node, whose codegen depends on whether the value ‘may overflow int32’ (i.e. has the high bit set so it does not fit a signed int32).

OBSERVED: Before this patch, op_unsigned was a plain UnaryOp with no arith profile (it was listed under op_group :UnaryOp in BytecodeList.rb). The patch moves :unsigned from :UnaryOp to :ProfiledUnaryOp, giving OpUnsigned a m_profileIndex and a UnaryArithProfile, and registers OpUnsigned in the FOR_EACH_ARITH_PROFILE-style macro list in Opcode.h.

OBSERVED: BytecodeGenerator::emitUnaryOp now special-cases op_unsigned: ‘if constexpr (UnaryOp::opcodeID == op_unsigned) UnaryOp::emit(this, dst, src, m_codeBlock->addUnaryArithProfile()); else UnaryOp::emit(this, dst, src);’ – allocating a profile slot at bytecode-emit time.

OBSERVED: The LLInt/Baseline slow path slow_path_unsigned now records the result into the profile: it fetches ‘auto& profile = codeBlock->unlinkedCodeBlock()->unaryArithProfile(bytecode.m_profileIndex)’ and uses RETURN_WITH_PROFILING(result, { profile.observeResult(result); }) instead of a bare RETURN(jsNumber(a)). observeResult records whether the result was an int32, a double (overflow), etc.

OBSERVED: In DFGByteCodeParser::makeSafe, a new ‘case UInt32ToNumber:’ pulls the UnaryArithProfile for the current bytecode index (unaryArithProfileForBytecodeIndex), and if profile->didObserveInt32Overflow() OR the exit profile hasExitSite(m_currentIndex, Overflow), it merges NodeMayOverflowInt32InBaseline onto the node. Previously only the exit-site half of that condition was available.

INFERRED: NodeMayOverflowInt32InBaseline steers the DFG/FTL to compile UInt32ToNumber in a form that correctly handles values above INT32_MAX (producing a double / taking the overflow path) rather than speculating the result always fits int32. Without the profile, the DFG could speculate no-overflow and only discover otherwise via an OSR exit, causing exit churn; a soundness bug here is the general precondition for the OOB/type-confusion primitives that mis-speculated arithmetic ranges enable, though this specific patch is framed as making the existing speculation sound rather than fixing a proven corruption.

Key code

DFG now consults the new profile (plus exit sites) to flag UInt32ToNumber overflow (DFGByteCodeParser.cpp)

        case UInt32ToNumber: {
            UnaryArithProfile* arithProfile = m_inlineStackTop->m_profiledBlock->unaryArithProfileForBytecodeIndex(m_currentIndex);
            if (!arithProfile)
                break;
            if (arithProfile->didObserveInt32Overflow() || m_inlineStackTop->m_exitProfile.hasExitSite(m_currentIndex, Overflow))
                node->mergeFlags(NodeMayOverflowInt32InBaseline);
            break;
        }

Patch walkthrough

  • Source/JavaScriptCore/bytecode/BytecodeList.rb — Moves :unsigned out of op_group :UnaryOp and into op_group :ProfiledUnaryOp, so op_unsigned is emitted with a profile index (m_profileIndex) and a backing UnaryArithProfile like to_number/to_numeric/bitnot.
  • Source/JavaScriptCore/bytecode/Opcode.h — Adds ‘macro(OpUnsigned)’ to the arith-profile opcode macro list so OpUnsigned participates in the profiled-opcode machinery.
  • Source/JavaScriptCore/bytecompiler/BytecodeGenerator.h — emitUnaryOp now allocates a UnaryArithProfile for op_unsigned (m_codeBlock->addUnaryArithProfile()) via a constexpr branch, leaving all other unary ops unchanged.
  • Source/JavaScriptCore/runtime/CommonSlowPaths.cpp — slow_path_unsigned records the produced value into the profile using RETURN_WITH_PROFILING(result, { profile.observeResult(result); }), so LLInt/Baseline execution observes whether results overflow int32.
  • Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp — makeSafe gains a UInt32ToNumber case that reads the collected profile and merges NodeMayOverflowInt32InBaseline when an int32 overflow was observed or an Overflow exit site exists, grounding DFG overflow handling in profiled behavior.

Background

op_unsigned / UInt32ToNumber — op_unsigned performs an unsigned interpretation of an integer (as produced by >>> 0 and other ToUint32 sites). Its result ranges over 0..2^32-1, so values above INT32_MAX cannot be an int32 and must become a double. In the DFG the operation is the UInt32ToNumber node whose codegen must choose the right representation.

UnaryArithProfile — A per-bytecode profiling record that LLInt/Baseline update as an arithmetic op executes, capturing observed result kinds (int32, number/double, non-number) and whether int32 overflow occurred (didObserveInt32Overflow). The DFG reads these to decide type speculations and overflow handling.

NodeMayOverflowInt32InBaseline — A DFG node flag indicating that, in unoptimized (baseline) execution, this arithmetic node was seen to overflow int32. It biases the DFG/FTL toward compiling the node so the overflow case is handled correctly (e.g. producing a double) instead of speculating the value fits a signed int32.

Profiling vs OSR-exit learning — The DFG can learn either from explicit profiles collected in lower tiers or reactively from OSR exits (exit sites recorded when a speculation failed). Relying only on exit sites means the first overflow always triggers a costly deopt and the compiler starts pessimistic; explicit profiling lets the DFG make the right call on first compile.

RETURN_WITH_PROFILING — A slow-path macro that returns a value while running a supplied profiling lambda, the standard way LLInt/Baseline slow paths feed observation data (here profile.observeResult(result)) back into the bytecode’s profile for later tiers.

Vulnerability window

  1. Prior state — op_unsigned was a plain unary op with no arith profile; DFG UInt32ToNumber overflow handling depended solely on OSR-exit history at the node.
  2. Observation — JSC engineers found op_unsigned’s Overflow extension was ’largely relying on DFG OSR exit’ rather than collected LLInt/Baseline information (bug 317358 / rdar 179972217).
  3. Add profiling — op_unsigned reclassified as a ProfiledUnaryOp; emitUnaryOp allocates a UnaryArithProfile and the slow path records results via observeResult.
  4. Consume in DFG — makeSafe gains a UInt32ToNumber case that merges NodeMayOverflowInt32InBaseline from the profile or an Overflow exit site.
  5. Result — DFG/FTL speculate UInt32ToNumber overflow soundly from first compile, avoiding exit-driven learning and the mis-speculation window it implies.
  6. Landed — Committed as 315430@main on 2026-06-17, reviewed by Keith Miller.

Triggering

OBSERVED: No test or PoC is included in the patch (it is a profiling/soundness change). INFERRED trigger shape: exercise a hot function containing an unsigned conversion (e.g. ‘function f(x){ return (x >>> 0); }’) with inputs whose ToUint32 result exceeds INT32_MAX so op_unsigned observes int32 overflow, warm it to tier up to DFG/FTL, then rely on the newly-flagged NodeMayOverflowInt32InBaseline to force correct double-producing codegen. Demonstrating a security impact would require showing the pre-patch DFG mis-speculated the range in a way another node consumed unsafely, which the patch does not include.

Exploitation

  1. Reach — Get a >>> 0 (or equivalent ToUint32) site hot enough to be DFG/FTL-compiled, feeding it values above INT32_MAX so the unsigned result cannot fit a signed int32.
  2. Mis-speculation (pre-patch) — Without the profile, the DFG could compile UInt32ToNumber assuming no int32 overflow, learning otherwise only via OSR exit; a downstream node that trusted an int32 range could then be fed an out-of-range value.
  3. Honest note — This patch adds the missing profiling to make speculation sound; it does not ship a corruption PoC. Any OOB/type-confusion would live in whatever consumer trusted the un-profiled range, and is not demonstrated here. Treat as a soundness hardening rather than a proven exploit chain.

Detection & hunting

For defenders and SOC / detection engineers:

  • Overflow OSR-exit churn at UInt32ToNumber
  • Hot >>> 0 with large results
  • JIT differential testing

Audit directions

  • Other ops relying on exit-only learning
  • Consumers of UInt32ToNumber range
  • Profile plumbing correctness
  • NodeMayOverflow flag propagation

Before / after

Loading diff…