1f4e4caab0 [JSC] StringAt should respect arrayMode in CSE
Triage note: String.prototype.at returns undefined OOB unlike charAt; adding arrayMode to the pure value prevents an unsound CSE miscompile.
Contents
The bug at a glance
A DFG JIT soundness bug that produces a type-confused value: two String.prototype.at accesses that should return different types (a JSString when in-bounds vs. the undefined value when out-of-bounds) can be collapsed into one by pure common-subexpression elimination because the cache key ignored arrayMode. Miscompiles that hand JavaScript a value of the wrong type are the classic root of JSC exploit chains, which argues for high severity; it is scored medium because triggering it requires a specific same-receiver/same-index pattern whose two occurrences are speculated into different array modes, and the immediately observable divergence is undefined-vs-string rather than an obviously controllable object pointer. Confidence 0.65: the fix and its own comment state the invariant plainly, but the exact downstream misuse is inferred.
DFG pure CSE keys a StringAt node only on its opcode and children, so it considers two s.at(i) nodes identical even when one was speculated with an in-bounds array mode and the other with a mode that admits out-of-bounds. String.prototype.at returns a string in bounds and undefined out of bounds, so merging the two nodes lets an OOB access reuse the in-bounds result’s type, unlike charAt which is always a string and therefore safe to merge.
Root cause
In the DFG, array accesses carry an ArrayMode that records what the speculation/profiling decided about the receiver and index – for a string indexed access this encodes assumptions such as the string’s storage and whether the index is expected to be within bounds. StringAt is the node that lowers String.prototype.at. The abstract interpreter and the backend generate different code and, crucially, a different result type depending on that ArrayMode: an in-bounds StringAt yields a JSString cell, while an out-of-bounds StringAt yields the JavaScript undefined value. This is the semantic difference from String.prototype.charAt (node StringCharAt), which returns the empty string for OOB and thus is always typed as a string regardless of mode.
Pure CSE in JSC works by having each node declare, in clobberize(), a PureValue that acts as a hash/equality key: two nodes with equal PureValue and no intervening clobber are treated as the same computation, and the later one is replaced by the earlier one’s result. Before this patch the StringAt case fell through into the StringCharAt case and called def(PureValue(node)), which keys only on the node’s opcode and its child edges (the string and the index). arrayMode() was not part of the key.
The consequence is that two StringAt nodes over the same string and same index but with different ArrayModes – for instance one compiled for a receiver/index seen to be in-bounds and one that must handle the OOB case – compare equal and are CSE’d together. The surviving computation carries one mode’s result semantics, so a site that should have produced undefined can instead be given the sibling’s string-typed result (or vice versa). Downstream nodes that were type-checked against the CSE survivor’s inferred type then operate on a value of the wrong type, which is a type confusion introduced purely by the optimizer.
The fix makes the StringAt case stop falling through and instead call def(PureValue(node, node->arrayMode().asWord())), folding the array mode word into the pure value key. Now StringAt nodes are only merged when their ArrayMode matches, so an in-bounds variant and an OOB variant are never coalesced, preserving the distinct undefined-vs-string result types.
Key code
DFGClobberize.h: StringAt gains its own PureValue keyed on arrayMode
case StringAt:
// String.prototype.at returns a string when in bounds and undefined when OOB. This is
// unlike charAt, which always returns a string. Include arrayMode to prevent CSE across
// modes.
def(PureValue(node, node->arrayMode().asWord()));
return;
case StringCharAt:
def(PureValue(node));
return;
Patch walkthrough
Source/JavaScriptCore/dfg/DFGClobberize.h— The StringAt label previously fell straight through to the StringCharAt label, sharing its def(PureValue(node)) and return. The patch gives StringAt its own body: a comment explaining that at() returns a string in-bounds and undefined OOB (unlike charAt), followed by def(PureValue(node, node->arrayMode().asWord())) and an explicit return. Adding arrayMode().asWord() as a second PureValue operand makes it part of the CSE equality key, so StringAt nodes with different array modes are no longer considered the same pure value and cannot be merged.
Background
Pure CSE / PureValue — JSC’s common-subexpression elimination replaces later nodes that recompute an already-available pure value. clobberize() declares each node’s PureValue via def(); the PureValue is the equality/hash key, built from the opcode, child edges, and any extra words passed in. Two nodes with equal PureValue and no intervening clobbering write are merged.
ArrayMode — A DFG abstraction attached to array/string indexed-access nodes that encodes speculation about storage type and, importantly, whether the index is expected in-bounds or may be out-of-bounds. It selects both the generated code and the result’s inferred type.
StringAt vs StringCharAt — StringAt lowers String.prototype.at, which returns undefined for out-of-bounds indices; StringCharAt lowers String.prototype.charAt, which returns the empty string for OOB. Because at() can return two different types (string or undefined) while charAt() always returns a string, at()’s CSE key must distinguish in-bounds from OOB modes.
Type confusion via miscompilation — When an optimizer merges computations that can produce different types, code specialized for one type can execute against the other. In JSC these optimizer-introduced type confusions are a well-trodden path to reading/writing memory with wrong assumptions about a value’s shape.
Vulnerability window
- Feature — String.prototype.at is lowered in the DFG as StringAt, distinct from StringCharAt, with OOB returning undefined instead of the empty string.
- Latent defect — clobberize() lets the StringAt case fall through to StringCharAt’s def(PureValue(node)), so the CSE key omits arrayMode.
- Trigger condition — A function performs two at() accesses on the same string and index that get compiled with different ArrayModes (one in-bounds, one OOB-capable), so CSE coalesces them.
- Report — Tracked as webkit.org/b/311976, rdar://174422482; reviewed by Yusuke Suzuki.
- Fix — StringAt gets its own def(PureValue(node, node->arrayMode().asWord())), adding the array mode to the CSE key so cross-mode merges cannot happen (canonical 314148@main).
Proof of concept
Added as JSTests/stress/string-at-cse-array-mode.js. opt() performs two identical s.at(i) accesses so CSE can merge them. The first loop runs 20000 in-bounds calls (index 1 into “hello”) at aggressive JIT policy (–jitPolicyScale=0.1) to force FTL/DFG compilation with an in-bounds array mode; the second loop then calls with index 100, which is out-of-bounds and must return undefined. The template literal ${a}_${b}_end observes both results; if the two StringAt nodes were CSE’d across modes, b would take a’s string-typed result instead of undefined, exposing the miscompile.
//@ runDefault("--jitPolicyScale=0.1")
function opt(s, i) {
let a = s.at(i);
let b = s.at(i);
return `${a}_${b}_end`;
}
noInline(opt);
for (let j = 0; j < 20000; j++) opt("hello", 1);
for (let j = 0; j < 200; j++) opt("hello", 100);
Exploitation
- Shape the compile — Warm a function with in-bounds at() accesses so the DFG/FTL speculates a String in-bounds ArrayMode for the shared StringAt, then reach the sibling access that must handle OOB.
- Coalesce — With arrayMode absent from the PureValue key, pure CSE replaces the OOB StringAt with the in-bounds one, so the OOB site inherits string-typed semantics where undefined was required.
- Consume the wrong type — Downstream nodes typed against the surviving value operate on a string when undefined was expected (or vice versa), an optimizer-introduced type confusion. Building this into a memory primitive requires arranging follow-on operations that trust the miscompiled type; INFERRED, not shown by the test.
Detection & hunting
For defenders and SOC / detection engineers:
- CSE of indexed string accesses —
- undefined/string divergence under JIT —
Audit directions
- *Other At/index nodes in clobberize() —
- PureValue extra-operand usage —
- at() semantics elsewhere —