[1] Promise-pair bindings return a raw exception cell to script
Omit one argument and the promise-pair bindings hand you an exception cell.
High. The value that escapes is not a corrupted object but a cell that was never an object at all, and the first property store on it hands the engine a butterfly pointer where the cell's own first field lives. Trigger is a plain API call with a required argument omitted — no heap grooming needed to reach the primitive.
WebKit's IDL bindings layer is machine-generated glue: a code generator emits, per Web API operation, a C++ function that converts JS arguments, calls WebCore, and converts the result back. Newer operations declared [ReturnsPromisePair] return a dictionary carrying two promises — NavigationResult { committed, finished } is the shipping example — and route through a shared helper, callPromisePairFunction, which runs the generated body and decides what script gets back. Its contract: a failure inside that body is routed into a promise rejection, never returned as a value.
The angle: calling a [ReturnsPromisePair] operation with a required argument omitted hands script the engine's internal exception cell as an ordinary value, and the first property store on it writes a butterfly pointer over that cell's first field.
Patch Details
callPromisePairFunction previously decided that the functor had failed by testing the returned EncodedJSValue for emptiness — !JSC::JSValue::decode(result) — and did so only after calling rejectPromisesWithExceptionIfAny. The patch introduces an explicit functorThrew signal captured off the catch scope immediately after the generated body returns, hoisted above the rejection helper so the pending exception is still on the scope when it is read, and widens the rebuild condition from !JSC::JSValue::decode(result) alone to functorThrew || !JSC::JSValue::decode(result), so the empty-sentinel case is still caught and the thrown-exception case is caught alongside it. The rebuild branch replaces the result with a proper convertDictionaryToJS NavigationResult built from the two now-rejected promises.
Two distinct failure signals collapsed into one sentinel test, evaluated after the only path that could have distinguished them had already been cleared.
Background
Where this lives. CodeGeneratorJS.pm emits the per-operation binding bodies; JSDOMPromiseDeferred.h holds the shared helpers those bodies call into. callPromisePairFunction is the one used by operations that return two promises at once.
Encoded values and the empty sentinel. JSC passes values across the C++/JS boundary as EncodedJSValue, a machine word. The all-zero-bits encoding is the empty value sentinel — a distinguished non-value used internally to mean "no result", and never a legal script-visible value. IDL argument conversion failures propagate out of generated bindings as JSC::encodedJSValue(), the empty encoding.
throwVMError. The other failure form emitted by the generator is the missing-mandatory-argument check, which does return throwVMError(...). throwVMError throws onto the current scope and returns the encoded JSC::Exception* cell itself, which is a pointer-shaped, non-zero EncodedJSValue.
Cells versus objects. Exception.h declares class Exception final : public JSCell — not JSObject — and Exception::createStructure builds it with TypeInfo(CellType, StructureFlags). CellType is the lowest JSType tag, well below ObjectType. JS-visible operations on a value that isCell() and is not a string, symbol, or bigint route through the object path.
Butterflies. A JSObject's out-of-line properties and indexed storage live in a separately allocated Butterfly, whose pointer is stored in a fixed slot in the object header. Exception's member layout puts WriteBarrier<Unknown> m_value; first after the JSCell header, exposed via valueOffset().
ASSERT_WITH_SECURITY_IMPLICATION. A debug-only assertion macro; it compiles away entirely in release builds, so downcasts guarded only by it are unchecked in shipping code.
Analysis
The bug is a type confusion in which an internal GC cell escapes into script-visible JSValue space. Before the fix, callPromisePairFunction treated "the functor failed" and "the functor returned the empty sentinel" as the same condition — an equivalence that holds for exactly one of the two failure modes the generated bindings can produce.
Argument-conversion failure Missing-argument failure
--------------------------- ------------------------
return encodedJSValue() return throwVMError(...)
| (zero bits) | (Exception* cell)
v v
rejectPromisesWithExceptionIfAny ---> clears pending exception
| |
v v
!decode(result) == true !decode(result) == false
| |
v v
return empty (safe) return Exception cell to JS
Both columns pass through the same rejection helper first, and that helper drains the exception off catchScope. By the time control reaches the sentinel test in the right-hand column, there is no exception left to observe and the only remaining signal — the return value — is a live, non-empty cell. The guard evaluated false, and the raw JSC::Exception* was returned to the JS caller as though it were the NavigationResult dictionary.
What script receives is a cell whose JSType is CellType. The commit message states that a property store on that escaped cell reaches JSCell::putInline, where overridesPut() is false and the receiver is funneled through asObject(this) / jsCast<JSObject*>; on release builds the downcast is unchecked because its guard compiles away. The store then operates on the Exception allocation as if it had JSObject layout: it allocates a Butterfly and writes the butterfly pointer into the object's butterfly slot, which per Exception.h's member layout overlaps m_value.
The consequence lands on the next collection. Exception::visitChildrenImpl in Exception.cpp does visitor.append(thisObject->m_value) — so the collector marks a raw Butterfly pointer as if it were a JSCell, dereferencing whatever the first word of the butterfly decodes to as a StructureID. The attacker controls butterfly contents through ordinary property stores, so the value the GC interprets as a structure identifier is script-influenced.
This vulnerability weakens the boundary that keeps JSC's internal cell types out of script's reach: a value that the type system guarantees is never observable from JavaScript becomes an ordinary operand, and the object-shaped operations applied to it corrupt a GC-traced field.
Audit directions
- Sentinel values standing in for multi-mode failure. A single emptiness test cannot distinguish "failed" from "failed in the one way that happens to return zero bits" when the callee has more than one failure form. Search
JSDOMPromiseDeferred.hand theJSDOMConvert*headers for!JSC::JSValue::decode(...)used as a failure predicate, and cross-check each against the generator paths that can reach it. In code review, a!decode(x)emptiness test downstream of any call site that can alsoreturn throwVMError(...)deserves a comment explaining which failure forms it covers. - Exception-draining helpers that run before the failure test. Any helper that clears a pending exception off a
CatchScopedestroys the caller's ability to observe it afterwards. Audit the ordering wherever a rejection/reporting helper and a failure check sit in the same function, and treat "check first, then drain" as the required order. - Non-
JSObjectcells reachable throughEncodedJSValuereturns.JSCellsubclasses constructed withTypeInfo(CellType, ...)are one bad guard away from the object path. Enumerate the functions that can return such a cell encoded as a value, and confirm each caller distinguishes them structurally rather than by bit pattern.