Crash [@ ??] - 32-bit wasm memory64 bounds-check bypass in Ion (wasmBoundsCheck64 with AboveOrEqual) with --spectre-mitigations=off [145.0a]
Categories
(Core :: JavaScript: WebAssembly, defect)
Tracking
()
People
(Reporter: decoder, Unassigned)
References
Details
(4 keywords)
Attachments
(1 file)
|
2.08 KB,
application/x-javascript
|
Details |
Summary
On 32-bit targets (x86-32 and ARM32), the wasm Ion compiler's 64-bit heap bounds check is silently bypassed whenever the high 32 bits of an i64 memory index are non-zero and Spectre index masking is disabled. The index is then truncated to its low 32 bits and used directly as the effective address, giving content-controlled out-of-bounds read and write relative to the wasm heap base — i.e. arbitrary in-process memory access from web content on 32-bit Firefox.
The issue is specific to 32-bit because only the 32-bit backends implement wasmBoundsCheck64 by decomposing the 64-bit compare into a "high word == 0?" pre-check followed by a 32-bit compare; 64-bit backends do a single 64-bit compare that handles both polarities correctly.
The wasm baseline compiler always uses cond == Below, so baseline is unaffected; this gives a clean differential between --wasm-compiler=baseline and --wasm-compiler=ion.
Root cause
CodeGenerator::visitWasmBoundsCheck64 / visitWasmBoundsCheckInstanceField64 (js/src/jit/CodeGenerator.cpp) lower MWasmBoundsCheck like this:
if (JitOptions.spectreIndexMasking) {
Label ok;
masm.wasmBoundsCheck64(Assembler::Below, ptr, boundsCheckLimit, &ok);
masm.wasmTrap(wasm::Trap::OutOfBounds, mir->trapSiteDesc());
masm.bind(&ok);
} else {
auto* ool = new (alloc()) LambdaOutOfLineCode([=](OutOfLineCode& ool) {
masm.wasmTrap(wasm::Trap::OutOfBounds, mir->trapSiteDesc());
});
addOutOfLineCode(ool, mir);
masm.wasmBoundsCheck64(Assembler::AboveOrEqual, ptr, boundsCheckLimit,
ool->entry());
}
The x86 implementation at the affected revision (js/src/jit/x86/MacroAssembler-x86.cpp:1978):
void MacroAssembler::wasmBoundsCheck64(Condition cond, Register64 index,
Register64 boundsCheckLimit,
Label* label) {
Label ifFalse;
cmp32(index.high, Imm32(0));
j(Assembler::NonZero, &ifFalse);
wasmBoundsCheck32(cond, index.low, boundsCheckLimit.low, label);
bind(&ifFalse);
}
This is correct only for cond == Below, where label is the OK label and the trap immediately follows the call. When called with cond == AboveOrEqual (the !spectreIndexMasking Ion path), label is the OOL trap. If index.high != 0 the code jumps to ifFalse which is bound after the inner check, so control falls through into the regular access path without trapping. The same bug exists in the Address overload and in js/src/jit/arm/MacroAssembler-arm.cpp.
After the (skipped) bounds check, MWasmWrapU32Index (emitted under #ifndef JS_64BIT in js/src/wasm/WasmIonCompile.cpp::checkOffsetAndAlignmentAndBounds()) truncates the index to its low 32 bits, and the load/store executes at heapBase + low32(index). Since low32(index) is fully attacker-controlled, on 32-bit this covers the entire 4 GB address space.
Reproduction
Tested with 32-bit JS shell mozilla-central 20250918-cca03a5f7adf (build m-c-fuzzing-asan-opt32, x86 linux, fuzzing-asan-opt). Testcase attached as poc.js.
$ m-c-fuzzing-asan-opt32/dist/bin/js --fuzzing-safe --wasm-compiler=baseline --spectre-mitigations=off poc.js
uncaught exception: RuntimeError: index out of bounds # correct
$ m-c-fuzzing-asan-opt32/dist/bin/js --fuzzing-safe --wasm-compiler=ion --spectre-mitigations=off poc.js
# load at 0x1_0000_0000n succeeds (returns memory[0]), then OOB-write spray, then crash
poc.js first performs i32.load at i64 index 0x1_0000_0000 (high=1, low=0). Per spec this must trap; with the bug it returns memory[0]. It then sprays i64.store of 0x4141414141414141 at every page offset from 0x20000 upward, corrupting unrelated heap objects.
Crash signature (ASan opt 32-bit)
AddressSanitizer:DEADLYSIGNAL
==2868649==ERROR: AddressSanitizer: SEGV on unknown address 0x414141cd (pc 0x6776eb6b bp 0xffae61f8 sp 0xffae61d0 T0)
==2868649==The signal is caused by a READ memory access.
#0 0x6776eb6b (js+0x1ea6b6b)
#1 0x67dfb82c (js+0x253382c)
...
#8 0xdb82004c ([anon:js-executable-memory]+0x4c)
==2868649==Register values:
eax = 0x00000000 ebx = 0x6b860358 ecx = 0x414141cd edx = 0x41414141
ecx = 0x414141cd and edx = 0x41414141 are the sprayed bytes, demonstrating that the OOB writes overwrote a live pointer that was subsequently dereferenced. The debug build (m-c-debug32) crashes with SIGSEGV (no assertion fires first; the access happens entirely in JIT code).
Impact / configuration
- 32-bit x86 and ARM32 only; Ion-compiled wasm only (baseline unaffected).
- memory64 is shipped on by default; SharedArrayBuffer not required.
- Requires
JitOptions.spectreIndexMasking == false. The browser prefjavascript.options.spectre.index_maskingdefaults totrueon all tier-1 platforms (StaticPrefList.yaml:9999), so the vulnerableAboveOrEqualpath is not reachable in a default browser configuration → markingunsupported-config/sec-moderate. (Note: the JS shell default forspectreIndexMaskingis nowfalseinJitOptions.cpp:309, so the shell hits this without flags, but the browser overrides it.)
Suggested fix
In MacroAssembler::wasmBoundsCheck64 (x86 and arm), select the high-word branch target based on cond: when cond == AboveOrEqual and index.high != 0, jump to label (the OOL trap) instead of falling through. This appears to have already landed on mozilla-central as commit 04e2701b51d4 (Bug 2010050) with regression test js/src/jit-test/tests/wasm/regress/bug2010050.js; this report is filed for tracking against the older affected build and may be a duplicate of that bug.
Secondary finding (separate root cause)
poc2-atomics-wait.js (also attached) demonstrates an independent 32-bit-specific issue in js/src/wasm/WasmInstance.cpp:446 (Instance::PerformWait) that needs no non-default flags and reproduces in both baseline and Ion: byteOffset + sizeof(ValT) is computed in size_t, which on 32-bit wraps for byteOffset = 0xFFFFFFFC, bypassing the bounds check and reading 4–8 bytes immediately before the SAB data buffer (UBSan: SharedMem.h:95:27: addition of unsigned offset overflowed). This was incidentally fixed by commit 04932a1ae0ba (Bug 2020378), which rewrote the check as memSizeBytes < sizeof(ValT) || byteOffset > memSizeBytes - sizeof(ValT).
| Reporter | ||
Comment 1•4 months ago
|
||
| Reporter | ||
Updated•4 months ago
|
| Reporter | ||
Updated•4 months ago
|
Comment 2•4 months ago
|
||
I'm pretty sure this is a duplicate of bug 2010050. The current source of wasmBoundsCheck64 on x86 [1] is different than the OP:
void MacroAssembler::wasmBoundsCheck64(Condition cond, Register64 index,
Register64 boundsCheckLimit,
Label* label) {
MOZ_ASSERT(cond == Assembler::AboveOrEqual || cond == Assembler::Below);
Label rejoin;
Label* failLabel = cond == Assembler::AboveOrEqual ? label : &rejoin;
cmp32(index.high, Imm32(0));
j(Assembler::NonZero, failLabel);
wasmBoundsCheck32(cond, index.low, boundsCheckLimit.low, label);
bind(&rejoin);
}
I'll have to double check the PoC.
Updated•4 months ago
|
Comment 3•4 months ago
|
||
This a duplicate of bug 2010050 and doesn't reproduce anymore.
Comment 4•4 months ago
|
||
(In reply to Christian Holler (:decoder) from comment #0)
Tested with 32-bit JS shell
mozilla-central 20250918-cca03a5f7adf(buildm-c-fuzzing-asan-opt32, x86 linux, fuzzing-asan-opt). Testcase attached aspoc.js.
I'm curious why you're testing such old code? Testing old ESR branches for fixes we should have uplifted, or for unexpected problems caused by the fixes we DID uplift, is worth considering given our limited user and QA testing on ESR, but nightly 143.0a confuses me. Honestly curious: you do everything with great thought so I feel like there is something here I might need to consider in my own work.
This appears to have already landed on mozilla-central as commit
04e2701b51d4(Bug 2010050) with regression testjs/src/jit-test/tests/wasm/regress/bug2010050.js; this report is filed for tracking against the older affected build and may be a duplicate of that bug.
This is a duplicate, but closing it as such will remove any tracking on that older build if there's some special reason to be tracking it.
If you're going to test old branches it would be helpful to have your tool set the "Version" field to match the cycle you're testing rather than "Trunk" which implies something close to the open date. You could get the dates from product-details.mozilla.org, for example the major release dates and then add 2 to get the nightly version. There are also beta dates but I couldn't find explicit nightly start dates. They do show up at https://whattrainisitnow.com/calendar/#Past_branch_dates, though, so you could ask Pascal if there's a source for that or if they are derived from the other dates. Or scrape that page.
| Reporter | ||
Comment 5•3 months ago
|
||
(In reply to Daniel Veditz [:dveditz] from comment #4)
(In reply to Christian Holler (:decoder) from comment #0)
Tested with 32-bit JS shell
mozilla-central 20250918-cca03a5f7adf(buildm-c-fuzzing-asan-opt32, x86 linux, fuzzing-asan-opt). Testcase attached aspoc.js.I'm curious why you're testing such old code?
The issue here was stale builds in fuzzfetch due to targets being moved when linux x86 was deprecated. This has been fixed.
Updated•3 months ago
|
Description
•