CVE-2026-16392
Overview
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 anMObjectToIteratorproducing an internalPropertyIteratorObject, from whichMObjectKeysFromIteratorbuilds the keys array. - Resume point
- An
MResumePointsnapshot 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.
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
- Trigger the optimization
Get Ion to compile a hot function that calls
Object.keyson an object so the scalar-replacement optimization inserts theMObjectToIterator/MObjectKeysFromIteratorpair. - Force a bailout at the iterator
Cause an invalidation bailout (e.g. via GC pressure) while the internal
PropertyIteratorObjectis in the result slot, before the keys array is built. - Leak the internal object
The baseline frame resumes with the internal
PropertyIteratorObjectsitting in the slot the bytecode believes contains the keys array. - 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
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
| Function | Change | Notes |
|---|---|---|
ifjs/src/jit/BaselineBailouts.cpp |
modified | |
ifjs/src/jit/MIR.cpp |
modified | |
forjs/src/jit/MIR.cpp |
modified | |
forjs/src/jit/ScalarReplacement.cpp |
modified |
Files Changed
js/src/jit/BaselineBailouts.cppjs/src/jit/IonTypes.hjs/src/jit/MIR.cppjs/src/jit/MIR.hjs/src/jit/ScalarReplacement.cpp
Audit Directions
- Scalar-replacement recovery slotsAudit 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` valuesWhenever a
ResumeModecase is added, verify it is handled consistently inIsResumeAfter,ResumeModeToString, and everybuildExpressionStack-style bailout consumer. - Resume point operand rewiringReview code that calls
replaceOperandorreplaceAllUsesWitharound resume points to ensure the operand’s type after bailout matches what the resumed bytecode expects.
Patch
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