953bc0380f [JSC] Add missing ExitOK after constant folding `then`
Triage note: A missing ExitOK marker after node removal is a DFG exit-soundness bug potentially leading to invalid OSR exits.
Contents
The bug at a glance
This is a DFG JIT soundness bug: a graph transformation (constant-folding PerformPromiseThen into PerformPromiseThenOneHandler) inserted a replacement node without the ExitOK marker that records that OSR exit is legal at that point, leaving the exit state invalid. Missing ExitOK/exit-state invariants are exactly the class of miscompilation that becomes exploitable type confusion when an OSR exit fires with inconsistent state. In this instance it only tripped graph validation (a debug/assertion-level failure) with the note that no value fix-up was needed, which caps its practical severity below the worst OSR-exit corruption bugs but keeps it high as a JIT-correctness defect.
A prior change (313277@main) added constant folding for an initial Promise.prototype.then, rewriting PerformPromiseThen to PerformPromiseThenOneHandler, but forgot to emit an ExitOK node after the folded-in node. ExitOK marks that the abstract program point has valid OSR-exit state; without it the DFG’s validation catches an exit-state inconsistency. The one-line fix inserts the missing ExitOK immediately before removing the old node.
Root cause
In the DFG, every point where the optimizing JIT might bail back to the baseline interpreter (an OSR exit) must have a well-defined, consistent snapshot of abstract program state — which locals/temporaries are live and what their formats are. The compiler tracks, per node, whether it is valid to exit at that node; the ExitOK flag / ExitOK node records that the machine is in a consistent state such that an exit here is sound. DFG validation enforces that nodes which can exit are preceded by a point where ExitOK holds; a node inserted into the graph that changes state without re-establishing ExitOK breaks this invariant.
Commit 313277@main added constant folding for an initial then: when the DFG can prove the shape of a Promise.prototype.then call, ConstantFoldingPhase::foldConstants rewrites the generic PerformPromiseThen into the specialized PerformPromiseThenOneHandler, constructed here with three KnownCellUse edges — Edge(inputPromise.node(), KnownCellUse), Edge(handlerEdge.node(), KnownCellUse), Edge(resultPromise.node(), KnownCellUse) — via m_insertionSet.insertNode(…). The old node is then removed with node->remove(m_graph) and changed = true records that the graph mutated. The defect: after inserting the new PerformPromiseThenOneHandler the phase did not insert an ExitOK node, so the exit-state bookkeeping at that index was left invalid. When DFG validation ran, it observed a node/exit-state inconsistency and tripped.
The fix is a single inserted statement: m_insertionSet.insertNode(indexInBlock, SpecNone, ExitOK, node->origin);, placed after the PerformPromiseThenOneHandler insertion and before node->remove(m_graph). It emits an ExitOK node at the same indexInBlock with SpecNone result type and the folded node’s origin, re-establishing that OSR exit is legal at this program point. The commit is explicit that no value fix-up was required for the folding itself — the operands and semantics of PerformPromiseThenOneHandler were correct — the sole omission was the missing ExitOK marker that validation depends on.
Because the bug is a missing exit-legality marker rather than a wrong-typed operand, its observable consequence in the shipped commit is a validation failure; the danger of the class is that, absent validation, an OSR exit taken at a program point with stale/undefined exit state can reconstruct the baseline frame with wrong values, feeding type confusion. Here the maintainers assert the state was actually consistent (no fix-up needed) and only the annotation was missing, so this instance is a soundness/annotation defect corrected before it could miscompile in release.
Key code
DFGConstantFoldingPhase.cpp — insert the missing ExitOK after folding then
Edge(inputPromise.node(), KnownCellUse),
Edge(handlerEdge.node(), KnownCellUse),
Edge(resultPromise.node(), KnownCellUse));
m_insertionSet.insertNode(indexInBlock, SpecNone, ExitOK, node->origin);
node->remove(m_graph);
changed = true;
Patch walkthrough
Source/JavaScriptCore/dfg/DFGConstantFoldingPhase.cpp— Inside ConstantFoldingPhase::foldConstants, in the branch that folds PerformPromiseThen into PerformPromiseThenOneHandler, a new linem_insertionSet.insertNode(indexInBlock, SpecNone, ExitOK, node->origin);is added immediately after the PerformPromiseThenOneHandler node is inserted (with its three KnownCellUse edges) and before node->remove(m_graph). This re-establishes valid OSR-exit state at the fold point, satisfying DFG validation.JSTests/stress/dfg-perform-promise-then-one-handler-exit-ok.js— Added stress test: opt() calls Promise.resolve(-0).then(a3) inside a with() block, wrapped in try/catch and nested arrow/Proxy/async constructs to force the DFG to compile and constant-fold the initial then; it is run for testLoopCount iterations to trigger DFG tiering. On an unpatched build with validation enabled this hits the exit-state assertion.
Background
DFG OSR exit — The DFG optimizing JIT speculates on types and structures; when a speculation fails it performs an OSR (on-stack replacement) exit, transferring execution back to the baseline JIT/interpreter. This requires reconstructing the baseline stack frame from the DFG’s recorded exit state, so that state must be exact at every exitable point.
ExitOK and exit-state validity — ExitOK is a DFG marker indicating the abstract interpreter’s state is consistent and it is legal to OSR-exit at that point. The graph carries the notion of whether exiting is currently OK; nodes that establish a fresh valid state emit ExitOK. Validation asserts exitable nodes are covered by a valid ExitOK, catching phases that mutate the graph without re-establishing it.
ConstantFoldingPhase — A DFG optimization phase that, using abstract-interpreter results, replaces nodes with cheaper specialized forms or constants. It uses an InsertionSet (m_insertionSet) to splice new nodes at a given indexInBlock and node->remove() to delete the folded-away node; any inserted node must maintain graph invariants including exit-state annotations.
PerformPromiseThen vs PerformPromiseThenOneHandler — PerformPromiseThen is the generic DFG intrinsic modeling Promise.prototype.then; when the DFG proves the call shape (a single handler, known cells), it can specialize to PerformPromiseThenOneHandler, built here with KnownCellUse edges for the input promise, handler, and result promise. The specialization is semantically equivalent but omitted the ExitOK annotation on insertion.
KnownCellUse edges — An edge use-kind asserting the operand is already known to be a JSCell, so no type check (and thus no exit) is needed on that edge. The three KnownCellUse edges on PerformPromiseThenOneHandler encode that the promise/handler/result are proven cells — consistent with the maintainers’ note that no value fix-up was needed, only the ExitOK marker.
Vulnerability window
- Prior feature — 313277@main adds DFG constant folding for an initial then, rewriting PerformPromiseThen to PerformPromiseThenOneHandler in ConstantFoldingPhase.
- Omission — The folding inserts the specialized node and removes the original but does not emit an ExitOK node, leaving exit-state bookkeeping invalid at the fold point.
- Trigger — A function that calls Promise.resolve(x).then(fn) in a shape the DFG folds is compiled hot enough to reach ConstantFoldingPhase.
- Validation trip — DFG graph validation detects the exit-state inconsistency at the folded node and asserts.
- Fix — A single m_insertionSet.insertNode(…, ExitOK, node->origin) restores valid exit state; the accompanying stress test guards the fold path.
Proof of concept
Verbatim added stress test dfg-perform-promise-then-one-handler-exit-ok.js. It repeatedly calls opt(), which invokes Promise.resolve(-0).then(a3) inside a with() block, driving the DFG to compile and constant-fold the initial then into PerformPromiseThenOneHandler. On an unpatched validating build this trips the missing-ExitOK exit-state assertion; on a patched build it runs cleanly.
function opt(a1, a2) {
try {
} catch (x) {
};
function a3(a, i) {
};
with ((Promise.resolve(-0).then(a3))) {
byteLength = (({flags: a5, source: a6}) => (({valueOf: a7, throw: a8}) => (new Proxy(Infinity, ((async () => await 9.431092e-317))))))
};
}
for (let i = 0; i < testLoopCount; i++) {
try {
opt((Float32Array.prototype.BYTES_PER_ELEMENT), 2.3023e-320);
} catch (e) {
}
}
Exploitation
- Trigger the fold — Get a function containing a foldable initial then to tier up into the DFG so ConstantFoldingPhase specializes PerformPromiseThen to the one-handler form.
- Theoretical exit corruption — The dangerous form of a missing-ExitOK bug is an OSR exit taken at the annotation-invalid point reconstructing the baseline frame with stale values, yielding type confusion. That is the general risk class, not what this specific instance does.
- Reality check — The commit states no value fix-up was needed — the actual exit state was consistent and only the ExitOK marker was missing — so this manifests as a validation assertion rather than a release miscompile. No memory-corruption primitive is present; treat as a JIT-soundness hardening fix.
Detection & hunting
For defenders and SOC / detection engineers:
- DFG validation assertion —
- Workload heuristic —
- Version gating —
Audit directions
- InsertionSet folds without ExitOK —
- Recently added intrinsic foldings —
- Validation coverage —
- Exit-state after remove() —