Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionJIT miscompilation in the JavaScript Engine: JIT component
ComponentSpiderMonkey
Bug ClassLogic Error
Tracker2044606
Fix commit9a9c26f30a96 (firefox) +85/-0
CISA KEVNot listed
CreditedGary Kwong
Disclosed2026-07-21

Background

Scalar replacement
A SpiderMonkey Ion optimization that eliminates object allocations by replacing an object with its individual field values, recovering the object only if a bailout needs it.
`Object.keys` optimization
An Ion transform that lowers Object.keys(o) into an MObjectToIterator producing an internal PropertyIteratorObject, from which MObjectKeysFromIterator builds the keys array.
Resume point
An MResumePoint snapshot of the interpreter/baseline stack state at a bytecode boundary, used to reconstruct a baseline frame when Ion code bails out.
Bailout
The transition from optimized Ion code back to a Baseline frame, e.g. after an invalidation triggered by GC, during which stack slots are rematerialized from the resume point.

Root Cause Analysis

When Ion applied the Object.keys scalar-replacement optimization, ObjectKeysReplacer::run rewired uses of the keys array to the recovery instruction, but the MObjectToIterator call’s resume point still described the result slot with ResumeMode::ResumeAfter, so the slot was treated as an ordinary already-produced value. If that VM call bailed out (for instance an invalidation bailout caused by GC), the baseline frame’s result slot was rematerialized holding the internal PropertyIteratorObject rather than the JavaScript keys array the bytecode expects. The invariant violated is that a bailout must restore exactly the value the unoptimized bytecode would have produced at that point; here an engine-internal iterator object leaked into script-observable baseline state, causing a JIT miscompilation and type confusion.

The fix introduces ResumeMode::ResumeAfterObjectKeys: the replacer points the resume point operand at the iterator and marks the mode, and on bailout BaselineStackBuilder::buildExpressionStack calls ObjectKeysFromIterator to convert the iterator back into the proper keys array before it can be observed. This guarantees the internal PropertyIteratorObject is never exposed to the baseline frame regardless of when the bailout occurs.

Key insight
The single core mistake was that the scalar-replaced Object.keys path left a resume point describing the result slot as the finished keys array while the running code actually held an internal iterator, so a mid-operation bailout materialized the wrong object type. The fix tags that resume point with a dedicated ResumeMode::ResumeAfterObjectKeys and performs the iterator-to-array conversion during bailout, restoring the value the bytecode expects.

Attack Path

  1. Trigger the optimization Get Ion to compile a hot function that calls Object.keys on an object so the scalar-replacement optimization inserts the MObjectToIterator / MObjectKeysFromIterator pair.
  2. Force a bailout at the iterator Cause an invalidation bailout (e.g. via GC pressure) while the internal PropertyIteratorObject is in the result slot, before the keys array is built.
  3. Leak the internal object The baseline frame resumes with the internal PropertyIteratorObject sitting in the slot the bytecode believes contains the keys array.
  4. Exploit the type confusion Subsequent script operations treat the internal iterator as an ordinary array/object, mismatching the engine’s assumptions about that slot’s type.

Impact Assessment

An attacker running JavaScript in the content process gains a type confusion in which an engine-internal PropertyIteratorObject is exposed where a keys array is expected, a moderate-severity logic/miscompilation defect. It requires the attacker to drive Ion into compiling and scalar-replacing an Object.keys call and then to induce a bailout (such as through GC) at the vulnerable point. The confusion is confined to the JavaScript engine within the process executing the attacker’s script.

Changed Functions

FunctionChangeNotes
if
js/src/jit/BaselineBailouts.cpp
modified
if
js/src/jit/MIR.cpp
modified
for
js/src/jit/MIR.cpp
modified
for
js/src/jit/ScalarReplacement.cpp
modified

Files Changed

  • js/src/jit/BaselineBailouts.cpp
  • js/src/jit/IonTypes.h
  • js/src/jit/MIR.cpp
  • js/src/jit/MIR.h
  • js/src/jit/ScalarReplacement.cpp

Audit Directions

  • Scalar-replacement recovery slots
    Audit every optimization that leaves an engine-internal object in a slot recovered on bailout, confirming the resume point restores the script-observable value and not the internal placeholder.
  • New `ResumeMode` values
    Whenever a ResumeMode case is added, verify it is handled consistently in IsResumeAfter, ResumeModeToString, and every buildExpressionStack-style bailout consumer.
  • Resume point operand rewiring
    Review code that calls replaceOperand or replaceAllUsesWith around resume points to ensure the operand’s type after bailout matches what the resumed bytecode expects.
diff --git a/js/src/jit/BaselineBailouts.cpp b/js/src/jit/BaselineBailouts.cpp
index 6e2bc4f8146..a7db5affa23 100644
--- a/js/src/jit/BaselineBailouts.cpp
+++ b/js/src/jit/BaselineBailouts.cpp
@@ -25,12 +25,14 @@
 #include "jit/RematerializedFrame.h"
 #include "jit/SharedICRegisters.h"
 #include "jit/Simulator.h"
+#include "jit/VMFunctions.h"
 #include "js/friend/StackLimits.h"  // js::AutoCheckRecursionLimit, js::ReportOverRecursed
 #include "js/Utility.h"
 #include "proxy/ScriptedProxyHandler.h"
 #include "util/Memory.h"
 #include "vm/ArgumentsObject.h"
 #include "vm/BytecodeUtil.h"
+#include "vm/Iteration.h"
 #include "vm/JitActivation.h"
 
 #include "jit/JitFrames-inl.h"
@@ -918,6 +920,25 @@ bool BaselineStackBuilder::buildExpressionStack() {
     }
   }
 
+  if (resumeMode() == ResumeMode::ResumeAfterObjectKeys) {
+    JitSpew(JitSpew_BaselineBailouts,
+            "      Converting Object.keys iterator to keys array");
+    // The result slot holds the internal PropertyIteratorObject produced by the
+    // Object.keys scalar-replacement optimization. Convert it back to the keys
+    // array so the internal iterator is never exposed to the baseline frame.
+    Value iterVal;
+    if (peekLastValue(&iterVal) && !iterVal.isMagic(JS_OPTIMIZED_OUT)) {
+      MOZ_RELEASE_ASSERT(iterVal.isObject());
+      MOZ_RELEASE_ASSERT(iterVal.toObject().is<PropertyIteratorObject>());
+      RootedObject iterObj(cx_, &iterVal.toObject());
+      JSObject* keys = ObjectKeysFromIterator(cx_, iterObj);
+      if (!keys) {
+        return false;
+      }
+      valuePointerAtStackOffset(0).set(ObjectValue(*keys));
+    }
+  }
+
   return true;
 }
 
diff --git a/js/src/jit/IonTypes.h b/js/src/jit/IonTypes.h
index 8c42cb63263..9de8e5574e1 100644
--- a/js/src/jit/IonTypes.h
+++ b/js/src/jit/IonTypes.h
@@ -795,6 +795,15 @@ enum class ResumeMode : uint8_t {
   // of a proxy get trap aligns with what the spec requires.
   ResumeAfterCheckProxyGetResult,
 
+  // Innermost frame. Resume at the next bytecode op when bailing out, but the
+  // value in the result slot is the internal PropertyIteratorObject created by
+  // the Object.keys scalar-replacement optimization instead of the keys array.
+  // On bailout we convert it back to the keys array so the internal iterator is
+  // never exposed to the baseline frame. This is used when the
+  // MObjectToIterator
+  // VM call bails out (e.g. an invalidation bailout caused by GC).
+  ResumeAfterObjectKeys,
+
   // Innermost frame. Resume at the current bytecode op when bailing out.
   ResumeAt,
 
@@ -826,6 +835,8 @@ inline const char* ResumeModeToString(ResumeMode mode) {
       return "ResumeAfterCheckIsObject";
     case ResumeMode::ResumeAfterCheckProxyGetResult:
       return "ResumeAfterCheckProxyGetResult";
+    case ResumeMode::ResumeAfterObjectKeys:
+      return "ResumeAfterObjectKeys";
   }
   MOZ_CRASH("Invalid mode");
 }
@@ -835,6 +846,7 @@ inline bool IsResumeAfter(ResumeMode mode) {
     case ResumeMode::ResumeAfter:
     case ResumeMode::ResumeAfterCheckIsObject:
     case ResumeMode::ResumeAfterCheckProxyGetResult:
+    case ResumeMode::ResumeAfterObjectKeys:
       return true;
     default:
       return false;
diff --git a/js/src/jit/MIR.cpp b/js/src/jit/MIR.cpp
index 0daa5f95bef..dbf9fa5f222 100644
--- a/js/src/jit/MIR.cpp
+++ b/js/src/jit/MIR.cpp
@@ -714,6 +714,16 @@ void MInstruction::stealResumePoint(MInstruction* other) {
   setResumePoint(resumePoint);
 }
 
+bool MInstruction::copyResumePointFrom(TempAllocator& alloc,
+                                       MInstruction* previous) {
+  MResumePoint* rp = previous->resumePoint_->clone(alloc);
+  if (!rp) {
+    return false;
+  }
+  setResumePoint(rp);
+  return true;
+}
+
 void MInstruction::moveResumePointAsEntry() {
   MOZ_ASSERT(isNop());
   block()->clearEntryResumePoint();
@@ -4412,6 +4422,19 @@ MResumePoint* MResumePoint::New(TempAllocator& alloc, MBasicBlock* block,
   return resume;
 }
 
+MResumePoint* MResumePoint::clone(TempAllocator& alloc) {
+  MResumePoint* resume = new (alloc) MResumePoint(block(), pc_, mode_);
+  size_t n = this->numOperands();
+  if (!resume->operands_.init(alloc, n)) {
+    return nullptr;
+  }
+  for (size_t i = 0; i < n; i++) {
+    resume->initOperand(i, getOperand(i));
+  }
+  resume->stores_.copy(this->stores_);
+  return resume;
+}
+
 MResumePoint::MResumePoint(MBasicBlock* block, jsbytecode* pc, ResumeMode mode)
     : MNode(block, Kind::ResumePoint),
       pc_(pc),
diff --git a/js/src/jit/MIR.h b/js/src/jit/MIR.h
index 8e909e049f7..d1973605eaa 100644
--- a/js/src/jit/MIR.h
+++ b/js/src/jit/MIR.h
@@ -1065,6 +1065,9 @@ class MInstruction : public MDefinition, public InlineListNode<MInstruction> {
 
   void setResumePoint(MResumePoint* resumePoint);
   void stealResumePoint(MInstruction* other);
+  // Copy resume point from the previous instruction.
+  [[nodiscard]] bool copyResumePointFrom(TempAllocator& alloc,
+                                         MInstruction* previous);
 
   void moveResumePointAsEntry();
   void clearResumePoint();
@@ -8878,6 +8881,9 @@ class MResumePoint final : public MNode
  public:
   static MResumePoint* New(TempAllocator& alloc, MBasicBlock* block,
                            jsbytecode* pc, ResumeMode mode);
+  // NOTE: instruction_ is left null; call setResumePoint to associate the
+  // clone with an instruction.
+  [[nodiscard]] MResumePoint* clone(TempAllocator& alloc);
 
   MBasicBlock* block() const { return resumePointBlock(); }
 
@@ -8927,6 +8933,7 @@ class MResumePoint final : public MNode
     instruction_ = nullptr;
   }
   ResumeMode mode() const { return mode_; }
+  void setMode(ResumeMode mode) { mode_ = mode; }
 
   void releaseUses() {
     for (size_t i = 0, e = numOperands(); i < e; i++) {
diff --git a/js/src/jit/ScalarReplacement.cpp b/js/src/jit/ScalarReplacement.cpp
index 140b90eb8db..8bcdbff5b89 100644
--- a/js/src/jit/ScalarReplacement.cpp
+++ b/js/src/jit/ScalarReplacement.cpp
@@ -4491,6 +4491,28 @@ bool ObjectKeysReplacer::run(MInstructionIterator& outerIterator) {
 
   auto* forRecovery = MObjectKeysFromIterator::New(alloc_, objToIter_);
   arr_->block()->insertBefore(arr_, forRecovery);
+
+  auto* nop = MNop::New(alloc_);
+  arr_->block()->insertBefore(arr_, nop);
+  if (!nop->copyResumePointFrom(alloc_, objToIter_)) {
+    return false;
+  }
+
+  {
+    // Use the PropertyIteratorObject in the resume point for the
+    // MObjectToIterator instruction. If this instruction calls a VM function
+    // that triggers an invalidation, we use ResumeMode::ResumeAfterObjectKeys
+    // to create the array from the iterator object when we bail out.
+    MResumePoint* rp = objToIter_->resumePoint();
+    size_t n = rp->numOperands() - 1;
+    for (size_t i = 0; i < n; i++) {
+      MOZ_RELEASE_ASSERT(rp->getOperand(i) != arr_);
+    }
+    MOZ_RELEASE_ASSERT(rp->getOperand(n) == arr_);
+    rp->replaceOperand(n, objToIter_);
+    MOZ_RELEASE_ASSERT(rp->mode() == ResumeMode::ResumeAfter);
+    rp->setMode(ResumeMode::ResumeAfterObjectKeys);
+  }
   arr_->replaceAllUsesWith(forRecovery);
 
   // We need to explicitly discard the instruction since it's marked as
Loading diff…