Missing ensureBallast() in wasm Ion JSPI emitters (emitResume/emitStoreSuspendParams) trips infallible-LifoAlloc assertion [@ FunctionCompiler::emitResume]
Categories
(Core :: JavaScript: WebAssembly, defect, P3)
Tracking
()
| Tracking | Status | |
|---|---|---|
| firefox-esr115 | --- | unaffected |
| firefox-esr140 | --- | unaffected |
| firefox151 | --- | wontfix |
| firefox152 | --- | wontfix |
| firefox153 | --- | fixed |
People
(Reporter: bugmon, Assigned: yury)
References
(Regression)
Details
(4 keywords)
Attachments
(4 files)
The WebAssembly JSPI / stack-switching MIR emitters in WasmIonCompile.cpp allocate an attacker-controlled number of MIR nodes inside per-parameter loops without ever calling mirGen().ensureBallast(). The wasm Ion TempAllocator is created infallible-by-default, so MIR nodes are allocated via an infallible LifoAlloc path; the engine-wide convention (15 call sites in this same file) is to call mirGen().ensureBallast() at the top of every loop that emits a module-controlled number of nodes, which pre-reserves a 16 KiB ballast chunk inside a temporary fallible scope. The two JSPI emitters FunctionCompiler::emitStoreSuspendParams (loop creating one MWasmStoreStackResult per suspend-tag param) and FunctionCompiler::emitResume (per-handler/per-param loops creating MWasmStackResultArea slots and MWasmStackResult nodes) omit this call entirely.
As a result, a single wasm 'suspend' or 'resume' op whose tag carries a large number of parameters causes ~N MIR nodes to be allocated within one op's infallible-allocation window. This overflows the current LifoAlloc chunk past the reserved ballast; the next node forces an infallible new-chunk allocation, which trips MOZ_ASSERT(fallibleScope_, "[OOM] Cannot allocate a new chunk in an infallible scope.") in LifoAlloc::newChunkWithCapacity (js/src/ds/LifoAlloc.cpp:164) and aborts the process. This is reachable from untrusted wasm: JSPI/StackSwitching is gated with 'flag fuzz enable = true' in js/public/WasmFeatures.h, so it is available under --fuzzing-safe and can be enabled purely from script (setPrefValue('wasm_stack_switching', true)); reaching the Ion path additionally requires the optimizing tier (setJitCompilerOption('wasm.baseline', 0), since baseline rejects the stack-switching opcodes).
This is a violated allocator invariant in the wasm Ion compiler, but it is a debug-build/assertion finding: on release and ASAN builds the MOZ_ASSERT is compiled out and LifoAlloc::newChunkWithCapacity simply allocates the new chunk, so there is no out-of-bounds access, use-after-free, or memory corruption in shipping builds. It is a correctness/robustness (DoS-class) defect, fixed by adding mirGen().ensureBallast() to the per-parameter loops, matching the rest of the file.
Build Info
- Branch: main
- Revision: 1f7030c8de8f2b349c7d91d7b5a3253c109a1cc1
- Timestamp: 2026-05-21T16:50:48+00:00
Affected Code
File: js/src/wasm/WasmIonCompile.cpp, line 9657-9675
// The rest are suspendTagParams, again in reverse order.
for (uint32_t i = 0; i < suspendTagParams.length(); i++) {
size_t reverseIndex = suspendTagParams.length() - i - 1;
ValType handlerParam = suspendTagParams[reverseIndex];
MWasmStackResultArea::StackResult loc(paramsAreaOffset,
handlerParam.toMIRType());
MDefinition* param = suspendParams[reverseIndex];
MWasmStoreStackResult* store = MWasmStoreStackResult::New(
alloc(), paramsArea, paramsAreaOffset, param); // <-- no ensureBallast() in this loop
if (!store) {
return false;
}
curBlock_->add(store);
paramsAreaOffset = loc.endOffset();
}
return true;
}
File: js/src/wasm/WasmIonCompile.cpp, line 9895-9910
DefVector resultDefs;
size_t suspendLabelParams = suspendTagParams.length() + 1;
for (uint32_t i = 0; i < suspendLabelParams; i++) {
size_t stackResultIndex = currentResultsAreaIndex - i - 1;
MWasmStackResult* stackResult =
MWasmStackResult::New(alloc(), handlersResultArea, stackResultIndex); // line 9901: no ensureBallast()
if (!stackResult || !resultDefs.append(stackResult)) {
return false;
}
curBlock_->add(stackResult);
}
if (!br(handler.labelDepth(), resultDefs)) {
return false;
}
File: js/src/wasm/WasmIonCompile.cpp, line 9108-9119
// Convention used everywhere else in this file: ensureBallast() at the top
// of every loop that emits a module-controlled number of MIR nodes.
for (uint32_t i = 0; i < numElements; i++) {
if (!mirGen().ensureBallast()) {
return false;
}
// ... allocate MIR nodes ...
}
The JSPI emitters allocate a module-controlled number of MIR nodes per iteration without ensureBallast(), unlike every other variable-count allocating loop in the file.
Exploit Chain
- Craft a wasm module that uses the JSPI / stack-switching proposal: define a cont type (cont $ft) and a tag whose parameter list is large (e.g. 500 i64 params).
- Define a function $f that pushes the tag's params and executes 'suspend $tag', and an exported function that does 'cont.new' + 'resume $ct (on $tag L)'.
- From JavaScript, enable the feature with setPrefValue('wasm_stack_switching', true) (permitted under --fuzzing-safe) and force the optimizing tier with setJitCompilerOption('wasm.baseline', 0).
- Construct the module with new WebAssembly.Module(bytes); this synchronously invokes the Ion wasm compiler (IonCompileFunctions -> RootCompiler::generate -> emitBodyExprs).
- Compiling the 'suspend'/'resume' op enters emitStoreSuspendParams / emitResume, which allocate ~500 MIR nodes in a per-parameter loop without calling ensureBallast().
- The infallible LifoAlloc ballast (16 KiB) is exhausted mid-loop; the next infallible allocation requests a new chunk, hitting MOZ_ASSERT(fallibleScope_) and aborting the process (debug/fuzzing builds).
Steps to Reproduce
- Use the debug JS shell: /firefox/obj-js-debug/dist/bin/js (assertions enabled).
- Run the testcase jspi_ensureballast.js (it self-enables stack switching and the Ion tier in-script, so the evaluator's default --fuzzing-safe is sufficient; no extra flags needed).
- Observe: Assertion failure: fallibleScope_ ([OOM] Cannot allocate a new chunk in an infallible scope.) at js/src/ds/LifoAlloc.cpp:164, with the crash stack landing in FunctionCompiler::emitResume (WasmIonCompile.cpp:9901) / emitStoreSuspendParams (WasmIonCompile.cpp:9666).
- Note: the ASAN/release build (/firefox/obj-firefox-asan/dist/bin/js) compiles the module without crashing because the MOZ_ASSERT is compiled out and the infallible allocation simply grows the LifoAlloc.
Security Impact
- Severity: Low
- Attacker capability: Untrusted wasm (e.g. from a web page) that reaches the optimizing wasm tier can crash the JS engine's debug/fuzzing build by submitting a module whose JSPI suspend/resume tag carries a large parameter list, aborting on a violated allocator invariant in the wasm Ion compiler. On release/ASAN builds the assertion is compiled out and the allocation succeeds, so there is no memory corruption, OOB access, or use-after-free in shipping builds; impact is limited to a correctness/robustness (DoS-class) defect and to crashing assertion-enabled builds.
- Preconditions: JSPI / stack-switching must be enabled (gated by the wasm_stack_switching pref; carries 'flag fuzz enable = true' so it is available under --fuzzing-safe) and the optimizing (Ion) wasm compiler must be used (baseline rejects the stack-switching opcodes). No memory-corruption primitive is obtained; the MOZ_ASSERT is debug-only.
ASAN Report
Assertion failure: fallibleScope_ ([OOM] Cannot allocate a new chunk in an infallible scope.), at js/src/ds/LifoAlloc.cpp:164
#1 js::LifoAlloc::newChunkWithCapacity(unsigned long, bool) js/src/ds/LifoAlloc.cpp:163
#2 js::LifoAlloc::getOrCreateChunk(unsigned long) js/src/ds/LifoAlloc.cpp:219
#3 js::LifoAlloc::allocImplColdPath(unsigned long) js/src/ds/LifoAlloc.cpp:229
#5 js::LifoAlloc::allocInfallible(unsigned long) js/src/ds/LifoAlloc.h:897
#6 js::jit::TempAllocator::allocateInfallible(unsigned long) js/src/jit/JitAllocPolicy.h:42
#8 js::jit::MInstruction::operator new(unsigned long, js::jit::TempAllocator&) js/src/jit/MIR.h:1047
#9 js::jit::MWasmStackResult::New<...>(...) js/src/jit/MIR-wasm.h:1947
#10 (anonymous namespace)::FunctionCompiler::emitResume() js/src/wasm/WasmIonCompile.cpp:9901
#11 (anonymous namespace)::FunctionCompiler::emitBodyExprs() js/src/wasm/WasmIonCompile.cpp:10513
#12 (anonymous namespace)::RootCompiler::generate() js/src/wasm/WasmIonCompile.cpp:11320
#13 js::wasm::IonCompileFunctions(...) js/src/wasm/WasmIonCompile.cpp:11475
#14 ExecuteCompileTask(...) js/src/wasm/WasmGenerator.cpp:627
#16 js::wasm::ModuleGenerator::finishFuncDefs() js/src/wasm/WasmGenerator.cpp:860
#17 DecodeCodeSection<...>(...) js/src/wasm/WasmCompile.cpp:976
#19 js::WasmModuleObject::construct(JSContext*, unsigned int, JS::Value*) js/src/wasm/WasmJS.cpp:1968
UndefinedBehaviorSanitizer:DEADLYSIGNAL
==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x000000000000 in MOZ_CrashSequence(void*, long) /firefox/obj-js-debug/dist/include/mozilla/Assertions.h:261:3 (deliberate crash from the failed MOZ_ASSERT)
SUMMARY: UndefinedBehaviorSanitizer: SEGV in MOZ_CrashSequence
| Reporter | ||
Comment 1•11 days ago
|
||
| Reporter | ||
Comment 2•11 days ago
|
||
| Reporter | ||
Comment 3•11 days ago
|
||
Updated•11 days ago
|
Comment 5•11 days ago
|
||
Might be the same issue as bug 2042726.
Comment 7•11 days ago
|
||
This is an issue for stack-switching, not JS-PI. JS-PI uses this code but only ever uses a single suspend param and can't trigger this. JS-PI is enabled in nightly but stack switching is not.
Updated•11 days ago
|
Updated•11 days ago
|
Comment 8•10 days ago
|
||
Bug 2042765 is a similar issue elsewhere. Maybe worth fixing both at the same time.
| Assignee | ||
Comment 9•9 days ago
|
||
Comment 10•9 days ago
|
||
Comment 11•8 days ago
|
||
| bugherder | ||
Updated•7 days ago
|
Description
•