Critical CVSS 9.8 firefox OOB 🔧 Commit mapped

Overview

Critical
Severity
9.8
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactcritical
DescriptionAn attacker was able to perform an out-of-bounds read or write on a JavaScript `Promise` object.
ComponentSpiderMonkey
Bug ClassOOB
Tracker1966612
Fix commitd03d25aec14c (firefox) +33/-13
CWECWE-125, CWE-787 (Out-of-bounds read, Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedEdouard Bochin and Tao Yan from Palo Alto Networks working with Trend Micro's Zero Day Initiative
Disclosed2025-05-17

Background

`PromiseCombinatorDataHolder`
the shared state object for one combinator invocation: it holds the remaining-count and the growable values array that element functions index into by element index.
Element function extended slots
each element function is a JSFunction allocated with FUNCTION_EXTENDED; slot PromiseCombinatorElementFunctionSlot_Data holds the data holder and a second slot holds the per-element index. The patch renames that second slot from ..._ElementIndex to ..._ElementIndexOrResolveFunc.
`[[AlreadyCalled]]`
the spec’s once-only guard: each element’s resolve/reject pair must share a single already-called record so an element can be settled at most once. In SpiderMonkey this is enforced in PromiseCombinatorElementFunctionAlreadyCalled, which reads the index slot and extracts idx.
`NewPromiseCombinatorElementFunction`
factory that builds an element function and stores the data holder plus the index into its extended slots. The patch adds a Handle<Value> maybeResolveFunc parameter.

Root Cause Analysis

Promise.allSettled is unique among the combinators in that each input element gets two element functions — a resolve function and a reject function — that must share one [[AlreadyCalled]] record so the element is counted exactly once. In the old code (Promise_static_allSettled), both were built by NewPromiseCombinatorElementFunction(... dataHolder, index) independently, and each stored its own Int32Value(index) in its own PromiseCombinatorElementFunctionSlot_ElementIndex slot with independent already-called state. PromiseCombinatorElementFunctionAlreadyCalled read fn->getExtendedSlot(ElementIndex).toInt32() as idx and used it to index the shared values array. Because the resolve and reject closures did not share the guard, a single element could be driven to settle twice (resolve then reject, or vice versa), each invocation decrementing the remaining-count and writing into the values array at idx. Settling the same index more than once corrupts the combinator’s bookkeeping — the remaining-count and the values-array indexing invariant — which is the mechanism behind the out-of-bounds read/write on the Promise object that the advisory and CWE-125/CWE-787 describe.

The patch makes the two functions share a single [[AlreadyCalled]]/index record. NewPromiseCombinatorElementFunction now takes a maybeResolveFunc value; for allSettled, the reject function is created with maybeResolveFunc = resolveFunVal (the already-created resolve function object), and stores that object in the renamed ElementIndexOrResolveFunc slot instead of an Int32Value. Every other combinator (and the resolve function itself) passes UndefinedHandleValue, so it keeps storing the plain Int32Value(index). In PromiseCombinatorElementFunctionAlreadyCalled, new code checks whether that slot holds an object; if so it redirects fn to the referenced resolve function (fn = &slotVal.toObject().as<JSFunction>()) before reading the index, then asserts MOZ_RELEASE_ASSERT(fn->getExtendedSlot(indexOrResolveFuncSlot).isInt32()). The result is that the reject function’s already-called check and index now resolve through the same resolve-function slot, so settling via either function trips the one shared guard and the element can be processed only once. The index read becomes fn->getExtendedSlot(indexOrResolveFuncSlot).toInt32() on the (possibly redirected) function.

The advisory’s OOB class is consistent with the fix: the diff substantiates the root as a missing shared once-only guard for allSettled’s element pair (a logic flaw), whose consequence is double-settling that corrupts the values-array index/count bookkeeping and yields the out-of-bounds read/write primitive. I keep OOB as the controlled-vocabulary class and note the logic-error nature of the root in this paragraph.

Key insight
Promise.allSettled gave each element a separate resolve and reject function with independent [[AlreadyCalled]] state, violating the spec rule that the pair share one once-only guard; that let an attacker settle a single element twice and corrupt the combinator’s index/count bookkeeping into an out-of-bounds access on the values array.

Attack Path

  1. Script From ordinary page JavaScript, call Promise.allSettled over an iterable whose elements are thenables the attacker controls (custom then methods), so attacker code runs when each element’s resolve/reject element function is invoked.
  2. Double-settle one element Because the reject and resolve element functions for the same index do not share [[AlreadyCalled]], invoke both for one element (resolve then reject), each passing the once-only check that should have fired only once.
  3. Corrupt bookkeeping Each spurious settle decrements the shared data holder’s remaining-count and writes at the element index, driving the remaining-count and values-array indexing out of their invariant (inferred mechanism: the diff proves the shared-guard fix; the exact count/index corruption is the downstream consequence).
  4. Out-of-bounds access The confused index/count causes a read or write on the Promise’s values array outside its bounds — the out-of-bounds read/write on a Promise object named by the advisory.
  5. Primitive Controlled OOB read/write within the JS heap, the classic SpiderMonkey exploitation primitive and the basis of this Pwn2Own-class renderer bug.

Impact Assessment

This is a renderer-process (content JS) bug in SpiderMonkey reachable from unprivileged web content with no user interaction beyond loading a page — consistent with the CVSS AV:N/AC:L/PR:N/UI:N and 9.8 score. The achieved primitive is an out-of-bounds read and write on a JavaScript Promise’s internal values array, a strong heap primitive routinely escalated to arbitrary read/write and then code execution inside the content process; this CVE was demonstrated as a Pwn2Own entry (Edouard Bochin & Tao Yan via ZDI). The diff substantiates the memory-safety root (missing shared [[AlreadyCalled]]) and the index/count corruption surface; full RCE additionally requires the usual renderer exploitation chain (not shown by the diff). Gating is minimal — any site can invoke Promise.allSettled with crafted thenables.

Changed Functions

FunctionChangeNotes
if
js/src/builtin/Promise.cpp
modified

Files Changed

  • js/src/builtin/Promise.cpp

Audit Directions

  • Other paired-function combinators
    any construct that spawns more than one closure per element and relies on each to settle once; verify a single shared [[AlreadyCalled]] record rather than per-function state (the exact class of bug fixed here for allSettled).
  • Extended-slot type assumptions
    grep for getExtendedSlot(...).toInt32() / .toObject() across SpiderMonkey where a slot can hold either a tagged int or an object; the new MOZ_RELEASE_ASSERT guards exactly the type-confusion risk of such dual-purpose slots.
  • Values-array indexing in combinators
    review how PromiseCombinatorDataHolder’s remaining-count and values array are updated under repeated or reentrant settles (thenable then callbacks, revoked proxies) for any remaining path that can decrement the count or write an index twice.
  • Reentrancy via user thenables
    audit all combinator element functions for attacker-controlled reentrancy during resolution, a common source of once-only-guard bypasses.
diff --git a/js/src/builtin/Promise.cpp b/js/src/builtin/Promise.cpp
index 34f9c28961a..60d09e03a13 100644
--- a/js/src/builtin/Promise.cpp
+++ b/js/src/builtin/Promise.cpp
@@ -99,7 +99,7 @@ enum RejectFunctionSlots {
 
 enum PromiseCombinatorElementFunctionSlots {
   PromiseCombinatorElementFunctionSlot_Data = 0,
-  PromiseCombinatorElementFunctionSlot_ElementIndex,
+  PromiseCombinatorElementFunctionSlot_ElementIndexOrResolveFunc,
 };
 
 enum ReactionJobSlots {
@@ -3241,7 +3241,8 @@ static bool Promise_static_all(JSContext* cx, unsigned argc, Value* vp) {
 
 static JSFunction* NewPromiseCombinatorElementFunction(
     JSContext* cx, Native native,
-    Handle<PromiseCombinatorDataHolder*> dataHolder, uint32_t index);
+    Handle<PromiseCombinatorDataHolder*> dataHolder, uint32_t index,
+    Handle<Value> maybeResolveFunc);
 
 static bool PromiseAllResolveElementFunction(JSContext* cx, unsigned argc,
                                              Value* vp);
@@ -3343,7 +3344,8 @@ static bool PromiseAllResolveElementFunction(JSContext* cx, unsigned argc,
 
       // Steps 4.j-q.
       JSFunction* resolveFunc = NewPromiseCombinatorElementFunction(
-          cx, PromiseAllResolveElementFunction, dataHolder, index);
+          cx, PromiseAllResolveElementFunction, dataHolder, index,
+          UndefinedHandleValue);
       if (!resolveFunc) {
         return nullptr;
       }
@@ -3990,7 +3992,8 @@ template <typename T>
 
 static JSFunction* NewPromiseCombinatorElementFunction(
     JSContext* cx, Native native,
-    Handle<PromiseCombinatorDataHolder*> dataHolder, uint32_t index) {
+    Handle<PromiseCombinatorDataHolder*> dataHolder, uint32_t index,
+    Handle<Value> maybeResolveFunc) {
   JSFunction* fn = NewNativeFunction(
       cx, native, 1, nullptr, gc::AllocKind::FUNCTION_EXTENDED, GenericObject);
   if (!fn) {
@@ -3999,8 +4002,15 @@ static JSFunction* NewPromiseCombinatorElementFunction(
 
   fn->setExtendedSlot(PromiseCombinatorElementFunctionSlot_Data,
                       ObjectValue(*dataHolder));
-  fn->setExtendedSlot(PromiseCombinatorElementFunctionSlot_ElementIndex,
-                      Int32Value(index));
+  if (maybeResolveFunc.isObject()) {
+    fn->setExtendedSlot(
+        PromiseCombinatorElementFunctionSlot_ElementIndexOrResolveFunc,
+        maybeResolveFunc);
+  } else {
+    fn->setExtendedSlot(
+        PromiseCombinatorElementFunctionSlot_ElementIndexOrResolveFunc,
+        Int32Value(index));
+  }
   return fn;
 }
 
@@ -4033,6 +4043,14 @@ static bool PromiseCombinatorElementFunctionAlreadyCalled(
   // Step 1. Let F be the active function object.
   JSFunction* fn = &args.callee().as<JSFunction>();
 
+  constexpr size_t indexOrResolveFuncSlot =
+      PromiseCombinatorElementFunctionSlot_ElementIndexOrResolveFunc;
+  if (fn->getExtendedSlot(indexOrResolveFuncSlot).isObject()) {
+    Value slotVal = fn->getExtendedSlot(indexOrResolveFuncSlot);
+    fn = &slotVal.toObject().as<JSFunction>();
+  }
+  MOZ_RELEASE_ASSERT(fn->getExtendedSlot(indexOrResolveFuncSlot).isInt32());
+
   // Promise.all functions
   // Step 2. If F.[[AlreadyCalled]] is true, return undefined.
   // Promise.allSettled functions
@@ -4061,9 +4079,7 @@ static bool PromiseCombinatorElementFunctionAlreadyCalled(
   // Step 4. Let index be F.[[Index]].
   // Promise.allSettled functions
   // Step 5. Let index be F.[[Index]].
-  int32_t idx =
-      fn->getExtendedSlot(PromiseCombinatorElementFunctionSlot_ElementIndex)
-          .toInt32();
+  int32_t idx = fn->getExtendedSlot(indexOrResolveFuncSlot).toInt32();
   MOZ_ASSERT(idx >= 0);
   *index = uint32_t(idx);
 
@@ -4117,7 +4133,8 @@ static bool PromiseCombinatorElementFunctionAlreadyCalled(
 
     // Steps 4.j-q.
     JSFunction* resolveFunc = NewPromiseCombinatorElementFunction(
-        cx, PromiseAllResolveElementFunction, dataHolder, index);
+        cx, PromiseAllResolveElementFunction, dataHolder, index,
+        UndefinedHandleValue);
     if (!resolveFunc) {
       return false;
     }
@@ -4341,7 +4358,8 @@ static bool Promise_static_allSettled(JSContext* cx, unsigned argc, Value* vp) {
 
     // Steps 4.j-r.
     JSFunction* resolveFunc = NewPromiseCombinatorElementFunction(
-        cx, PromiseAllSettledResolveElementFunction, dataHolder, index);
+        cx, PromiseAllSettledResolveElementFunction, dataHolder, index,
+        UndefinedHandleValue);
     if (!resolveFunc) {
       return false;
     }
@@ -4349,7 +4367,8 @@ static bool Promise_static_allSettled(JSContext* cx, unsigned argc, Value* vp) {
 
     // Steps 4.s-z.
     JSFunction* rejectFunc = NewPromiseCombinatorElementFunction(
-        cx, PromiseAllSettledRejectElementFunction, dataHolder, index);
+        cx, PromiseAllSettledRejectElementFunction, dataHolder, index,
+        resolveFunVal);
     if (!rejectFunc) {
       return false;
     }
@@ -4578,7 +4597,8 @@ static void ThrowAggregateError(JSContext* cx,
 
     // Steps 4.j-q.
     JSFunction* rejectFunc = NewPromiseCombinatorElementFunction(
-        cx, PromiseAnyRejectElementFunction, dataHolder, index);
+        cx, PromiseAnyRejectElementFunction, dataHolder, index,
+        UndefinedHandleValue);
     if (!rejectFunc) {
       return false;
     }
Loading diff…