← All issues

[6] Wasm OMG Tail Call Scratch Register Corruption on ARM

Severity: High | Component: JSC Wasm OMG JIT — tail call implementation | 4572dd4

Rated High because the observable effect is deterministic register corruption during tail call setup on ARM platforms (iOS, Apple Silicon macOS), and the projected escalation to controlled argument substitution is assessed with confidence 0.82 — the exact implicit scratch usage by ARM macro assembler instructions is well-known but not directly provable from the diff.

The OMG tailcall patchpoint uses the scratch register. Currently, the scratch is not clobbered early because on x64 we exhaust all registers if we do so. Because of that, prepareTailCallImpl has special handling for saving and restoring the scratch if it happens to alias one of the inputs. This special save and restore has issues on ARM as the stack pointer arithmetic itself may use the scratch, which complicates the restoring. This PR makes the tail call patchpoint code architecture-specific to confine the save/restore complexity to x64.

Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

AllowMacroScratchRegisterUsage allowScratch(jit);
auto tmp = jit.scratchRegister();
 
+#if CPU(X86_64)
+ // On x64, the scratch register may alias one of the inputs and needs special saving.
+ //
+ // Be careful not to clobber this below.
+ // We also need to make sure that we preserve this if it is used by the patchpoint body.
bool tmpNeedsSaving = false;
int tmpSpillOffsetRelativeToOriginalSP = 0;
...
jit.storePtr(tmp, CCallHelpers::Address(MacroAssembler::stackPointerRegister, tmpSpillOffsetRelativeToOriginalSP));
}
+#else
+ constexpr bool tmpNeedsSaving = false;
+ constexpr int tmpSpillOffsetRelativeToOriginalSP = 0;
+
+ // Set up a valid frame so that we can clobber this one.
+ jit.emitRestore(calleeSaves);
+
+#if ASSERT_ENABLED
+ for (unsigned i = 0; i < params.size(); ++i) {
+ auto arg = params[i];
+ if (arg.isGPR()) {
+ ASSERT(!calleeSaves.find(arg.gpr()));
+ ASSERT(arg.gpr() != tmp);
+ continue;
+ }
+ ...
+ }
+ ASSERT(!calleeSaves.find(tmp));
+#endif // ASSERT_ENABLED
+#endif // CPU(X86_64)
...
- // Nothing after restoring tmp can use the scratch register since it might clobber an input.
{
+#if CPU(X86_64)
+ // On x64, nothing after restoring tmp can use the scratch register since it might clobber an input.
DisallowMacroScratchRegisterUsage disallowScratch(jit);
+#endif
jit.addPtr(MacroAssembler::TrustedImm32(newSPAtPrologueOffsetFromSP), MacroAssembler::stackPointerRegister);

The fix makes prepareForTailCallImpl architecture-specific. On non-x64 (ARM), the scratch register is now clobbered early (callee saves are restored before argument shuffling), so it can never alias a patchpoint input. The DisallowMacroScratchRegisterUsage guard after the scratch restore is also made x64-only. On the non-x64 path, ASSERT_ENABLED guards verify the invariant: no patchpoint input aliases the scratch register, and no callee save register conflicts with the scratch. The x64 path retains the existing save/restore logic because x64 cannot afford to clobber the scratch early (it would exhaust available GPRs).

WebAssembly tail calls (return_call / return_call_indirect) allow a function to transfer control to another function by reusing the current stack frame rather than pushing a new one. 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 — because source and destination slots may overlap (a permutation problem).

The OMG tier implements tail calls via patchpoints — late-bound code generation callbacks that emit platform-specific machine code after register allocation. prepareForTailCallImpl shuffles arguments from the current frame into the caller's frame positions, adjusting the stack pointer. A patchpoint's params list contains the register assignments chosen by the B3 register allocator for the patchpoint's inputs.

On ARM, the macro assembler may implicitly use a designated scratch register (typically ip0/ip1) for address materialization and arithmetic that does not fit in a single instruction. This implicit usage is invisible at the JIT emission level — a jit.addPtr(TrustedImm32(largeValue), sp) may internally use the scratch to materialize the immediate, corrupting whatever value the scratch held.

The root cause is that prepareForTailCallImpl used a single codepath for all architectures to handle the scratch register during tail call setup. The scratch register could alias one of the tail call's input arguments (assigned by the B3 register allocator). On x64, this was handled by special save/restore logic — spilling the scratch's value before clobbering it and restoring it afterward. On ARM, this save/restore approach was insufficient because ARM's stack pointer arithmetic itself may use the scratch register internally via the macro assembler. When the scratch aliased an input argument, the stack pointer adjustment (addPtr with a large immediate to SP) would corrupt the scratch, and subsequent code that tried to use the original input value from it would read a corrupted value.

The fix correctly splits the implementation: ARM clobbers the scratch early by restoring callee saves before argument shuffling, which guarantees that the register allocator cannot have assigned the scratch to any patchpoint input (since callee saves and the scratch are now restored before the shuffling code runs). The assertion guards on the non-x64 path (ASSERT(arg.gpr() != tmp)) make the invariant machine-checkable.

🔒

Detailed vulnerability analysis & security impact assessment

Subscribe to read more

🔒

Pattern-based audit directions for variant discovery

Subscribe to read more