← All issues

[7] Wasm OMG Tail Call F32 Spill Stack OOB Write

Severity: High | Component: JSC Wasm OMG JIT — tail call FPR spilling | a73d24d

Rated High because the observable effect is a deterministic 4-byte stack-adjacent write from JIT-generated code reachable via any Wasm module with F32 tail call arguments, and the adjacent data could include saved registers or control flow metadata — though the attacker's control over the written value (likely upper FPR bits, typically zeros on x86 SSE) limits the primitive's strength, projected with confidence 0.85.

F32 and F64 were both doing a double store when spilling in OMG tail calls, while F32 should be doing a float store to avoid writing out of bounds of the spill slot. No test as cannot reliably and observably test this temporary stack corruption.

Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

} else if (src.isFPR()) {
srcOffset = allocateSpill(dstType.width());
- if (dstType.width() <= Width::Width64)
- jit.storeDouble(src.fpr(), CCallHelpers::Address(MacroAssembler::stackPointerRegister, srcOffset));
- else
- jit.storeVector(src.fpr(), CCallHelpers::Address(MacroAssembler::stackPointerRegister, srcOffset));
+ auto dst = CCallHelpers::Address(MacroAssembler::stackPointerRegister, srcOffset);
+ if (dstType == Types::F32)
+ jit.storeFloat(src.fpr(), dst);
+ else if (dstType == Types::F64)
+ jit.storeDouble(src.fpr(), dst);
+ else {
+ ASSERT(dstType == Types::V128);
+ jit.storeVector(src.fpr(), dst);
+ }

In prepareForTailCallImpl, the FPR spilling path during OMG-tier Wasm tail calls previously used a single storeDouble (8-byte store) for all FPR types with width ≤ 64 bits, including F32. The fix splits this into three distinct cases: storeFloat (4 bytes) for Types::F32, storeDouble (8 bytes) for Types::F64, and storeVector (16 bytes) for Types::V128. The spill slot is allocated via allocateSpill(dstType.width()), which for F32 returns a 4-byte slot — so the old 8-byte storeDouble into a 4-byte slot wrote 4 bytes beyond the allocation.

WebAssembly tail calls (return_call / return_call_indirect) reuse the current stack frame for the callee. During tail call preparation, the JIT must temporarily spill live argument values to scratch stack space before rearranging them into the callee's expected locations. allocateSpill(width) reserves a stack region sized to the given width, and the store instruction must match that width exactly — storeFloat writes 4 bytes, storeDouble writes 8. FPR registers on modern architectures are 64 or 128 bits wide and can hold values of different floating-point widths; the register width and the memory store width are not the same.

The root cause is a width-based dispatch that conflated F32 and F64 because both fit in a 64-bit FPR. The spill slot allocation correctly used allocateSpill(dstType.width()), which allocates 4 bytes for F32. But the subsequent store used storeDouble (8 bytes) for any FPR value with width <= Width64, including F32. Every F32 tail-call argument spill therefore wrote 4 bytes past the end of its allocated slot. The extra 4 bytes overwrite whatever is adjacent — another spill slot, saved registers, or return metadata on the stack frame.

Notably, the constant-handling path just below in the same function already correctly distinguished F32 (Width32/storeFloat) from F64 (Width64/storeDouble). This asymmetry — where the register path was wrong but the constant path was right — strongly suggests the FPR path was written with a Width-based check that didn't account for the semantic difference between float and double FPR values.

🔒

Detailed vulnerability analysis & security impact assessment

Subscribe to read more

🔒

Pattern-based audit directions for variant discovery

Subscribe to read more