
PoC exploit chain for CVE-2026-15718: SpiderMonkey wasm baseline compiler array.fill missing-sync -> invalid pointer -> addrOf/fakeobj -> arbitrary R/W -> RCE
Proof-of-concept exploit chain for CVE-2026-15718, a missing-sync()
miscompilation in Mozilla SpiderMonkey's WebAssembly baseline compiler
("JavaScript: WebAssembly" component). A single zero-trip array.fill yields a
fakeobj primitive; the chain escalates to arbitrary read/write of the host
process and arbitrary native code execution (posix_spawnp("/bin/sh", ...)).
4441102e62e8 ("Bug 2045443", one line)BaseCompiler::emitArrayFill() (js/src/wasm/WasmBaselineCompile.cpp) emits
the fill loop without first calling :
array.fillsync() if (elementType.isRefRepr()) {
freePtr(RegPtr(PreBarrierReg));
}
+ sync();
+
// Perform the fill loop using `numElements` as the loop variable ...
sync() flushes the baseline compiler's value stack (stk_) from registers to
the machine stack. The fill loop's per-iteration imprecise post-barrier
(emitPostBarrierEdgeImprecise) calls sync() internally — but only if the
loop runs. The loop's exit label (done) is a join of two paths:
stk_ is synced — every
operand lives in a machine-stack slot;numElements == 0): stk_ is untouched — operands still live
only in registers.The compiler's static model after the join always reflects the synced path, so
code emitted after array.fill reads operands from stack slots that a
zero-trip execution never wrote. A reftype operand read back this way is an
invalid (stale) pointer (CWE-763).
The desync is bidirectional across two frame-identical wasm functions:
n = 1, loop runs): the in-loop sync() writes a value into
the slot — a real ref (addrOf) or a controlled i64 (fakeobj setup);n = 0, zero-trip): the slot is never written; the twin function
reads the stale slot back — as i64 (leak) or as (ref $t) (fakeobj).| File | Stage | Result |
|---|---|---|
poc-min.js (+ gen_trigger.py) | desync demo | control path returns the correct value; zero-trip path returns a garbage pointer read from the never-written slot |
chain.js (+ gen_chain.py, chain.tpl.js, build.sh) | full chain | addrOf + fakeobj → fake WasmArrayObject → arbitrary R/W → XUL base leak → runtime Mach-O import walk → forged funcref → posix_spawnp("/bin/sh", ["-c", "touch /tmp/CVE-2026-15718-PWNED"]) |
Stage details in chain.js:
plantSpray/plantCmd/plantFun/plantExt + leak — addrOf for wasm
arrays, funcrefs and JS objects (externref).sprayV + fakeRd/fakeWr — fakeobj; fake WasmArrayObject
(numElements_ @+16, data_ @+24, inline elements @+40) with the fake
window kept in an out-of-line (malloc'd) array so GC cannot move it
mid-scan.0xfeedfacf) → XUL base.LC_SEGMENT_64/LC_SYMTAB/LC_DYSYMTAB + indirect symbol
table) to locate _posix_spawnp's __stubs entry. No hardcoded offsets;
the stub performs lazy binding on first call.typeDef @+0x40 copied from a real ref.func, call target
@+0x38 = stub address) and invoke it through call_ref with
posix_spawnp(pid, "/bin/sh", 0, 0, [sh, -c, cmd], 0).Tested with an xpcshell (or the js shell / browser) built from a
pre-152.0.6 tree (the reference build here is Firefox 149.0a1, 2026-01-14):
# minimal desync demo
xpcshell poc/poc-min.js
# full chain (regenerates chain.js from the template + generator)
(cd poc && sh build.sh)
rm -f /tmp/CVE-2026-15718-PWNED
xpcshell poc/chain.js # creates /tmp/CVE-2026-15718-PWNED
ls -l /tmp/CVE-2026-15718-PWNED
The harness sets javascript.options.wasm_optimizingjit=false to pin the
baseline tier during the long Mach-O scan (the bug is baseline-only; in the
browser the trigger runs well under the tier-up threshold so this is not
needed there). tools/xul_slots.py is an offline validator that lists XUL's
named stub/GOT slots (used to cross-check the runtime walker).
Offsets are for macOS arm64, this exact tree/build
(WasmArrayObject { +16 numElements, +24 data_, +40 inline },
JSFunction native @ +0x20, WasmFuncRef { +0x38/+0x48 call target, +0x40
typeDef }). All of them are re-derived at runtime by the chain's
self-tests; only the structure layouts are assumed. No PAC concerns (XUL is an
arm64, non-arm64e binary); the lazy-binding stub resolves the target on first
call, so no dyld-shared-cache offsets are needed.
This PoC runs in an unsandboxed shell process. In a real Firefox attack
the chain lands inside the content process, where the macOS sandbox denies
posix_spawnp — a separate OS-sandbox-escape bug is needed for a full
compromise (see e.g. the stage-2 analysis in
CVE-2026-2796-and-CVE-2026-2768-escape-the-wasm-box).
javascript.options.wasm=false blocks the trigger vector.For security research, education, and defensive testing only. The vulnerability is patched in current Firefox/Thunderbird releases. Do not use against systems you do not own or have explicit authorization to test.