3ddc7045b7c5a9ccb6ef09124c7b8efb93527208 [JSC] DFG SwitchChar releases its operand before speculating that it is a string
Triage note: Moves speculateString before op1.use() so the operand stays live during the type check; releasing it first left the scrutinee dead/poisoned at OSR exit, a JIT liveness bug that can surface a wrong/dead value.
Contents
The bug at a glance
The bug is reachable from ordinary JavaScript: any switch whose scrutinee the DFG types as a string (SwitchChar with StringUse) that then OSR-exits on a non-string scrutinee triggers the mis-ordered death event, which the provided test reproduces with a two-line driver. The consequence is that baseline re-execution of op_switch_char receives a dead/poisoned recovery value for the live scrutinee, i.e. a value of the wrong type where a JSString is assumed, a classic type-confusion foundation. It sits at 8.1 because turning the dead-value read into a controlled primitive is non-trivial and depends on OSR-exit value scavenging, not because reachability is limited.
OSR exit reconstruction in the DFG is driven by a variable event stream in which each node has a birth and a death; a value must remain “live” at the exit point for the OSR machinery to hand baseline a correct recovery. emitSwitchChar’s StringUse case called op1.use() — which emits the operand’s death event — before speculateString(), the check that can actually take the BadType exit. So on the exit, the scrutinee’s death had already been recorded ahead of the exit point, and reconstruction concluded the value was dead. With --poisonDeadOSRExitVariables=1 you can see it plainly: baseline re-runs op_switch_char on a poisoned/garbage value. The one-line fix hoists speculateString() above op1.use(), exactly as emitSwitchString() already ordered it, so the operand stays live across the type check.
Root cause
In SpeculativeJIT::emitSwitchChar (Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp), the StringUse case obtained op1GPR = op1.gpr() and tempGPR = temp.gpr(), then called op1.use() and only afterwards speculateString(node->child1(), op1GPR) before emitSwitchCharStringJump(...). op1.use() decrements the virtual-register use count and, when it reaches zero, records the node’s death in the DFG variable event stream at that program point. speculateString() is what emits the BadType speculation check that branches to OSR exit when the scrutinee is not actually a JSString.
Because use() ran first, the death event was ordered before the exit site. When the exit is later taken, the OSR machinery walks the variable event stream up to the exit’s code origin to reconstruct the state baseline expects. Any bytecode local whose MovHint pointed at the scrutinee node — here the scrutinee temporary, whose SetLocal the DFG had already eliminated as dead within optimized code — is reconstructed from that node. Seeing the death already recorded, reconstruction hands out a dead-value recovery for that local.
The reaching path is plain JS, as in the test JSTests/stress/switch-char-scrutinee-live-at-osr-exit.js: function opt(a4) { switch (a4) { case 'a': function f() { a4 } case 'b': case 'c': } } compiled eagerly, first called with a string (opt('')) so the DFG speculates SwitchChar/StringUse, then called with a non-string (opt([])) so speculateString fails and OSR-exits. The capture of a4 by the nested function f/a4 reference keeps the local relevant to reconstruction while its SetLocal was eliminated.
This is unsafe because after the exit, baseline re-executes op_switch_char on the scrutinee local — but that local’s value came from a dead-value recovery rather than the actual live operand. Under --poisonDeadOSRExitVariables=1 the slot is filled with a poison sentinel, exposing the bug as a crash; in production the local holds a stale/garbage value that baseline then treats as the switch scrutinee. A wrong-typed value standing in for a value the code assumes is a string is the seed of type confusion.
The fix swaps the two statements so speculateString(node->child1(), op1GPR) runs before op1.use(). Now the BadType exit’s speculation is emitted while the operand is still live, so the death event follows the exit point in the stream and reconstruction correctly keeps the scrutinee live and recovers its real value — matching the ordering emitSwitchString() already uses.
Key code
DFGSpeculativeJIT.cpp: speculate the string type while the operand is still live
GPRReg op1GPR = op1.gpr();
GPRReg tempGPR = temp.gpr();
+speculateString(node->child1(), op1GPR);
op1.use();
-speculateString(node->child1(), op1GPR);
emitSwitchCharStringJump(node, data, op1GPR, tempGPR, node->child1());
noResult(node, UseChildrenCalledExplicitly);
break;
Patch walkthrough
Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp— InemitSwitchChar’s StringUse case,speculateString(node->child1(), op1GPR)is moved to beforeop1.use()instead of after. This ensures the BadType speculation check (which may OSR-exit) is emitted while the operand is still considered live, so the operand’s death event is recorded after the exit point in the variable event stream rather than before it. Reconstruction at the exit then keeps the scrutinee live and provides its true value to baseline, eliminating the dead/poisoned-value recovery. The change mirrors the ordering already correct inemitSwitchString().JSTests/stress/switch-char-scrutinee-live-at-osr-exit.js— New regression test run with--useLLInt=0 --forceEagerCompilation=1 --poisonDeadOSRExitVariables=1. It definesopt(a4)with aswitch (a4)whosecase 'a'containsfunction f() { a4 }(capturing the scrutinee so the local stays relevant), callsopt('')to make the DFG speculate string SwitchChar, thenopt([])to force the speculateString BadType exit. With poisoning enabled the pre-fix code faults on the dead scrutinee; the fixed code recovers the live value and runs cleanly.
Background
op1.use() and the variable event stream — In the DFG, use() on a JSValueOperand signals the last use of an operand, decrementing its use count and, on reaching zero, appending a death event to the variable event stream. OSR-exit reconstruction replays this stream to determine which values are live at an exit and how to recover them for baseline.
speculateString and BadType exit — speculateString(edge, gpr) emits a type check that branches to an OSR exit tagged BadType when the value is not a JSString. Whether the operand is marked used before or after this check determines the relative order of its death event and the exit point.
MovHint / SetLocal elimination and dead-value recovery — MovHint associates a DFG node with a bytecode local for OSR; the DFG may eliminate a local’s SetLocal in optimized code. At exit, if the associated node is already dead in the event stream, reconstruction supplies a dead-value recovery — which --poisonDeadOSRExitVariables=1 fills with a poison sentinel to surface such bugs.
SwitchChar StringUse — The DFG lowers op_switch_char on a string-typed scrutinee to SwitchChar with a StringUse edge, reading the single character to index the jump table. It must speculate the scrutinee is a string before dispatching, and must keep the scrutinee live across that speculation for correct OSR exit.
Vulnerability window
- Introduction —
emitSwitchChar’s StringUse case was written to callop1.use()beforespeculateString(), ordering the scrutinee’s death event ahead of the BadType exit — unlike the siblingemitSwitchString(), which speculates first. - Latent exposure — The mis-ordering only bites when a string-speculated switch-char actually OSR-exits on a non-string scrutinee and the scrutinee local is reconstructed via an eliminated SetLocal, so it stayed hidden without dead-variable poisoning.
- Discovery — Under
--poisonDeadOSRExitVariables=1the scrutinee reconstructed to a poisoned dead value, revealing the liveness miscompile (bug 322101 / rdar://183337191). - Fix — Commit 3ddc7045b7 (Tadeu Zagallo, 2026-08-19) hoisted
speculateString()aboveop1.use()and added the regression test, keeping the scrutinee live across the type check.
Proof of concept
This is the patch’s own regression test. opt('') runs the eagerly-compiled function with a string scrutinee so the DFG lowers the switch to SwitchChar with a StringUse edge; the nested function f() { a4 } captures the scrutinee local so it remains relevant to OSR reconstruction even though its SetLocal is eliminated. opt([]) then passes a non-string, so speculateString fails and OSR-exits. Pre-fix, the scrutinee’s death event preceded the exit, so reconstruction handed baseline a dead recovery for the local; with --poisonDeadOSRExitVariables=1 that dead slot is poisoned and re-executing op_switch_char on it faults, demonstrating the liveness bug. It shows the mis-recovery, not a full exploit primitive.
//@ requireOptions("--useLLInt=0", "--forceEagerCompilation=1", "--poisonDeadOSRExitVariables=1")
function opt(a4) {
switch (a4) {
case 'a':
function f() { a4 }
case 'b':
case 'c':
}
}
opt('');
opt([]);
Exploitation
- Shape the DFG speculation — Call a switch-on-char function repeatedly with strings so the DFG compiles SwitchChar/StringUse and eliminates the scrutinee’s SetLocal, with a closure capturing the scrutinee so the local participates in OSR reconstruction.
- Force the BadType exit — Invoke the function with a non-string scrutinee so speculateString fails and OSR exit runs; because the death event precedes the exit, baseline receives a dead-value recovery for the scrutinee local.
- Leverage the wrong-typed value — Baseline re-executes op_switch_char treating a stale/garbage local as the scrutinee where a JSString is assumed; converting this type confusion into a controlled read/write would require steering what stale value occupies the slot, which is not demonstrated by the test and is not reliable from the patch alone.
Detection & hunting
For defenders and SOC / detection engineers:
- Poisoned-variable crashes at switch-char OSR exit — Running with
--poisonDeadOSRExitVariables=1and observing faults when a string-speculated switch OSR-exits on a non-string scrutinee is the direct signal this bug produces. - use()-before-speculate ordering — Review DFG lowerings for any
op1.use()(or edge use) emitted before aspeculateX()that can BadType-exit on the same operand; that ordering places the death event ahead of the exit and is the fingerprint here. - Baseline re-execution on stale scrutinee — Instrument OSR exit to flag recoveries where a live bytecode local resolves to a dead-value recovery for the node being switched on.
Audit directions
- All emitSwitch and speculate-then-use lowerings* — Audit every SpeculativeJIT case that both speculates a type and calls use() on the same operand to confirm speculation precedes use(), matching emitSwitchString()’s correct ordering.
- Operands live across BadType exits — Sweep for operands whose death event could precede a speculation exit, particularly where SetLocal elimination plus a MovHint make a local depend on the operand node for reconstruction.
- Dead-value recovery paths — Review OSR-exit reconstruction to ensure a value needed live at baseline can never resolve to a dead recovery, and expand poison-dead-variable testing across switch and other speculation-heavy nodes.