[1] Type confusion via raw Exception cell returned to script from callPromisePairFunction
navigation.navigate() with no arguments hands JavaScript a raw Exception cell
Rated High because the diff shows a raw JSC::Exception cell (JSType CellType, not JSObject) reaching script through callPromisePairFunction, and a subsequent property store runs an unchecked jsCast<JSObject*> in production that yields a controlled write over Exception::m_value; escalation beyond a controlled GC crash requires heap grooming the diff does not establish.
callPromisePairFunction's sentinel check !JSValue::decode(result) only catches the zero empty-value sentinel returned by IDL argument-conversion failures. When a [ReturnsPromisePair] operation is called with fewer than the mandatory number of arguments, the generated bindings hit the missing-argument check emitted by CodeGeneratorJS.pm and return throwVMError(...), which encodes a non-zero JSC::Exception* cell rather than the empty sentinel. After rejectPromisesWithExceptionIfAny clears the pending exception, the sentinel check evaluates false and the raw Exception cell is returned to JavaScript. This affects all [ReturnsPromisePair] IDL operations with mandatory arguments, notably Navigation.navigate() / reload() / traverseTo() / back() / forward(). The fix captures catchScope.exception() before rejectPromisesWithExceptionIfAny clears it, and always rebuilds a valid result dictionary via convertDictionaryToJS when the functor threw.
Source/WebCore/bindings/js/JSDOMPromiseDeferred.h
LayoutTests/navigation-api/navigation-navigate-no-arguments-crash.html
Patch Details
The patch modifies callPromisePairFunction. Before the fix, the decision to rebuild the result dictionary was made solely on !JSC::JSValue::decode(result) — whether the functor returned the empty-value sentinel. The patch adds bool functorThrew = !!catchScope.exception(); immediately after the functor returns (before rejectPromisesWithExceptionIfAny clears the pending exception), and changes the rebuild condition to if (functorThrew || !JSC::JSValue::decode(result)). When the functor threw for any reason, the function now discards the functor's return value and rebuilds a valid NavigationResult-style dictionary via convertDictionaryToJS from the two now-rejected promises. The non-pair callPromiseFunction was not touched — it already discards the functor's return value.
Sentinel-only error detection that fails to recognize an alternate error encoding (raw Exception cell from throwVMError), leaking a non-object cell to script and enabling JSObject type confusion.
Background
The [ReturnsPromisePair] IDL extended attribute marks operations that return a dictionary of two promises — the Navigation API's NavigationResult { committed, finished }. callPromisePairFunction is the common C++ helper in JSDOMPromiseDeferred.h that runs the operation functor and packages the two promises for return to JavaScript. throwVMError is a JSC helper that records a pending exception and returns an EncodedJSValue encoding the JSC::Exception* cell — a non-zero value, distinct from the empty-value sentinel (encoded 0) used to signal IDL argument-conversion failure. A JSC::Exception is a garbage-collected JSCell with JSType == CellType, not a JSObject, and holds a JSValue m_value member that GC visits via Exception::visitChildrenImpl. JSCell::putInline routes a property store on a non-overridesPut cell through asObject(this) / jsCast<JSObject*>; ASSERT_WITH_SECURITY_IMPLICATION guards that cast in debug builds but compiles to a no-op in production, so the type is not checked at runtime. rejectPromisesWithExceptionIfAny consumes and clears any pending exception on the catch scope so the two promises are rejected with it.
Analysis
The root cause is that callPromisePairFunction used the empty-JSValue sentinel as its only signal that the functor failed. That sentinel is the value returned by IDL argument-conversion failures — but a [ReturnsPromisePair] operation invoked with insufficient arguments hits the missing-argument check emitted by CodeGeneratorJS.pm, which executes return throwVMError(...). throwVMError encodes and returns a non-zero EncodedJSValue wrapping a raw JSC::Exception* cell (inferred from the added diff comment; the throwVMError body is not in the diff). rejectPromisesWithExceptionIfAny then clears the pending exception, so RETURN_IF_EXCEPTION does not fire, and the non-zero sentinel check is false — so the raw JSC::Exception cell is handed back to JavaScript as the operation's return value.
A JSC::Exception cell has JSType == CellType (0) and is not a JSObject. When script performs a property store on it (result.foo = 0xdead), JSCell::putInline sees overridesPut() == false and does asObject(this) → jsCast<JSObject*>. In production ARM64 builds, if ASSERT_WITH_SECURITY_IMPLICATION compiles to ((void)0) (a general JSC build claim, not visible in the diff), the cast is an unchecked static_cast — type confusion.
The test case demonstrates the exact trigger. let result = navigation.navigate() with zero arguments takes the throwVMError path and obtains the leaked Exception cell. The subsequent result.foo = 0xdead treats the Exception cell as a JSObject, allocates a Butterfly, and overwrites the word at the JSObject butterfly offset — which aliases Exception::m_value at +0x08 — with the Butterfly pointer, then stores the attacker value into the property slot. Forcing GC then reaches Exception::visitChildrenImpl, where visitor.append(m_value) treats the clobbered field as a JSCell and SlotVisitor::drain dereferences it as a live object with an attacker-influenced Structure/StructureID.
This is a web-reachable type confusion that leaks a raw JSC::Exception cell to script. It is at minimum a reliable controlled crash during GC, and could provide a stronger memory-corruption primitive if the fake-JSCell fed to GC and the m_value overwrite are groomed by an attacker — realizing that would depend on placing controlled data at what the clobbered m_value is made to point at.
This vulnerability weakens the memory-type-safety boundary inside the WebContent renderer. The bindings layer is assumed to only ever hand JavaScript a well-typed JSObject (the NavigationResult dictionary); returning a raw JSC::Exception cell violates that invariant and lets an attacker from ordinary web content defeat JSC's type system. It does not itself cross the sandbox boundary — a separate escape would still be required to affect other processes.
The root fault is a bindings helper trusting a single error-encoding convention (empty-value sentinel) when the code path it wraps can signal failure through a different one (a pending exception plus a raw Exception cell). Any "detect failure by inspecting the return value" pattern is fragile when the callee's failure modes are heterogeneous; the robust check is "did the scope record an exception?", which is exactly what the fix adds. That the sibling callPromiseFunction was safe only because it discards the functor's return value is a reminder that a function's safety can hinge on an unstated implementation detail rather than an enforced invariant.
Note: Some implementation details — the throwVMError encoding, the production no-op behavior of ASSERT_WITH_SECURITY_IMPLICATION, and the full inventory of [ReturnsPromisePair] operations — are inferred from the diff comment and surrounding code patterns rather than directly visible in the commit. The core execution flow and crash conditions are consistently supported by the patch and test.
Audit directions
- Failure inferred from a sentinel return value rather than the exception scope. Bindings and glue code that branches on a magic-value return can miss failure paths that signal through a pending exception instead. Audit other helpers in
Source/WebCore/bindings/js/(andJSDOMPromiseDeferred.h/.cpp) that branch on!JSC::JSValue::decode(result)or similar checks, and verify each also treatsscope.exception()being set as failure. Grep forJSValue::decode(result)andthrowVMErrorco-occurring in generated or hand-written binding glue. - Raw non-
JSObjectJSCells reaching script through a return path. BecauseJSC::Exception(JSType CellType) is not a JSObject, any code returning anEncodedJSValueoriginating fromthrowVMError/throwExceptionwhere the caller does not re-check the exception scope is suspect. ReviewCodeGeneratorJS.pmemission points for mandatory-argument and conversion failures feeding into promise-pair and other aggregate return helpers. - Unchecked
jsCast<JSObject*>reachable from script-controlled cells. SinceASSERT_WITH_SECURITY_IMPLICATIONis a no-op in production, investigate otherJSCell::putInline/asObject/jsCast<JSObject*>sites reachable when a non-object cell can be surfaced to JS, and consider whether any should carry a release-mode type guard. Start fromJSCell::putInlineand callers ofasObject()in JavaScriptCore. - Verify the
[ReturnsPromisePair]inventory. Grep the IDL tree forReturnsPromisePairand cross-check that Navigation API entry points (navigate,reload,traverseTo,back,forward) and any future additions all route throughcallPromisePairFunction.