core-jmp core-jmpdeath of core jump

IonStack Part I: An Unsound IonBanana Peel in the Ion Compiler — Slipping Through Firefox’s SpiderMonkey JIT (CVE-2026-10702)

CVE-2026-10702 is a miscompilation bug in Firefox's SpiderMonkey Ion/Warp JIT. The MObjectToIterator instruction produced by scalar-replacing Object.keys() declares a load-only alias set, but it can actually allocate a fresh property buffer. Global value numbering trusts that model and reuses a now-dangling slots pointer, yielding a use-after-free that is escalated into addrof/fakeobj, a fake Uint8Array, and full arbitrary read/write in the renderer.

oxfemale August 1, 2026 17 min read 74 reads
Export PDF
IonStack Part I: An Unsound IonBanana Peel in the Ion Compiler — Slipping Through Firefox’s SpiderMonkey JIT (CVE-2026-10702)
Original text: “IonStack Part I: Unsound IonBanana Peel in Ion Compiler, Slipping Through Firefox’s SpiderMonkey JIT”Nebula Security, NebuSec (July 10, 2026). Code, tables and figures below are reproduced verbatim with attribution captions.

Executive Summary

CVE-2026-10702 is a JIT miscompilation bug in Firefox’s SpiderMonkey engine (the Ion/Warp optimizing tier). When Ion scalar-replaces an unescaped Object.keys(obj), it emits an MObjectToIterator instruction whose getAliasSet() claims the instruction only loads object fields and elements. In reality that instruction can trigger lazy property resolution on a function object, and resolving length/name/prototype can allocate a brand-new dynamic-slot (property) buffer. Because the alias set lies about the side effect, global value numbering and alias analysis conclude that nothing between two MSlots(obj) loads can change the slots pointer — so the second load is replaced by the first. After the buffer is reallocated, that reused pointer is dangling: a classic use-after-free born entirely from an incorrect semantic model rather than a memory bug in the usual sense.

The write-up walks the full chain: the SpiderMonkey tiering pipeline (CacheIR → WarpBuilder → MIR), the optimization passes whose soundness depends on accurate alias sets (scalar replacement, alias analysis, GVN), the exact instruction whose model is wrong, and the escalation from a dangling slots pointer to a full exploit. By pre-filling a function’s property buffer and abusing the reallocation to read a hidden-class (shape) pointer out of the stale buffer, the researchers build addrof and fakeobj, forge a fake Uint8Array, and obtain arbitrary read/write across the entire SpiderMonkey heap — enough for renderer code execution and, per the disclosure, a Tor Browser compromise. Mozilla fixed it in Firefox 151.0.3 by removing the bogus alias set and correcting resume-point handling.

Background

To see why a one-line alias set is catastrophic, you need the shape of SpiderMonkey’s JIT pipeline and the optimization passes whose correctness rests on instructions describing their own side effects honestly.

SpiderMonkey’s JIT Compiler

SpiderMonkey is a multi-tier engine. Code starts in an interpreter, is promoted to a Baseline tier as it warms up, and hot functions eventually reach WarpMonkey — the top tier — which compiles JavaScript to optimized machine code at run time. The higher tiers trade compilation cost for speed, and to do that safely they must reason about what each operation can and cannot do to memory.

CacheIR

CacheIR is a small, typed, linear IR specialized for compiling inline caches. Consider a trivial getter:

function f(o) {
  return o.x + 1;
}

Its bytecode is roughly:

GetArg 0          ; push argument o
GetProp "x"       ; compute o.x
Int8 1            ; push 1
Add               ; add the two values
Return

In the pure interpreter, GetProp is a generic property lookup:

CASE(GetProp) {
  ReservedRooted<Value> lval(&rootValue0, REGS.sp[-1]);
  MutableHandleValue res = REGS.stackHandleAt(-1);
  ReservedRooted<PropertyName*> name(&rootName0, script->getName(REGS.pc));
  if (!GetProperty(cx, lval, name, res)) {
    goto error;
  }
}
END_CASE(GetProp)

As a script warms up, MaybeEnterJit bumps a warm-up counter. Once the Baseline Interpreter threshold is hit, SpiderMonkey creates a JitScript, which owns an ICScript. The ICScript owns the IC entries and fallback stubs and is the main bridge between bytecode and CacheIR. Each IC entry initially points at a fallback stub for its operation type:

ICEntry.firstStub -> GetProp fallback stub

The Baseline code generator emits a call into the current IC for each property access:

bool BaselineCodeGen<Handler>::emit_GetProp() {
    frame.popRegsAndSync(1);

    if (!emitNextIC()) {
        return false;
    }

    frame.push(R0);
    return true;
}

emitNextIC generates a call to ICStubReg + ICStub::offsetOfStubCode(). When the entry is still a fallback stub, control reaches DoGetPropFallback, which attaches CacheIR to the stub. For a plain object, the attached CacheIR can look like:

GuardToObject        inputId 0
GuardShape           objId 0, shapeOffset 0
LoadFixedSlotResult  objId 0, offsetOffset 8
ReturnFromIC

CacheIR stubs chain together, so a single site can handle several object types by walking the stub list until a shape guard matches.

CacheIR Stub
CacheIR stub chains: an IC entry points at the first stub, and stubs chain to handle different object shapes. Source: original article.

Ion/Warp and MIR

WarpBuilder is the frontend to the Ion optimizing compiler. It converts bytecode into MIR (Mid-level Intermediate Representation) while consuming the already-collected CacheIR as a source of type information. Because the CacheIR is typed, WarpBuilder can emit the corresponding MIR instructions directly, weaving in type guards as it goes. The MIR is then run through a series of optimization passes — and a handful of those passes are exactly where this vulnerability lives.

WarpBuilder
WarpBuilder turns bytecode into MIR, using CacheIR as a type-information source. Source: original article.

Scalar Replacement

Scalar replacement removes object allocations when the JIT can prove the object never escapes. Take:

function f(a, b) {
  let o = {x: a, y: b};
  return o.x + o.y;
}

Normally o would be heap-allocated. If the JIT proves it does not escape — it is never returned or stored beyond the function — the allocation is elided and x and y survive as plain scalar values:

function f(a, b) {
  let x = a;
  let y = b;
  return x + y;
}

This is a big win for short-lived, function-local objects — including temporary objects and the temporary array produced by Object.keys.

Scalar Replacement Soundness

Scalar replacement is sound only when the JIT can guarantee the object does not escape. If it might escape, the engine must materialize the real allocation. Everything downstream depends on that guarantee holding.

Alias Analysis

Alias analysis decides whether two references can point at the same memory. Without an intervening call:

function f(o) {
  let a = o.x;
  let b = o.x;
  return a + b;
}

If the JIT proves o is unchanged between the two reads, the second read is redundant and reuses the first value. But add a call:

function f(o) {
  let a = o.x;
  g(o);
  let b = o.x;
  return a + b;
}

Now, unless the JIT can prove g(o) does not touch o.x, it must assume side effects and keep the second read. SpiderMonkey encodes this per-instruction as an AliasSet (in js/src/jit/MIR.h) — a memory-effect summary that is a bitmask plus a load/store bit:

AliasSet::None()
AliasSet::Load(flags)
AliasSet::Store(flags)

Concretely:

AliasSet::Load(AliasSet::ObjectFields)
// reads object metadata

AliasSet::Load(AliasSet::DynamicSlot)
// reads values from dynamic slots

AliasSet::Store(AliasSet::ObjectFields | AliasSet::DynamicSlot)
// writes/clobbers object metadata and dynamic slot values

AliasSet::Store(AliasSet::Any)
// unknown side effects; assume it may touch everything

An instruction that returns a Load alias set is a promise: “I read this category of memory and write nothing.” Passes downstream take that promise at face value.

Global Value Numbering

GVN removes redundant MIR computations. Pure arithmetic is the easy case:

Add1 = MAdd(a, b)
Add2 = MAdd(a, b)

Add2 is congruent to Add1 and can be eliminated. Memory operations are the subtle case:

load1 = MLoad(o, "x");
maybe_write_obj();
load2 = MLoad(o, "x");

GVN must not fold load2 into load1 if maybe_write_obj() can modify o.x. To decide, GVN consults alias analysis, which sets dependencies between instructions based on their alias sets. GVN then rewrites the graph using that aliasing information — so if an alias set is wrong, GVN’s decision is wrong too.

GVN Soundness

GVN must never replace a load with an earlier load unless it has proven that no instruction in between modifies the loaded memory. That proof is only as trustworthy as the alias sets it is built on.

The Vulnerability

The reordering and removal of critical instructions is sound only when the optimizer can prove the target code meets certain conditions. But what happens when the semantic model of an instruction — its own declared side effects — is simply not correct? Then every pass that trusts the model inherits the lie.

Starting from an innocent example

Consider a loop that looks utterly side-effect-free:

for (let k in obj) {
  console.log(obj.k);
}

At a glance, enumerating an object’s properties reads memory and writes nothing — and the optimizer shares that intuition. The catch is that not every property is materialized when an object is created. SpiderMonkey lazily resolves certain properties, such as a function’s name and length, on first access.

Now think about Object.keys(function). To build a key list it must access and resolve the function’s properties. All of these paths reach fun_enumerate, the enumerate hook in JSFunctionClassOps:

static const JSClassOps JSFunctionClassOps = {
    .enumerate = fun_enumerate,
    .resolve = fun_resolve,
    .mayResolve = fun_mayResolve,
    .trace = fun_trace,
};

Resolving length, name, and prototype goes through HasOwnProperty:

static bool fun_enumerate(JSContext* cx, HandleObject obj) {
  MOZ_ASSERT(obj->is<JSFunction>());

  RootedId id(cx);
  bool found;

  if (obj->as<JSFunction>().needsPrototypeProperty()) {
    id = NameToId(cx->names().prototype);
    if (!HasOwnProperty(cx, obj, id, &found)) {
      return false;
    }
  }

  if (!obj->as<JSFunction>().hasResolvedLength()) {
    id = NameToId(cx->names().length);
    if (!HasOwnProperty(cx, obj, id, &found)) {
      return false;
    }
  }

  if (!obj->as<JSFunction>().hasResolvedName()) {
    id = NameToId(cx->names().name);
    if (!HasOwnProperty(cx, obj, id, &found)) {
      return false;
    }
  }

  return true;
}

The lookup eventually reaches NativeLookupOwnPropertyInline:

template <AllowGC allowGC,
          LookupResolveMode resolveMode = LookupResolveMode::CheckResolve>
static MOZ_ALWAYS_INLINE bool NativeLookupOwnPropertyInline(
    JSContext* cx, typename MaybeRooted<NativeObject*, allowGC>::HandleType obj,
    typename MaybeRooted<jsid, allowGC>::HandleType id, PropertyResult* propp) {
  MOZ_ASSERT_IF(obj->getOpsLookupProperty(),
                obj->template is<ModuleEnvironmentObject>());

  // .........

  if (obj->getClass()->getResolve()) {
    if constexpr (!allowGC) {
      return false;
    } else {
      return CallResolveOp(cx, obj, id, propp);
    }
  }

  propp->setNotFound();
  return true;
}

If the shape does not already carry the property, the lookup calls the class’s resolve hook. For a JSFunction, CallResolveOp dispatches to fun_resolve:

static bool fun_resolve(JSContext* cx, HandleObject obj, HandleId id,
                        bool* resolvedp) {
  if (!id.isAtom()) {
    return true;
  }

  // .........

  bool isLength = id.isAtom(cx->names().length);
  if (isLength || id.isAtom(cx->names().name)) {
    MOZ_ASSERT(!IsInternalFunctionObject(*obj));

    RootedValue v(cx);

    if (isLength) {
        // .........
    }

    if (!NativeDefineDataProperty(cx, fun, id, v,
                                  JSPROP_READONLY | JSPROP_RESOLVING)) {
      return false;
    }
    // .........
  }

  return true;
}

So length, name, and prototype are resolved lazily: on first access the resolve hook calls NativeDefineDataProperty to actually define them. And here is the important consequence — when the object’s current property buffer is full, defining a new property allocates a new, larger buffer. Enumeration, which looks read-only, can therefore allocate memory.

The soundness of several optimizations rests on modeling instruction side effects correctly, and memory allocation is a side effect. When Ion scalar-replaces an unescaped Object.keys(obj) result, ObjectKeysReplacer rewrites it into an MObjectToIterator with skipRegistration_ set:

bool ObjectKeysReplacer::run(MInstructionIterator& outerIterator) {
  MBasicBlock* startBlock = arr_->block();

  objToIter_ = MObjectToIterator::New(alloc_, objectKeys()->object(), nullptr);
  objToIter_->setSkipRegistration(true);
  arr_->block()->insertBefore(arr_, objToIter_);

  // .........

  if (!graph_.alloc().ensureBallast()) {
    return false;
  }

  return true;
}

And the alias set for MObjectToIterator is:

AliasSet getAliasSet() const override {
  return skipRegistration_
             ? AliasSet::Load(AliasSet::ObjectFields | AliasSet::Element)
             : AliasSet::Store(AliasSet::Any);
}

When skipRegistration_ is true, the instruction advertises a load of object fields and elements. But as we just saw, MObjectToIterator can allocate a new property buffer — that is a store-like side effect, not a load. The alias set is a lie, and every pass that believes it is now unsound.

Exploit the Unsoundness

Turning the wrong alias set into a memory-corruption primitive is direct: because MObjectToIterator can reallocate the property buffer, any slots pointer computed before it and reused after it becomes stale. We want a MIR sequence that loads a dynamic-slot property before Object.keys and another after it:

  Start#0

  # Load some dynamic-slot property before Object.keys
  GuardMultipleShapesToOffset
  Slots#1 = MSlots(obj)
  LoadDynamicSlotFromOffset#2 = MLoadDynamicSlotFromOffset(Slots#1, offset1)

  # Scalar-replaced Object.keys(obj)
  ObjectToIterator#22 = MObjectToIterator(obj, skipRegistration=true)

  # Load another dynamic-slot property after Object.keys
  GuardMultipleShapesToOffset#3
  Slots#4 = MSlots(obj)
  LoadDynamicSlotFromOffset#5 = MLoadDynamicSlotFromOffset(Slots#4, offset2)

Both Slots#1 and Slots#4 load the object’s slots pointer and both depend only on Start#0, because the optimizer — trusting the load-only alias set — believes nothing between them changes the slots. GVN therefore treats Slots#4 as congruent to Slots#1 and eliminates it:

  Start#0

  # Load some dynamic-slot property before Object.keys
  GuardMultipleShapesToOffset
  Slots#1 = MSlots(obj)
  LoadDynamicSlotFromOffset#2 = MLoadDynamicSlotFromOffset(Slots#1, offset1)

  # Scalar-replaced Object.keys(obj)
  ObjectToIterator#22 = MObjectToIterator(obj, skipRegistration=true)

  # Load another dynamic-slot property after Object.keys
  GuardMultipleShapesToOffset#3
  LoadDynamicSlotFromOffset#5 = MLoadDynamicSlotFromOffset(Slots#1, offset2)

If MObjectToIterator#22 reallocated the property buffer, Slots#1 is now a dangling pointer — and the post-Object.keys load reads through it. That is the use-after-free.

Dynamic Slots

Like other JavaScript engines, SpiderMonkey splits an object’s storage into inline (fixed) slots and dynamic slots — the latter being a pointer to an out-of-line property buffer. Growing an object’s property count past its inline capacity allocates or reallocates that dynamic buffer, which is precisely the memory whose pointer we just made stale.

Unsoundness to unreal object

The goal is the classic pair of primitives: addrof (leak an object’s address) and fakeobj (materialize a JavaScript object at an attacker-chosen address). The obvious route — corrupting a typed array’s length or data pointer — is harder here because SpiderMonkey does not use pointer compression, so object pointers are less predictable and there is no cheap way to guess a spray address. An initial, reliable leak is needed first.

The trick is to pre-fill every slot in the target’s property buffer so it is exactly full. Then the reallocation is guaranteed on the next resolved property.

Property Access
Pre-filling all slots in the property buffer so the next resolved property forces a reallocation. Source: original article.

With the buffer full, MObjectToIterator‘s lazy property resolution allocates a fresh, larger buffer — leaving the old one free while the miscompiled code still holds a pointer into it.

Property Buffer Reallocation
MObjectToIterator triggers lazy resolution; a full buffer forces reallocation, leaving a stale pointer behind. Source: original article.

Because a function has three lazily-resolved properties to work with, resolving Function.name can be arranged so the stale read jumps just past nearby spray objects and lands on a hidden-class (shape) pointer — a controlled, high-value leak.

Hidden Class

As in other engines, SpiderMonkey uses hidden classes (shapes) to describe an object’s layout and type. Reading a shape pointer out of the stale buffer hands that value back to JavaScript, and from there the exploit interprets stale-buffer data as object pointers to fabricate fake objects. Crafting a fake Uint8Array with attacker-controlled data pointer and length turns the leak into arbitrary read/write across the whole SpiderMonkey heap — the standard springboard to renderer code execution.

Stale Buffer Out-of-Bounds Access
Reading a hidden-class pointer through the stale buffer, then crafting fake objects for arbitrary read/write. Source: original article.

Key Takeaways

  • The root cause is a wrong AliasSet: with skipRegistration_ set, MObjectToIterator claims to only load object fields/elements, but it can allocate a new dynamic-slot buffer.
  • Alias analysis and GVN trust that model, so a second MSlots(obj) is folded into an earlier one across the allocating instruction — producing a dangling slots pointer and a use-after-free.
  • Enumeration (Object.keys / for-in) on a function object is not side-effect-free: lazily resolving name/length/prototype can reallocate the property buffer.
  • Pre-filling the property buffer makes the reallocation deterministic; resolving Function.name then leaks a hidden-class pointer out of the freed buffer.
  • From that leak the exploit builds addrof/fakeobj, forges a fake Uint8Array, and gains full arbitrary read/write — enough for renderer RCE, and per the disclosure a Tor Browser compromise.
  • Mozilla fixed CVE-2026-10702 in Firefox 151.0.3 by removing the bogus alias set and fixing resume-point handling in the Object.keys scalar-replacement path.

Defensive Recommendations

  • Update Firefox. Ensure Firefox / Firefox ESR / Tor Browser are at or beyond the fixed builds (Firefox 151.0.3 and the corresponding ESR/Tor releases) across the fleet, and enforce auto-update.
  • Treat JIT bugs as critical. A single miscompilation yields arbitrary R/W in the content process; prioritize browser-engine advisories accordingly.
  • Model side effects honestly. For engine developers: any MIR instruction that can allocate, resolve lazily, or call user/native code must declare a Store (or Any) alias set — never a Load-only set. Audit instructions whose behavior is conditional on a flag like skipRegistration_.
  • Fuzz the optimizer’s soundness, not just crashes. Differential testing that compares interpreter vs. Ion results across Object.keys/for-in over exotic objects (functions, proxies, lazily-resolved properties) is well suited to catching alias-set regressions.
  • Layer defenses for high-risk users. Tor Browser’s “Safer”/”Safest” security levels disable JIT for many sites; recommend them where the performance hit is acceptable, and consider javascript.options.ion/baselinejit hardening in locked-down deployments.
  • Monitor for renderer exploitation. Unexpected content-process crashes, JIT-region anomalies, and suspicious child-process behavior are useful post-patch hunting telemetry.

Timeline

  • 2026-05-20 — Vulnerability, with exploitation, reported to Mozilla.
  • 2026-05-20 — Mozilla acknowledged and began investigation.
  • 2026-05-20 — Mozilla identified the root cause and completed a fix.
  • 2026-06-02 — Fix released in Firefox 151.0.3.
  • 2026-07-21 — Blog post published.

Mitigation

The fix refines how the MObjectToIterator / Object.keys path is modeled. The bogus getAliasSet() override is removed from MObjectToIterator:

--- a/js/src/jit/MIR.h
+++ b/js/src/jit/MIR.h
@@ -9348,22 +9348,16 @@ class MObjectToIterator : public MUnaryI
   }

   public:
   NativeIteratorListHead* enumeratorsAddr() const { return enumeratorsAddr_; }
   INSTRUCTION_HEADER(ObjectToIterator)
   TRIVIAL_NEW_WRAPPERS
   NAMED_OPERANDS((0, object))

-  AliasSet getAliasSet() const override {
-    return skipRegistration_
-               ? AliasSet::Load(AliasSet::ObjectFields | AliasSet::Element)
-               : AliasSet::Store(AliasSet::Any);
-  }
-
   bool wantsIndices() const { return wantsIndices_; }
   void setWantsIndices(bool value) { wantsIndices_ = value; }

   bool skipRegistration() const { return skipRegistration_; }
   void setSkipRegistration(bool value) { skipRegistration_ = value; }
 };

 class MPostIntPtrConversion : public MUnaryInstruction,

The recovery instruction’s alias set is pinned to none in the MIR op definitions:

--- a/js/src/jit/MIROps.yaml
+++ b/js/src/jit/MIROps.yaml
@@ -2370,16 +2370,17 @@
   can_recover: custom
   possibly_calls: true
   generate_lir: true

 - name: ObjectKeysFromIterator
   operands:
     iterator: Object
   result_type: Object
+  alias_set: none
   can_recover: true

 - name: LoadUnboxedScalar
   gen_boilerplate: false

 - name: LoadDataViewElement
   gen_boilerplate: false

And the resume point is stolen by MObjectToIterator rather than the recovery instruction in ObjectKeysReplacer::run:

--- a/js/src/jit/ScalarReplacement.cpp
+++ b/js/src/jit/ScalarReplacement.cpp
@@ -4113,16 +4113,17 @@ bool ObjectKeysReplacer::escapes(MElemen
   return false;
 }

 bool ObjectKeysReplacer::run(MInstructionIterator& outerIterator) {
   MBasicBlock* startBlock = arr_->block();

   objToIter_ = MObjectToIterator::New(alloc_, objectKeys()->object(), nullptr);
   objToIter_->setSkipRegistration(true);
+  objToIter_->stealResumePoint(arr_);
   arr_->block()->insertBefore(arr_, objToIter_);

   // Iterate over each basic block.
   for (ReversePostorderIterator block = graph_.rpoBegin(startBlock);
        block != graph_.rpoEnd(); block++) {
     if (mir_->shouldCancel("Scalar replacement of Object.keys array object")) {
       return false;
     }
@@ -4147,17 +4148,16 @@ bool ObjectKeysReplacer::run(MInstructio
       }
     }
   }

   assertSuccess();

   auto* forRecovery = MObjectKeysFromIterator::New(alloc_, objToIter_);
   arr_->block()->insertBefore(arr_, forRecovery);
-  forRecovery->stealResumePoint(arr_);
   arr_->replaceAllUsesWith(forRecovery);

   // We need to explicitly discard the instruction since it's marked as
   // effectful and we stole its resume point, which will trip assertion
   // failures later. We can't discard the instruction out from underneath
   // the iterator though, and we can't do the trick where we increment the
   // iterator at the top of the loop because we might discard the *next*
   // instruction, so we do this goofiness.

Users should update to the latest Firefox version.

Conclusion

CVE-2026-10702 is a textbook example of how a JIT’s correctness is only as strong as the honesty of its instruction models. There is no buffer overflow to spot and no obviously dangerous cast — just an MObjectToIterator that claims to read memory while it can quietly reallocate a property buffer. Alias analysis and GVN did exactly what they were designed to do; they were simply lied to, and the reward was a deterministic use-after-free that escalates to full arbitrary read/write and renderer compromise. The fix is small — delete the false alias set, pin the recovery op to none, and move the resume point — but the lesson is large: any optimizer instruction that can allocate or run resolve hooks must declare it, or every pass built on top becomes an exploit primitive. Mozilla’s SpiderMonkey team was credited for a fast, thorough response.

Original text: “IonStack Part I: Unsound IonBanana Peel in Ion Compiler, Slipping Through Firefox’s SpiderMonkey JIT” by Nebula Security at NebuSec. Disclosure followed Nebula Security’s standard 90+30 day policy; thanks were given to the SpiderMonkey team for their quick response and thorough investigation.

oxfemale Vulnerability research, reverse engineering, and exploit development.
// Discussion