Closed Bug 2029313 Opened 7 months ago Closed 7 months ago

UAF: compacting GC does not fix up Wasm ArrayDataPointer slots on JSPI-suspended stacks

Categories

(Core :: JavaScript: WebAssembly, defect)

defect

Tracking

()

RESOLVED DUPLICATE of bug 2008358

People

(Reporter: bugmon, Unassigned)

Details

(6 keywords)

Attachments

(4 files)

Wasm-Ion is allowed to keep the interior WasmArrayObject::data_ pointer live across calls because MWasmCallBase::wasmCallAliasSet() explicitly excludes WasmArrayDataPointer from the call alias set, on the documented assumption that "the GC will rewrite any array data pointers on move". These cached interior pointers are recorded in stack maps as StackMap::Kind::ArrayDataPointer and are supposed to be patched by Instance::updateFrameForMovingGC() after a moving GC.

However, updateFrameForMovingGC() is only invoked from jit::UpdateJitActivationsForMinorGC and jit::UpdateJitActivationsForCompactingGC, both of which iterate JitActivationIterator(cx) — i.e. only the activations currently on the main thread's activation chain. WebAssembly JS Promise Integration (JSPI) suspended stacks are not on that chain; their frames are traced via SuspenderObject::trace -> TraceSuspendableStack -> Instance::traceFrame(), which traces GC references but never calls updateFrameForMovingGC(). Consequently, when GCRuntime::compactPhase (Compacting.cpp) relocates a WasmArrayObject whose data is stored inline, the cached interior data pointer in a suspended Ion frame is left pointing into a freed/relocated arena.

After the freed arena is recycled for new GC cells, resuming the suspended wasm stack performs array.set through the stale interior pointer, overwriting fields of unrelated live objects with attacker-chosen 64-bit values. In the PoC the overwritten object is a (array (mut anyref)), so the next GC traces the corrupted element 0x4141414141414140 as a JSObject* and crashes. The stdout line result: 4141414141414140 (the value read back through the stale pointer after the spray) confirms the freed slot was reallocated before the wasm write, i.e. this is a true use-after-free write, not merely a write to poisoned memory.

Build Info

Affected Code

File: js/src/gc/Compacting.cpp, line 822-836

void GCRuntime::updateRuntimePointersToRelocatedCells(AutoGCSession& session) {
  ...
  traceRuntimeForMajorGC(&trc, session);

  jit::UpdateJitActivationsForCompactingGC(rt);  // misses JSPI suspended stacks
  ...
}

File: js/src/jit/JitFrames.cpp, line 1539-1553

void UpdateJitActivationsForCompactingGC(JSRuntime* rt) {
  MOZ_ASSERT(JS::RuntimeHeapIsMajorCollecting());
  Nursery& nursery = rt->gc.nursery();
  JSContext* cx = rt->mainContextFromOwnThread();
  for (JitActivationIterator activations(cx); !activations.done();
       ++activations) {
    for (JitFrameIter iter(activations->asJit()); !iter.done(); ++iter) {
      if (iter.isWasm()) {
        const wasm::WasmFrameIter& frame = iter.asWasm();
        frame.instance()->updateFrameForMovingGC(
            frame, frame.resumePCinCurrentFrame(), nursery);
      }
    }
  }
}

File: js/src/jit/MIR-wasm.h, line 2037-2044

static AliasSet wasmCallAliasSet() {
  // This is ok because:
  // - numElements is immutable
  // - the GC will rewrite any array data pointers on move
  AliasSet exclude = AliasSet(AliasSet::WasmArrayNumElements) |
                     AliasSet(AliasSet::WasmArrayDataPointer);
  return AliasSet::Store(AliasSet::Any) & ~exclude;
}

File: js/src/wasm/WasmPI.cpp, line 209-218

void SuspenderObject::trace(JSTracer* trc, JSObject* obj) {
  SuspenderObject& suspender = obj->as<SuspenderObject>();
  if (!suspender.isTraceable() || trc->isTenuringTracer()) {
    return;
  }
  TraceSuspendableStack(trc, &suspender);  // traces GC refs only; never calls updateFrameForMovingGC
}

Ion keeps interior wasm array data pointers live across calls on the assumption that the GC fixes them up via updateFrameForMovingGC. That fixup is driven only by JitActivationIterator over the current activation chain; JSPI suspended stacks are traced separately via TraceSuspendableStack which calls traceFrame() but never updateFrameForMovingGC(). After compacting GC relocates a WasmArrayObject with inline storage, the suspended frame retains a dangling interior pointer.

Exploit Chain

  1. Compile a wasm module with Ion (--wasm-compiler=optimizing) containing a function that loads a (ref (array (mut i64))), performs array.set, calls a Suspending import, then performs more array.set/array.get on the same local. Ion caches the interior data_ pointer across the call as a StackMap::ArrayDataPointer slot.
  2. Heap-groom so the target WasmArrayObject (small, inline-data) lands in a tenured arena that is mostly garbage and thus eligible for relocation during a shrinking compacting GC.
  3. Invoke the function via WebAssembly.promising(); inside, the Suspending import returns a pending Promise, suspending the wasm stack. The suspended Ion frame now holds the raw interior data_ pointer.
  4. Trigger a shrinking compacting GC. GCRuntime::compactPhase relocates the WasmArrayObject; updateRuntimePointersToRelocatedCells -> UpdateJitActivationsForCompactingGC walks only the live activation chain and skips the suspended stack, so the ArrayDataPointer slot is not updated and now points into a freed arena.
  5. Spray the GC heap with victim objects (e.g. (array (mut anyref)) of identical alloc-kind size) so the freed arena is reallocated and the stale pointer overlaps a live victim's element storage.
  6. Resolve the Promise to resume the suspended wasm stack. The post-call array.set instructions store attacker-chosen 64-bit values through the stale interior pointer, overwriting the victim anyref array's elements.
  7. The corrupted anyref element is later traced/used as a JSObject*, yielding type confusion on a fully attacker-controlled pointer. With suitable grooming this provides an arbitrary read/write primitive in the content process.

Steps to Reproduce

  1. Build SpiderMonkey (ASAN or debug) from current mozilla-central.
  2. Run: js --fuzzing-safe --setpref=wasm_js_promise_integration=true --wasm-compiler=optimizing poc.js
  3. Observe stdout prints 'result: 4141414141414140' (proof the freed slot was reallocated before the wasm write), then a SEGV in GC marking dereferencing 0x4141414141414140 as a JSObject*.

Security Impact

  • Severity: High
  • Attacker capability: Use-after-free write of attacker-controlled 64-bit values into reallocated tenured GC cells, leading to type confusion (controlled JSObject* / AnyRef) and ultimately arbitrary read/write and code execution in the content process.
  • Preconditions: WebAssembly JS Promise Integration must be enabled (javascript.options.wasm_js_promise_integration, currently default-off in release but a standards-track feature heading toward shipping) and the wasm function must be compiled with the optimizing (Ion) compiler. A compacting/shrinking major GC must occur while the wasm stack is suspended; this can be induced from content via memory pressure.

ASAN Report

AddressSanitizer:DEADLYSIGNAL
=================================================================
==723==ERROR: AddressSanitizer: SEGV on unknown address (pc 0x57c9ca53da0e bp 0x7ffff8fb4170 sp 0x7ffff8fb4160 T0)
==723==The signal is caused by a READ memory access.
==723==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 0x57c9ca53da0e in js::gc::detail::ChunkPtrHasStoreBuffer(void const*) /firefox/obj-firefox-asan/dist/include/js/HeapAPI.h:719:38
    #1 0x57c9ca53da0e in js::gc::IsInsideNursery(js::gc::Cell const*) /firefox/obj-firefox-asan/dist/include/js/HeapAPI.h:726:10
    #2 0x57c9ca53da0e in js::gc::Cell::isTenured() const /firefox/js/src/gc/Cell.h:163:54
    #3 0x57c9ca53da0e in bool ShouldMark<JSObject>(js::GCMarker*, JSObject*) /firefox/js/src/gc/Marking.cpp:344:15
    #4 0x57c9ca53da0e in void js::gc::MarkingTracerT<0u>::onEdge<JSObject>(JSObject**, char const*) /firefox/js/src/gc/Marking.cpp:856:8
    #5 0x57c9ca53da0e in js::GenericTracerImpl<js::gc::MarkingTracerT<0u>>::onObjectEdge(JSObject**, char const*) /firefox/obj-firefox-asan/dist/include/js/TracingAPI.h:221:3
    #6 0x57c9ca4fad61 in js::gc::TraceEdgeInternal(JSTracer*, JSObject**, char const*) /firefox/js/src/gc/Tracer.h:114:1
    #7 0x57c9ca4fad61 in auto bool TraceTaggedPtrEdge<js::wasm::AnyRef>(JSTracer*, js::wasm::AnyRef*, char const*)::'lambda'(js::wasm::AnyRef)::operator()<JSObject*>(js::wasm::AnyRef) const /firefox/js/src/gc/Marking.cpp:699:10
    #8 0x57c9ca4fad61 in auto js::MapGCThingTyped<bool TraceTaggedPtrEdge<js::wasm::AnyRef>(JSTracer*, js::wasm::AnyRef*, char const*)::'lambda'(js::wasm::AnyRef)>(js::wasm::AnyRef const&, js::wasm::AnyRef&&) /firefox/js/src/wasm/WasmAnyRef.h:390:28
    #9 0x57c9ca4fad61 in bool TraceTaggedPtrEdge<js::wasm::AnyRef>(JSTracer*, js::wasm::AnyRef*, char const*) /firefox/js/src/gc/Marking.cpp:698:16
    #10 0x57c9ca4fad61 in js::gc::TraceEdgeInternal(JSTracer*, js::wasm::AnyRef*, char const*) /firefox/js/src/gc/Marking.cpp:729:10
    #11 0x57c9cbd11a5e in void js::TraceManuallyBarrieredEdge<js::wasm::AnyRef>(JSTracer*, js::wasm::AnyRef*, char const*) /firefox/js/src/gc/Tracer.h:273:3
    #12 0x57c9cbd11a5e in js::WasmArrayObject::obj_trace(JSTracer*, JSObject*) /firefox/js/src/wasm/WasmGcObject.cpp:292:5
    #13 0x57c9ca53718b in JSClass::doTrace(JSTracer*, JSObject*) const /firefox/obj-firefox-asan/dist/include/js/Class.h:667:5
    #14 0x57c9ca53718b in bool js::GCMarker::callOrDelayTraceHook<0u>(JSObject*, JSClass const*, JS::SliceBudget&) /firefox/js/src/gc/Marking.cpp:1845:10
    #15 0x57c9ca53718b in bool js::GCMarker::processMarkStackTop<0u>(JS::SliceBudget&) /firefox/js/src/gc/Marking.cpp:1758:29
    #16 0x57c9ca5365fa in bool js::GCMarker::markOneColor<0u, (js::gc::MarkColor)2>(JS::SliceBudget&) /firefox/js/src/gc/Marking.cpp:1368:10
    #17 0x57c9ca510a89 in bool js::GCMarker::doMarking<0u>(JS::SliceBudget&, js::gc::ShouldReportMarkTime) /firefox/js/src/gc/Marking.cpp:1335:29
    #18 0x57c9ca4dba0e in js::gc::GCRuntime::markSynchronously(JS::SliceBudget&, js::gc::GCRuntime::ParallelMarking, js::gc::ShouldReportMarkTime) /firefox/js/src/gc/GC.cpp:3516:16
    #19 0x57c9ca4db342 in js::gc::GCRuntime::markPhase(JS::SliceBudget&) /firefox/js/src/gc/GC.cpp:3471:3
    #20 0x57c9ca4e1171 in js::gc::GCRuntime::incrementalSlice(JS::SliceBudget&, JS::GCReason, bool) /firefox/js/src/gc/GC.cpp:4336:11
    #21 0x57c9ca4e62dc in js::gc::GCRuntime::gcCycle(bool, JS::SliceBudget const&, JS::GCReason) /firefox/js/src/gc/GC.cpp:4946:3
    #22 0x57c9ca4e7be2 in js::gc::GCRuntime::collect(bool, JS::SliceBudget const&, JS::GCReason) /firefox/js/src/gc/GC.cpp:5136:9
    #23 0x57c9ca4ec5e6 in js::gc::GCRuntime::gc(JS::GCOptions, JS::GCReason) /firefox/js/src/gc/GC.cpp:5216:3
    #24 0x57c9ca4ec5e6 in JS::NonIncrementalGC(JSContext*, JS::GCOptions, JS::GCReason) /firefox/js/src/gc/GCAPI.cpp:300:21
    #25 0x57c9c9e2ee53 in GC(JSContext*, unsigned int, JS::Value*) /firefox/js/src/builtin/TestingFunctions.cpp:789:3
    #26 0x57c9cb539e1a in CallJSNative(JSContext*, bool (*)(JSContext*, unsigned int, JS::Value*), js::CallReason, JS::CallArgs const&) /firefox/js/src/vm/Interpreter.cpp:490:13
    #27 0x57c9cb539e1a in js::InternalCallOrConstruct(JSContext*, JS::CallArgs const&, js::MaybeConstruct, js::CallReason) /firefox/js/src/vm/Interpreter.cpp:586:12
    #28 0x57c9ca7cc91b in js::jit::DoCallFallback(JSContext*, js::jit::BaselineFrame*, js::jit::ICFallbackStub*, unsigned int, JS::Value*, JS::MutableHandle<JS::Value>) /firefox/js/src/jit/BaselineIC.cpp:1701:10
    #29 0x340809f0d823  (<unknown module>)

==723==Register values:
rax = 0x4141414141400000  rbx = 0x4141414141414140  rcx = 0x0828282828280000  rdx = 0x000057c9c817a4e0  
rdi = 0x00007a4aa76e0130  rsi = 0x00007ffff8fb41c0  rbp = 0x00007ffff8fb4170  rsp = 0x00007ffff8fb4160  
 r8 = 0x000057c9c7fb600c   r9 = 0x0000000000000000  r10 = 0x00007ffff8fb4601  r11 = 0x1000000000000000  
r12 = 0x00001d4d188c44e0  r13 = 0x00001d4d188c44e0  r14 = 0x00000fffff1f6830  r15 = 0x000003a9a311889c  
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV /firefox/obj-firefox-asan/dist/include/js/HeapAPI.h:719:38 in js::gc::detail::ChunkPtrHasStoreBuffer(void const*)
==723==ABORTING

--- stdout ---
result: 4141414141414140
Attached file poc.js —
Attached file flags.txt —
Attached patch fix.patch — — Splinter Review
Attached file crash_stack.txt —
Group: core-security → javascript-core-security

This might be a duplicate?

Component: JavaScript: GC → JavaScript: WebAssembly
Status: UNCONFIRMED → RESOLVED
Closed: 7 months ago
Duplicate of bug: 2008358
Resolution: --- → DUPLICATE

Yes, this is a duplicate. I can confirm that patches that fix that bug also fix this one.

Group: javascript-core-security
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Created:
Updated:
Size: