Type confusion in [@ js::wasm::AddPromiseReactions] via unchecked downcast of a cross-compartment Promise wrapper to PromiseObject
Categories
(Core :: JavaScript: WebAssembly, defect)
Tracking
()
People
(Reporter: bugmon, Unassigned)
Details
(5 keywords)
Attachments
(3 files)
|
529 bytes,
text/plain
|
Details | |
|
1.13 KB,
patch
|
Details | Diff | Splinter Review | |
|
1.35 KB,
text/plain
|
Details |
WebAssembly JS Promise Integration (JSPI) builds a hidden wasm "suspending" module whose generated code, on suspend, hands the suspend payload ($promise) to the C++ builtin AddPromiseReactions and, after resume, to GetPromiseResults. Both builtins receive the SAME $promise value. GetPromiseResults (WasmPI.cpp:1248-1257) defensively handles cross-compartment wrappers — IsWrapper(promiseObj) -> UncheckedUnwrap -> reject JS_IsDeadWrapper — before its as<PromiseObject>(). AddPromiseReactions (WasmPI.cpp:1336-1338) does NOT: it performs an unchecked &AnyRef::fromCompiledCode(promiseRef).toJSObject().as<PromiseObject>(), assuming a native PromiseObject.
$promise is produced by the PromiseResolve builtin (WasmPI.cpp:1371-1382 -> js::PromiseResolve -> CommonStaticResolveImpl, builtin/Promise.cpp:5742-5773). That routine returns its input object UNCHANGED when the input can unwrap to a Promise and Get(input,"constructor") === C, where C = GetPromiseConstructor(cx) is the wasm realm's Promise. A WebAssembly.Suspending callable can therefore return a Promise from a separate compartment (newGlobal({newCompartment:true})) whose own constructor property is forged to the wasm realm's Promise; PromiseResolve then returns that cross-compartment ProxyObject verbatim as $promise.
When that ProxyObject reaches AddPromiseReactions, the unchecked as<PromiseObject>() is a type confusion: a Proxy (cross-compartment wrapper) is reinterpreted as a PromiseObject. The debug build catches it with MOZ_ASSERT(this->is<T>()) (JSObject.h:521), which fires and crashes (SEGV via MOZ_CrashSequence) at WasmPI.cpp:1338; register r13=0xaaaaaaaaaaaaaaaa (jemalloc poison) shows the proxy's reserved slots being misread as PromiseObject fields. The bug is reachable from ordinary content JS through the WebAssembly.Suspending / WebAssembly.promising APIs.
Build Info
- Branch: main
- Revision: 1f7030c8de8f2b349c7d91d7b5a3253c109a1cc1
- Timestamp: 2026-05-21T16:50:48+00:00
Affected Code
File: js/src/wasm/WasmPI.cpp, line 1332-1346
int32_t AddPromiseReactions(Instance* instance, void* promiseRef, void* contRef,
void* reactionRef, void* promisingPromiseRef) {
MOZ_ASSERT(SASigAddPromiseReactions.failureMode == FailureMode::FailOnNegI32);
JSContext* cx = instance->cx();
Rooted<PromiseObject*> promiseObject(
cx,
&AnyRef::fromCompiledCode(promiseRef).toJSObject().as<PromiseObject>()); // <-- UNCHECKED downcast: promiseRef may be a cross-compartment ProxyObject
Rooted<ContObject*> contObject(
cx, &AnyRef::fromCompiledCode(contRef).toJSObject().as<ContObject>());
RootedFunction reactionFunc(
cx, &AnyRef::fromCompiledCode(reactionRef).toJSObject().as<JSFunction>());
Rooted<PromiseObject*> promisingPromise(
cx, &AnyRef::fromCompiledCode(promisingPromiseRef)
.toJSObject()
.as<PromiseObject>());
File: js/src/wasm/WasmPI.cpp, line 1248-1257
JSObject* promiseObj = &AnyRef::fromCompiledCode(promiseRef).toJSObject();
if (IsWrapper(promiseObj)) { // GetPromiseResults DOES guard the same value
promiseObj = UncheckedUnwrap(promiseObj);
if (JS_IsDeadWrapper(promiseObj)) {
JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr,
JSMSG_DEAD_OBJECT);
return nullptr;
}
}
Rooted<PromiseObject*> promise(cx, &promiseObj->as<PromiseObject>());
File: js/src/wasm/WasmPI.cpp, line 1371-1382
void* PromiseResolve(Instance* instance, void* valueRef) {
MOZ_ASSERT(SASigPromiseResolve.failureMode == FailureMode::FailOnNullPtr);
JSContext* cx = instance->cx();
RootedObject promiseConstructor(cx, GetPromiseConstructor(cx));
RootedValue value(cx, AnyRef::fromCompiledCode(valueRef).toJSValue());
RootedObject promise(cx, PromiseResolve(cx, promiseConstructor, value)); // returns the input wrapper verbatim if input.constructor === promiseConstructor
if (!promise) {
MOZ_ASSERT(cx->isExceptionPending());
return nullptr;
}
return AnyRef::fromJSObject(*promise).forCompiledCode();
}
File: js/src/builtin/Promise.cpp, line 5742-5773
if (argVal.isObject()) {
RootedObject xObj(cx, &argVal.toObject());
bool isPromise = false;
if (xObj->is<PromiseObject>()) {
isPromise = true;
} else if (IsWrapper(xObj)) {
if (xObj->canUnwrapAs<PromiseObject>()) { // a CCW around a Promise qualifies
isPromise = true;
}
}
if (isPromise) {
RootedValue ctorVal(cx);
if (!GetProperty(cx, xObj, xObj, cx->names().constructor, &ctorVal)) {
return nullptr;
}
if (ctorVal == ObjectValue(*C)) {
return xObj; // returns the CCW unchanged -> becomes $promise
}
}
}
The vulnerable unchecked downcast in AddPromiseReactions, contrasted with the correct wrapper handling in GetPromiseResults that processes the same value, and the PromiseResolve builtin that lets a cross-compartment wrapper become the suspend payload.
Exploit Chain
- Create a second compartment via
newGlobal({newCompartment:true})and expose the main realm'sPromiseinto it (g.P = Promise). - Construct a
WebAssembly.Suspendingwhose JS callable returns a Promise created in the second compartment whose ownconstructorproperty is forged to the main (wasm) realm'sPromise(p.constructor = P). From the main realm this value is a cross-compartment wrapper (ProxyObject). - Import the Suspending object into a tiny wasm module and wrap its export with
WebAssembly.promising, then call the promising function so the wasm calls the suspending import. - Inside the generated suspending module,
$builtin.promise-resolve(PromiseResolve) is invoked on the callable's return value. Because the forgedconstructorequalsGetPromiseConstructor(cx),CommonStaticResolveImplreturns the cross-compartment ProxyObject UNCHANGED. - The ProxyObject is stored via
local.tee $promiseand delivered to the resume handler viasuspend $on-suspend; the promising machinery then calls$builtin.add-promise-reactionswith that value. AddPromiseReactionsexecutesAnyRef::fromCompiledCode(promiseRef).toJSObject().as<PromiseObject>()(WasmPI.cpp:1338) on the ProxyObject — a type confusion. In debug buildsMOZ_ASSERT(this->is<PromiseObject>())fires (SEGV); in release builds it is a silent bad downcast of a Proxy treated as a PromiseObject.
Steps to Reproduce
- Use a JSPI-enabled SpiderMonkey shell (JSPI is force-enabled under --fuzzing-safe).
- Run jspi_addpromisereactions_typeconfusion.js with the debug build: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe jspi_addpromisereactions_typeconfusion.js
- Observe the crash: 'Assertion failure: this->is<T>(), at js/src/vm/JSObject.h:521' followed by a SEGV, with the stack showing JSObject::as<js::PromiseObject>() called from js::wasm::AddPromiseReactions at WasmPI.cpp:1338.
Security Impact
- Severity: High
- Attacker capability: An attacker controlling ordinary web content can drive a cross-compartment ProxyObject (cross-compartment wrappers map to cross-origin/other-realm objects, e.g. obtained from iframes or other globals) into the unchecked
as<PromiseObject>()in AddPromiseReactions, producing a type confusion in which a Proxy's reserved slots are reinterpreted as PromiseObject internal state. This is a memory-safety / type-confusion class defect: the engine treats a non-Promise object as a PromiseObject. In debug builds the invariant violation is caught by an assertion (crash); in release builds the bad downcast proceeds unchecked. - Preconditions: JSPI enabled (default in builds that ship WebAssembly.Suspending/WebAssembly.promising). The attacker must supply, as the resolved value of a Suspending callable, a Promise from another compartment whose
constructorresolves (through the wrapper) to the wasm realm's Promise constructor so that PromiseResolve returns the wrapper unchanged. No elevated privileges required; reachable from content JS.
ASAN Report
Assertion failure: this->is<T>(), at /firefox/js/src/vm/JSObject.h:521
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==2187==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000000 (pc 0x5bc927b35047 bp 0x7ffdf4195220 sp 0x7ffdf4195150 T2187)
==2187==The signal is caused by a WRITE memory access.
==2187==Hint: address points to the zero page.
#0 MOZ_CrashSequence(void*, long) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:261:3
#1 js::PromiseObject& JSObject::as<js::PromiseObject>() /firefox/js/src/vm/JSObject.h:521:5
#2 js::wasm::AddPromiseReactions(js::wasm::Instance*, void*, void*, void*, void*) /firefox/js/src/wasm/WasmPI.cpp:1338:58
#3 0x0869dea5d7dc (<wasm jit frame>)
#4 0x0869deaedd0d (<wasm jit frame>)
#5 0x0869deaeeb56 (<wasm jit frame>)
#6 js::wasm::Instance::callExport(...) /firefox/js/src/wasm/WasmInstance.cpp:4135:10
#7 WasmCall(...) /firefox/js/src/wasm/WasmInstance.cpp:3918:19
#13 js::wasm::WasmPromisingFunction(JSContext*, unsigned int, JS::Value*) /firefox/js/src/wasm/WasmPI.cpp:1110:7
==2187==Register values:
rax = 0x0000000000000000 ... r13 = 0xaaaaaaaaaaaaaaaa ...
SUMMARY: UndefinedBehaviorSanitizer: SEGV in MOZ_CrashSequence(void*, long)
| Reporter | ||
Comment 1•5 months ago
|
||
| Reporter | ||
Comment 2•5 months ago
|
||
| Reporter | ||
Comment 3•5 months ago
|
||
Updated•5 months ago
|
Updated•5 months ago
|
Updated•1 month ago
|
Description
•