Compartment mismatch with CCW to a promise with WasmPI
Categories
(Core :: JavaScript: WebAssembly, defect, P2)
Tracking
()
People
(Reporter: tsmith, Unassigned)
References
(Blocks 1 open bug)
Details
(6 keywords)
Attachments
(2 files)
Summary
Type confusion in js::wasm::AddPromiseReactions at /firefox/js/src/wasm/WasmPI.cpp:1437 — an unchecked as<PromiseObject>() cast is applied to the return value of PromiseResolve(), which can return a CrossCompartmentWrapper (a ProxyObject, not a PromiseObject). The type-confused pointer is later dereferenced in GetSuspendingPromiseResult at line 1361, where promise->value() reads getFixedSlot(1) — which on a ProxyObject overlays the inline ProxyValueArray::privateSlot containing a raw pointer to an object in a different compartment. This unwrapped cross-compartment pointer flows into JS value coercion, violating SpiderMonkey's fundamental compartment isolation invariant.
Affected Code
File: /firefox/js/src/wasm/WasmPI.cpp, lines 1431–1437
// Wrap a promise.
RootedObject promiseConstructor(cx, GetPromiseConstructor(cx));
RootedObject promiseObj(cx,
PromiseResolve(cx, promiseConstructor, resultValue));
if (!promiseObj) {
return AnyRef::invalid().forCompiledCode();
}
Rooted<PromiseObject*> promiseObject(cx, &promiseObj->as<PromiseObject>()); // [1] UNCHECKED CAST
File: /firefox/js/src/wasm/WasmPI.cpp, lines 1337–1361
Rooted<PromiseObject*> promise(
cx, returnType == SuspenderObject::ReturnType::Promise
? &resultRef.toJSObject().as<PromiseObject>() // [2] UNCHECKED CAST
: nullptr);
...
if (promise ? promise->state() == JS::PromiseState::Rejected // [3] reads getFixedSlot(0)
: returnType == SuspenderObject::ReturnType::Exception) {
...
}
// The exception and rejection are handled above -- expect resolved promise.
MOZ_ASSERT(promise->state() == JS::PromiseState::Fulfilled);
RootedValue jsValue(cx, promise->value()); // [4] reads getFixedSlot(1) — LEAKS RAW CROSS-COMPARTMENT POINTER
Why this is vulnerable:
-
JSObject::as<T>()(/firefox/js/src/vm/JSObject.h:516) is astatic_castguarded only byMOZ_ASSERT(is<T>())— compiled out in non-DEBUG builds (confirmed:getBuildConfiguration("debug") == false). -
PromiseResolve()→CommonStaticResolveRejectImpl()(/firefox/js/src/builtin/Promise.cpp:5673–5697) has a fast path that returns the argument unchanged if it is a wrapper around a Promise AND its.constructorproperty matches the current Promise constructor:} else if (IsWrapper(xObj)) { if (xObj->canUnwrapAs<PromiseObject>()) { isPromise = true; } } if (isPromise) { RootedValue ctorVal(cx); if (!GetProperty(cx, xObj, xObj, cx->names().constructor, &ctorVal)) ... if (ctorVal == thisVal) { return xObj; // returns the WRAPPER, not the unwrapped PromiseObject! } }The testcase trips this path by setting
otherPromise.constructor = Promise: the Set operation through the CCW wraps ourPromiseinto the other compartment; the subsequent Get rewraps it back — producing ourPromise— satisfyingctorVal == thisVal. -
Memory layout overlap (64-bit):
sizeof(NativeObject) == sizeof(ProxyObject) == sizeof(JSObject_Slots0) == 24(/firefox/js/src/vm/NativeObject.h:708,/firefox/js/src/vm/ProxyObject.h:32)NativeObject::fixedSlots() = this + 24(/firefox/js/src/vm/NativeObject.h:931)ProxyObject::inlineDataStart() = this + 24, holdingProxyValueArray { expandoSlot; privateSlot; ... }(/firefox/js/src/vm/ProxyObject.h:52,/firefox/js/public/Proxy.h:443–445)ProxyObject::target()→private_()→GetProxyPrivate()readsprivateSlot(/firefox/js/src/vm/ProxyObject.h:68,76)
Therefore on a type-confused CCW:
getFixedSlot(0)atthis+24readsexpandoSlot=NullValue()→ low 32 bits =0→flags()=0→state()=Pending- The
state() == Rejectedbranch is skipped getFixedSlot(1)atthis+32readsprivateSlot=ObjectValue(wrappedTarget)— a raw pointer to the other compartment's Promise object
-
This raw cross-compartment pointer flows into
Val::fromJSValue→ToWebAssemblyValue→ToInt32→ToPrimitiveSlow→OrdinaryToPrimitive→CallJSNative, wherecx->check()detects the compartment mismatch and callsMOZ_CRASH. -
Diagnostic-only catch:
cx->check()is guarded by#ifdef JS_CRASH_DIAGNOSTICS(/firefox/js/src/vm/JSContext-inl.h:221), which is only defined inDEBUG || NIGHTLY_BUILD(/firefox/js/src/util/DiagnosticAssertions.h:16). On Release/Beta builds, the check is a no-op and the raw cross-compartment pointer silently flows into script execution.
Exploit Chain
- Setup: Create a second global
gin a new compartment vianewGlobal({newCompartment: true})(in Firefox content: any cross-origin iframe provides this). - Create
otherPromise = new g.Promise(...)— this yields aCrossCompartmentWrapperin the current compartment wrapping aPromiseObjecting's compartment. - Set
otherPromise.constructor = Promise— the CCW'ssettrap wraps ourPromiseconstructor intog's compartment and stores it as an own property on the target. - Create a
WebAssembly.Suspendingwrapping a JS function that returnsotherPromise. - Import it into a Wasm module whose return type is
i32and invoke it viaWebAssembly.promising(). - Wasm calls
AddPromiseReactions(WasmPI.cpp:1419).PromiseResolvereceives the CCW:IsWrapper(xObj)→ true;canUnwrapAs<PromiseObject>()→ trueGetProperty(..., "constructor")through the CCW rewraps the stored constructor back → returns ourPromisectorVal == thisVal→return xObjreturns the CCW itself
&promiseObj->as<PromiseObject>()at line 1437:MOZ_ASSERT(is<PromiseObject>())compiled out →static_cast<PromiseObject*>(CCW)succeeds silently. Type confusion established.- The confused pointer is stored in
PROMISE_SLOTandreturnTypeis set toPromise. The promise is suspended. resolveOther(42)+drainJobQueue()runs the reaction →WasmPISuspendTaskContinue→ resumes intoGetSuspendingPromiseResult.- Line 1339:
as<PromiseObject>()on the CCW — type confusion again. - Line 1349:
promise->state()→getFixedSlot(0).toInt32()→ readsexpandoSlot(NullValue()) → low 32 bits =0→Pending. Rejection branch not taken. - Line 1361:
promise->value()→getFixedSlot(1)→ readsprivateSlot=ObjectValue(other-compartment Promise). - Line 1378:
Val::fromJSValue(cx, ValType::I32, jsValue, ...)→ToInt32→ToPrimitive→ attempts to callvalueOfon the raw cross-compartment object. CallJSNative→cx->check()→ compartment mismatch →MOZ_CRASH.
Security Impact
Severity: High. Cross-compartment pointer leak through type confusion.
- Diagnostic builds (DEBUG/NIGHTLY): controlled crash via
cx->check()— caught by safety net. - Release/Beta builds:
cx->check()is compiled out. The raw cross-compartment object pointer flows throughToPrimitive, invokingvalueOf/toStringon an object without its compartment wrapper, executing in the wrong realm/compartment. This violates the foundational invariant that all cross-compartment access goes through wrappers. Consequences include:- Realm confusion in JIT type specialization (shape guards on wrong-compartment objects)
- GC invariant violations (zone-local references to foreign-zone objects if compartments are in different zones)
- Cross-origin policy bypass in browser context (Xray wrapper bypass)
Preconditions: JS Promise Integration enabled (wasm_js_promise_integration). This is a stage-3 WebAssembly proposal currently prefed-off by default but actively shipping towards enablement. Two compartments are required — trivially available in web content via cross-origin iframes.
Suggested Fix
At /firefox/js/src/wasm/WasmPI.cpp:1437, replace the unchecked cast with an explicit type check that handles the wrapper case:
RootedObject promiseObj(cx,
PromiseResolve(cx, promiseConstructor, resultValue));
if (!promiseObj) {
return AnyRef::invalid().forCompiledCode();
}
// PromiseResolve can return a CrossCompartmentWrapper when the argument is a
// wrapped promise with a spoofed .constructor property (see
// CommonStaticResolveRejectImpl). We MUST have a same-compartment PromiseObject
// here because GetSuspendingPromiseResult reads fixed slots directly.
Rooted<PromiseObject*> promiseObject(cx);
if (promiseObj->is<PromiseObject>()) {
promiseObject = &promiseObj->as<PromiseObject>();
} else {
// Fall back: create a fresh promise in our compartment resolved to the
// original value. This goes through the then-able protocol on the wrapper.
RootedValue promiseVal(cx, ObjectValue(*promiseObj));
JSObject* fresh = PromiseObject::unforgeableResolve(cx, promiseVal);
if (!fresh) {
return AnyRef::invalid().forCompiledCode();
}
promiseObject = &fresh->as<PromiseObject>();
}
Alternatively, add MOZ_RELEASE_ASSERT(promiseObj->is<PromiseObject>()) as a minimal hardening patch while the proper fix is designed.
| Reporter | ||
Comment 1•8 months ago
|
||
Required flag: --setpref=wasm_js_promise_integration=true
Updated•8 months ago
|
Updated•8 months ago
|
Comment 2•8 months ago
|
||
Since this is a Wasm JS-PI related bug, which is behind a turned off by default pref, I making it disabled for now. We will make sure this bug addressed when we land the new implementation of the JS-PI proposal in the near future.
Comment 3•8 months ago
|
||
This looks like a duplicate of bug 2021780. Yury can you confirm?
Comment 4•8 months ago
|
||
(In reply to Ryan Hunt [:rhunt] from comment #3)
This looks like a duplicate of bug 2021780. Yury can you confirm?
Indeed, the patch from D287215 fixes the test case here too.
Updated•8 months ago
|
Updated•2 months ago
|
Description
•