## https://sploitus.com/exploit?id=46CACC58-44DA-5127-AC4E-775083607CF1
# CVE-2026-15718 β who put ptrs in my wasm
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",
...)`).
- **CVE:** CVE-2026-15718 (CWE-763, release of invalid pointer)
- **Affected:** Firefox < 152.0.6, Firefox ESR < 140.13, Thunderbird < 140.13
- **Fixed in:** Firefox 152.0.6 / ESR 140.13 / Thunderbird 140.13, 2026-07-14
- **Upstream bug:** Mozilla Bug 2045443 (MFSA-2026-67)
- **Fix commit:** `4441102e62e8` ("Bug 2045443", one line)
## Root cause
`BaseCompiler::emitArrayFill()` (`js/src/wasm/WasmBaselineCompile.cpp`) emits
the `array.fill` fill loop without first calling `sync()`:
```diff
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:
- **loop ran at least once** (reftype elements): `stk_` is synced β every
operand lives in a machine-stack slot;
- **zero-trip** (`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).
## Primitive: plant/read slot technique
The desync is bidirectional across two frame-identical wasm functions:
- **plant** (`n = 1`, loop runs): the in-loop `sync()` *writes* a value into
the slot β a real ref (`addrOf`) or a controlled `i64` (`fakeobj` setup);
- **read** (`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).
## Exploit chain (poc/)
| 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`:
1. `plantSpray`/`plantCmd`/`plantFun`/`plantExt` + `leak` β addrOf for wasm
arrays, funcrefs and JS objects (externref).
2. `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.
3. Leak a JSFunction's native entry (+0x20), scan back page-wise for the
Mach-O magic (`0xfeedfacf`) β XUL base.
4. **Fully dynamic symbol resolution**: walk XUL's Mach-O load commands via the
arbitrary read (`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.
5. Forge a funcref (`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)`.
## Running
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):
```sh
# 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).
## Portability notes
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.
## Scope / honest limitations
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](https://github.com/SneakyNachos/CVE-2026-2796-and-CVE-2026-2768-escape-the-wasm-box)).
## Mitigations
- Update to Firefox / Thunderbird β₯ 152.0.6 (ESR β₯ 140.13).
- Defense in depth: `javascript.options.wasm=false` blocks the trigger vector.
## References
- [MFSA-2026-67](https://www.mozilla.org/security/advisories/mfsa2026-67/)
- [NVD: CVE-2026-15718](https://nvd.nist.gov/vuln/detail/CVE-2026-15718)
- Fix: https://github.com/mozilla-firefox/firefox/commit/4441102e62e8
## Disclaimer
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.