Closed Bug 2023031 Opened 8 months ago Closed 8 months ago

Compartment mismatch with CCW to a promise with WasmPI

Categories

(Core :: JavaScript: WebAssembly, defect, P2)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2021780
Tracking Status
firefox148 --- wontfix
firefox149 --- disabled
firefox150 --- disabled

People

(Reporter: tsmith, Unassigned)

References

(Blocks 1 open bug)

Details

(6 keywords)

Attachments

(2 files)

Attached file crash_stack.txt —

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:

  1. JSObject::as<T>() (/firefox/js/src/vm/JSObject.h:516) is a static_cast guarded only by MOZ_ASSERT(is<T>()) — compiled out in non-DEBUG builds (confirmed: getBuildConfiguration("debug") == false).

  2. 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 .constructor property 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 our Promise into the other compartment; the subsequent Get rewraps it back — producing our Promise — satisfying ctorVal == thisVal.

  3. 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, holding ProxyValueArray { expandoSlot; privateSlot; ... } (/firefox/js/src/vm/ProxyObject.h:52, /firefox/js/public/Proxy.h:443–445)
    • ProxyObject::target() → private_() → GetProxyPrivate() reads privateSlot (/firefox/js/src/vm/ProxyObject.h:68,76)

    Therefore on a type-confused CCW:

    • getFixedSlot(0) at this+24 reads expandoSlot = NullValue() → low 32 bits = 0 → flags() = 0 → state() = Pending
    • The state() == Rejected branch is skipped
    • getFixedSlot(1) at this+32 reads privateSlot = ObjectValue(wrappedTarget) — a raw pointer to the other compartment's Promise object
  4. This raw cross-compartment pointer flows into Val::fromJSValue → ToWebAssemblyValue → ToInt32 → ToPrimitiveSlow → OrdinaryToPrimitive → CallJSNative, where cx->check() detects the compartment mismatch and calls MOZ_CRASH.

  5. Diagnostic-only catch: cx->check() is guarded by #ifdef JS_CRASH_DIAGNOSTICS (/firefox/js/src/vm/JSContext-inl.h:221), which is only defined in DEBUG || 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

  1. Setup: Create a second global g in a new compartment via newGlobal({newCompartment: true}) (in Firefox content: any cross-origin iframe provides this).
  2. Create otherPromise = new g.Promise(...) — this yields a CrossCompartmentWrapper in the current compartment wrapping a PromiseObject in g's compartment.
  3. Set otherPromise.constructor = Promise — the CCW's set trap wraps our Promise constructor into g's compartment and stores it as an own property on the target.
  4. Create a WebAssembly.Suspending wrapping a JS function that returns otherPromise.
  5. Import it into a Wasm module whose return type is i32 and invoke it via WebAssembly.promising().
  6. Wasm calls AddPromiseReactions (WasmPI.cpp:1419). PromiseResolve receives the CCW:
    • IsWrapper(xObj) → true; canUnwrapAs<PromiseObject>() → true
    • GetProperty(..., "constructor") through the CCW rewraps the stored constructor back → returns our Promise
    • ctorVal == thisVal → return xObj returns the CCW itself
  7. &promiseObj->as<PromiseObject>() at line 1437: MOZ_ASSERT(is<PromiseObject>()) compiled out → static_cast<PromiseObject*>(CCW) succeeds silently. Type confusion established.
  8. The confused pointer is stored in PROMISE_SLOT and returnType is set to Promise. The promise is suspended.
  9. resolveOther(42) + drainJobQueue() runs the reaction → WasmPISuspendTaskContinue → resumes into GetSuspendingPromiseResult.
  10. Line 1339: as<PromiseObject>() on the CCW — type confusion again.
  11. Line 1349: promise->state() → getFixedSlot(0).toInt32() → reads expandoSlot (NullValue()) → low 32 bits = 0 → Pending. Rejection branch not taken.
  12. Line 1361: promise->value() → getFixedSlot(1) → reads privateSlot = ObjectValue(other-compartment Promise).
  13. Line 1378: Val::fromJSValue(cx, ValType::I32, jsValue, ...) → ToInt32 → ToPrimitive → attempts to call valueOf on the raw cross-compartment object.
  14. 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 through ToPrimitive, invoking valueOf/toString on 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.

Attached file testcase.js —

Required flag: --setpref=wasm_js_promise_integration=true

Summary: Hit MOZ_CRASH(*** Compartment mismatch 79052b3e24d0 vs. 79052b3e35e0 at argument 0) at /firefox/js/src/vm/JSContext-inl.h:56 → Compartment mismatch with CCW to a promise
Summary: Compartment mismatch with CCW to a promise → Compartment mismatch with CCW to a promise with WasmPI

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.

Severity: -- → S2
Priority: -- → P2

This looks like a duplicate of bug 2021780. Yury can you confirm?

Flags: needinfo?(ydelendik)

(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.

Flags: needinfo?(ydelendik)
Status: NEW → RESOLVED
Closed: 8 months ago
Duplicate of bug: 2021780
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: