Closed Bug 2042722 Opened 5 months ago Closed 5 months ago

Type confusion in [@ js::wasm::AddPromiseReactions] via unchecked downcast of a cross-compartment Promise wrapper to PromiseObject

Categories

(Core :: JavaScript: WebAssembly, defect)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2042295

People

(Reporter: bugmon, Unassigned)

Details

(5 keywords)

Attachments

(3 files)

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

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

  1. Create a second compartment via newGlobal({newCompartment:true}) and expose the main realm's Promise into it (g.P = Promise).
  2. Construct a WebAssembly.Suspending whose JS callable returns a Promise created in the second compartment whose own constructor property is forged to the main (wasm) realm's Promise (p.constructor = P). From the main realm this value is a cross-compartment wrapper (ProxyObject).
  3. 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.
  4. Inside the generated suspending module, $builtin.promise-resolve (PromiseResolve) is invoked on the callable's return value. Because the forged constructor equals GetPromiseConstructor(cx), CommonStaticResolveImpl returns the cross-compartment ProxyObject UNCHANGED.
  5. The ProxyObject is stored via local.tee $promise and delivered to the resume handler via suspend $on-suspend; the promising machinery then calls $builtin.add-promise-reactions with that value.
  6. AddPromiseReactions executes AnyRef::fromCompiledCode(promiseRef).toJSObject().as<PromiseObject>() (WasmPI.cpp:1338) on the ProxyObject — a type confusion. In debug builds MOZ_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

  1. Use a JSPI-enabled SpiderMonkey shell (JSPI is force-enabled under --fuzzing-safe).
  2. Run jspi_addpromisereactions_typeconfusion.js with the debug build: /firefox/obj-js-debug/dist/bin/js --fuzzing-safe jspi_addpromisereactions_typeconfusion.js
  3. 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 constructor resolves (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)
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Group: core-security → javascript-core-security
Status: UNCONFIRMED → RESOLVED
Closed: 5 months ago
Duplicate of bug: 2042295
Resolution: --- → DUPLICATE
Group: javascript-core-security
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: