CVE-2025-43211
Overview
Background
- DFG JIT
- JavaScriptCore’s optimizing ‘Data Flow Graph’ compiler that speculates on value types and deoptimizes (OSR exit) if speculations fail.
- Fixup phase
- A DFG pass that assigns edge useKinds and inserts type-check/conversion nodes so later phases and codegen can assume specific representations.
- useKind / edge
- A DFG edge annotation (e.g. StringUse, UntypedUse, CellUse) declaring what type/representation a node’s input is guaranteed to have at that point.
- Manual vs Automatic OperandSpeculation
- A codegen flag choosing whether the speculation guard for an operand is emitted here (automatic) or was already emitted upstream so must not be repeated (manual).
- SpeculateCellOperand / speculateString
- Codegen helpers that load a value into a register as a cell and emit an inline type check that it is a JSString, OSR-exiting otherwise.
Root Cause Analysis
The bug is in the DFG JIT’s handling of String.prototype.replace / replaceAll, split across the Fixup phase (Source/JavaScriptCore/dfg/DFGFixupPhase.cpp) and codegen (Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp::compileStringReplace). For the StringReplace/StringReplaceAll node, child2 is the search argument. Before the patch, when child2 shouldSpeculateString(), the Fixup phase inserted an explicit Check(@child2, StringUse) node AND called fixEdge<StringUse>(child2), setting the edge’s useKind to StringUse. Codegen (compileStringReplace) then keyed off node->child2().useKind() == StringUse only to pick ManualOperandSpeculation (meaning ’the speculation guard was already emitted elsewhere, don’t re-emit it here’) and loaded child2 with a plain JSValueOperand as if it were a boxed JSValue. The invariant that broke is the contract between the Fixup phase and codegen about who guarantees, and how, that a StringUse edge actually holds a JSString cell in a cell-shaped register at the call site: the ManualOperandSpeculation path assumed the separately-inserted Check fully pinned the value, but the operand was still materialized as a generic JSValue rather than as a speculated cell, and the interaction with the else-if control flow (StringUse and the StringReplace-primordial/ForceOSRExit branch were mutually exclusive via ’else if’) left the guarding inconsistent. This mismatch let a non-guaranteed value flow into operationStringProtoFuncReplaceGeneric under DFG validation (the test runs with –validateBCE=true), producing an assertion/DFG_CRASH — a reliable controlled crash / DoS.
The fix, on the Fixup side, drops the manually inserted Check node, keeps fixEdge<StringUse>(child2), and adds an explicit break so the StringUse case no longer shares control flow with the StringReplace/ForceOSRExit branch. On the codegen side it rewrites compileStringReplace as a switch on child2’s useKind: for StringUse it loads child2 via SpeculateCellOperand and calls speculateString(child2, searchGPR) to emit the type guard right there, passing the search as a CellValue; for UntypedUse it keeps the plain JSValueOperand generic path; and default now hits DFG_CRASH(‘Bad UseKind’). This restores a single, explicit speculation site and makes the operand shape (cell vs JSValue) match the useKind.
Attack Path
- Reach the StringReplace DFG node From JavaScript, call String.prototype.replace with a search argument the DFG can speculate as a String — the reproducer does name.proto.replace(name, name) where name is f.name (a string).
- Warm up to the DFG tier Run the call in a tight loop (the test uses an infinite for(;;) plus low JIT thresholds via runDefault) so the function tiers up and the StringReplace node is compiled by the DFG SpeculativeJIT.
- Hit the mismatched StringUse path With child2 speculated String, the pre-patch Fixup inserts a Check and marks StringUse while codegen uses ManualOperandSpeculation on a generic JSValueOperand, leaving the search operand’s guarding inconsistent.
- Trip validation / speculation Under DFG validation (–validateBCE=true) or the speculation machinery the inconsistent guard is detected, driving an assertion / DFG_CRASH.
- Crash the WebContent process The abort terminates the renderer, yielding the denial-of-service described by the CVE.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifSource/JavaScriptCore/dfg/DFGFixupPhase.cpp |
modified | |
forJSTests/stress/string-replace-speculate-string.js |
modified |
Files Changed
JSTests/stress/string-replace-speculate-string.jsSource/JavaScriptCore/dfg/DFGFixupPhase.cppSource/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
Audit Directions
- Other ManualOperandSpeculation call sitesGrep DFGSpeculativeJIT.cpp for ‘ManualOperandSpeculation’ and confirm each is paired with an upstream Check/fixEdge that truly pins the operand’s representation; the removed Check here shows how fragile that pairing is.
- Fixup Check-node + fixEdge combinationsIn DFGFixupPhase.cpp grep for insertNode(…Check…) immediately followed by fixEdge<…>() on the same edge; each redundant/overlapping guard is a candidate for the same StringUse-vs-JSValue shape mismatch, especially where control flow uses ’else if'.
- Other string-intrinsic compilersAudit compileStringReplaceString, compileStringSlice, compileStringIndexOf and similar for whether their operands are loaded with SpeculateCellOperand/speculateString matching the edge useKind, or with a generic JSValueOperand that assumes an external guard.
- Missing default/Bad-UseKind guardsSearch DFG codegen ‘compile*’ functions that switch on useKind but lack a DFG_CRASH(‘Bad UseKind’) default; the added default here is what turns an unexpected useKind into a defined crash instead of undefined behavior.
Patch
diff --git a/Source/JavaScriptCore/dfg/DFGFixupPhase.cpp b/Source/JavaScriptCore/dfg/DFGFixupPhase.cpp
index c038929c6190..b79cdf2c5a77 100644
--- a/Source/JavaScriptCore/dfg/DFGFixupPhase.cpp
+++ b/Source/JavaScriptCore/dfg/DFGFixupPhase.cpp
@@ -1756,16 +1756,15 @@ private:
}
if (node->child2()->shouldSpeculateString()) {
- m_insertionSet.insertNode(
- m_indexInBlock, SpecNone, Check, node->origin,
- Edge(node->child2().node(), StringUse));
fixEdge<StringUse>(node->child2());
- } else if (op == StringReplace || op == StringReplaceAll) {
+ break;
+ }
+
+ if (op == StringReplace || op == StringReplaceAll) {
if (node->child2()->shouldSpeculateRegExpObject() && m_graph.isWatchingRegExpPrimordialPropertiesWatchpoint(node))
addStringReplacePrimordialChecks(node->child2().node());
else
- m_insertionSet.insertNode(
- m_indexInBlock, SpecNone, ForceOSRExit, node->origin);
+ m_insertionSet.insertNode(m_indexInBlock, SpecNone, ForceOSRExit, node->origin);
}
if (node->child1()->shouldSpeculateString()
diff --git a/Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp b/Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
index 66c0c10b2a18..ffebf47f729e 100644
--- a/Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
+++ b/Source/JavaScriptCore/dfg/DFGSpeculativeJIT.cpp
@@ -13455,22 +13455,41 @@ void SpeculativeJIT::compileStringReplace(Node* node)
return;
}
- // If we fixed up the edge of child2, we inserted a Check(@child2, String).
- OperandSpeculationMode child2SpeculationMode = AutomaticOperandSpeculation;
- if (node->child2().useKind() == StringUse)
- child2SpeculationMode = ManualOperandSpeculation;
+ switch (node->child2().useKind()) {
+ case StringUse: {
+ JSValueOperand string(this, node->child1());
+ SpeculateCellOperand search(this, node->child2());
+ JSValueOperand replace(this, node->child3());
+ JSValueRegs stringRegs = string.jsValueRegs();
+ GPRReg searchGPR = search.gpr();
+ JSValueRegs replaceRegs = replace.jsValueRegs();
- JSValueOperand string(this, node->child1());
- JSValueOperand search(this, node->child2(), child2SpeculationMode);
- JSValueOperand replace(this, node->child3());
- JSValueRegs stringRegs = string.jsValueRegs();
- JSValueRegs searchRegs = search.jsValueRegs();
- JSValueRegs replaceRegs = replace.jsValueRegs();
+ speculateString(node->child2(), searchGPR);
- flushRegisters();
- GPRFlushedCallResult result(this);
- callOperation(node->op() == StringReplaceAll ? operationStringProtoFuncReplaceAllGeneric : operationStringProtoFuncReplaceGeneric, result.gpr(), LinkableConstant::globalObject(*this, node), stringRegs, searchRegs, replaceRegs);
- cellResult(result.gpr(), node);
+ flushRegisters();
+ GPRFlushedCallResult result(this);
+ callOperation(node->op() == StringReplaceAll ? operationStringProtoFuncReplaceAllGeneric : operationStringProtoFuncReplaceGeneric, result.gpr(), LinkableConstant::globalObject(*this, node), stringRegs, CellValue(searchGPR), replaceRegs);
+ cellResult(result.gpr(), node);
+ break;
+ }
+ case UntypedUse: {
+ JSValueOperand string(this, node->child1());
+ JSValueOperand search(this, node->child2());
+ JSValueOperand replace(this, node->child3());
+ JSValueRegs stringRegs = string.jsValueRegs();
+ JSValueRegs searchRegs = search.jsValueRegs();
+ JSValueRegs replaceRegs = replace.jsValueRegs();
+
+ flushRegisters();
+ GPRFlushedCallResult result(this);
+ callOperation(node->op() == StringReplaceAll ? operationStringProtoFuncReplaceAllGeneric : operationStringProtoFuncReplaceGeneric, result.gpr(), LinkableConstant::globalObject(*this, node), stringRegs, searchRegs, replaceRegs);
+ cellResult(result.gpr(), node);
+ break;
+ }
+ default:
+ DFG_CRASH(m_graph, node, "Bad UseKind");
+ break;
+ }
}
void SpeculativeJIT::compileStringReplaceString(Node* node)
diff --git a/JSTests/stress/string-replace-speculate-string.js b/JSTests/stress/string-replace-speculate-string.js
new file mode 100644
index 000000000000..675232dcd256
--- /dev/null
+++ b/JSTests/stress/string-replace-speculate-string.js
@@ -0,0 +1,15 @@
+//@ runDefault("--thresholdForJITSoon=10", "--thresholdForJITAfterWarmUp=10", "--thresholdForOptimizeAfterWarmUp=100", "--thresholdForOptimizeAfterLongWarmUp=100", "--thresholdForOptimizeSoon=100", "--thresholdForFTLOptimizeAfterWarmUp=1000", "--thresholdForFTLOptimizeSoon=1000", "--validateBCE=true", "--useConcurrentJIT=0", "--watchdog=500", "--watchdog-exception-ok")
+
+for (;;) {
+ (() => {
+ function f() {
+ return undefined;
+ }
+ try { f(); } catch (e) {}
+ const name = f.name;
+ try { name.isWellFormed(); } catch (e) {}
+ name.__proto__.replace(name, name);
+ for (let i = 0; i < 100; i++) {
+ }
+ })()
+}
Regression Test / PoC
diff --git a/JSTests/stress/string-replace-speculate-string.js b/JSTests/stress/string-replace-speculate-string.js
new file mode 100644
index 000000000000..675232dcd256
--- /dev/null
+++ b/JSTests/stress/string-replace-speculate-string.js
@@ -0,0 +1,15 @@
+//@ runDefault("--thresholdForJITSoon=10", "--thresholdForJITAfterWarmUp=10", "--thresholdForOptimizeAfterWarmUp=100", "--thresholdForOptimizeAfterLongWarmUp=100", "--thresholdForOptimizeSoon=100", "--thresholdForFTLOptimizeAfterWarmUp=1000", "--thresholdForFTLOptimizeSoon=1000", "--validateBCE=true", "--useConcurrentJIT=0", "--watchdog=500", "--watchdog-exception-ok")
+
+for (;;) {
+ (() => {
+ function f() {
+ return undefined;
+ }
+ try { f(); } catch (e) {}
+ const name = f.name;
+ try { name.isWellFormed(); } catch (e) {}
+ name.__proto__.replace(name, name);
+ for (let i = 0; i < 100; i++) {
+ }
+ })()
+}