CVE-2026-20644
Overview
Background
- OMG JIT
- JavaScriptCore’s Optimizing (highest-tier) WebAssembly compiler; BBQ is the lower baseline Wasm tier, both of which emit native code that must respect calling conventions and register usage.
- Wasm tail call
- A return_call/return_call_indirect that reuses the caller’s frame for the callee, requiring the JIT to shuffle arguments over the existing frame and jump rather than call, which is where prepareForTailCallImpl operates.
- MacroAssembler scratch register
- A temporary register the assembler is allowed to use (jit.scratchRegister()); its identity and aliasing behavior differ by architecture, e.g. it can overlap argument registers on x86_64 but is a dedicated temp on ARM64.
- AllowMacroScratchRegisterUsage / DisallowMacroScratchRegisterUsage
- RAII guards that permit or forbid the assembler from using the scratch register in a code region, used to prevent scratch use from clobbering live inputs.
- calleeSaveRegisterAtOffsetList / emitRestore
- The set of callee-saved registers and their spill offsets for the compiled function; emitRestore(calleeSaves) reloads them to establish a valid frame before the current one is clobbered for the tail call.
Root Cause Analysis
This commit fixes architecture-specific scratch-register handling in the WebAssembly JITs. The central change is in WasmOMGIRGenerator.cpp’s prepareForTailCallImpl(), the code emitted to set up a WebAssembly tail call: it must shuffle the callee’s arguments into place over the current frame and then jump, which requires a scratch register (auto tmp = jit.scratchRegister()). The original code handled the scratch register with logic that is only correct for x86_64: on x64 the MacroAssembler scratch register is a general-purpose register that can alias one of the incoming argument registers, so it may need to be spilled to a stack slot (tmpNeedsSaving / tmpSpillOffsetRelativeToOriginalSP) before being clobbered and reloaded afterward, and after reloading it the code wraps the tail of the sequence in DisallowMacroScratchRegisterUsage because further scratch use could clobber an input. Applying this x64-shaped logic on other architectures (e.g. ARM64, where the assembler scratch is a dedicated data-temp that never aliases wasm argument or callee-save registers) was both unnecessary and unsound: the non-x64 path lacked the frame-restore step needed before the current frame is clobbered.
The patch conditionalizes the spilling logic behind #if CPU(X86_64); on the #else path it instead sets tmpNeedsSaving to a constexpr false, calls jit.emitRestore(calleeSaves) to ‘set up a valid frame so that we can clobber this one,’ and (under ASSERT_ENABLED) adds assertions that the scratch tmp never equals any argument GPR/FPR and is never a callee-save register. The DisallowMacroScratchRegisterUsage guard at the end is likewise restricted to x86_64. The saveSrc lambda’s capture list is changed from an explicit list to [=] so it compiles under both the runtime-bool (x64) and constexpr (non-x64) forms of tmpNeedsSaving/tmpSpillOffsetRelativeToOriginalSP. The second file, WasmBBQJIT.cpp, contains a related x86_64 scratch fix in a constant-operand float shaping branch (the diff’s nearest label is addI32Extend8S but the body does absFloat then a conditional sign flip): on x86_64 the sign negation is now done by moving the float to wasmScratchGPR, xor32 with the 0x80000000 sign bit, and moving back, instead of MacroAssembler negateFloat. The precise way the pre-fix non-x64 tail-call code corrupted state (which register got clobbered, and whether it led to memory corruption versus a controlled trap) is not spelled out in the diff, so that mechanism is an inference; what the diff establishes is that the scratch/frame handling was arch-incorrect off x86_64 and is now split per-CPU with a proper frame restore and debug asserts. The fuzzer-generated JSTests case (a large tail-call-heavy Wasm module invoked via fn5() under a watchdog) is the regression reproducer.
Attack Path
- Deliver a crafted Wasm module Serve web content that instantiates an attacker-authored WebAssembly module (as the test does via WebAssembly.instantiate of a base64 blob) containing functions that use return_call / return_call_indirect tail calls with argument layouts chosen to exercise the scratch-register/frame setup.
- Force OMG (or BBQ) compilation Invoke the tail-calling exports repeatedly (the test loops calling fn5()) so the affected function tiers up into the OMG optimizing JIT and prepareForTailCallImpl emits the buggy per-arch sequence on the target CPU.
- Trigger the arch-specific miscompile On a non-x86_64 target (e.g. Apple silicon/ARM64), the tail-call prologue clobbers the current frame without the frame restore the fix adds, or mishandles the scratch register, producing incorrect machine code during the argument shuffle.
- Reach corrupted state Executing the miscompiled tail call yields wrong register/stack contents; in the reproducer this surfaces as an unexpected process crash (guarded by the watchdog). Escalation beyond a crash is inference, not shown by the diff.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
prepareForTailCallImplSource/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp |
modified | Splits scratch-register handling by CPU: keeps the x86_64 tmp-spill/reload logic under #if CPU(X86_64), and on other arches uses constexpr tmpNeedsSaving=false, emits jit.emitRestore(calleeSaves) to build a valid frame before clobbering, and adds ASSERTs that the scratch never aliases args or callee-saves; also limits the trailing DisallowMacroScratchRegisterUsage to x86_64. |
saveSrc lambda (inside prepareForTailCallImpl)Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp |
modified | Capture list changed from an explicit [tmp, tmpNeedsSaving, ...] to [=] so it builds whether tmpNeedsSaving/tmpSpillOffset are runtime (x64) or constexpr (non-x64). |
BBQJIT float shaping branch (diff label addI32Extend8S; constant-operand abs/negate path)Source/JavaScriptCore/wasm/WasmBBQJIT.cpp |
modified | On x86_64, replaces negateFloat for the sign flip with an integer path: moveFloatTo32 into wasmScratchGPR, xor32 with the float -0.0 sign bit, then move32ToFloat back; non-x64 keeps negateFloat. |
Files Changed
JSTests/wasm/stress/omg-tail-call-clobber-scratch-register-2.jsSource/JavaScriptCore/wasm/WasmBBQJIT.cppSource/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp
Audit Directions
- Rest of prepareForTailCallImpl and tail-call codegenReview the whole prepareForTailCallImpl and its callers in WasmOMGIRGenerator.cpp for any remaining assumption that jit.scratchRegister() may/mustn’t alias inputs or that the frame is valid; grep for ‘scratchRegister’, ’tmpNeedsSaving’, ’emitRestore’, ‘AllowMacroScratchRegisterUsage’, and ‘DisallowMacroScratchRegisterUsage’.
- BBQ/OMG float sign/negate on x86_64Check other float operations that use negateFloat/absFloat around constant operands in WasmBBQJIT.cpp and WasmOMGIRGenerator.cpp for the same x64 issue the sign-flip fix addresses; grep for ’negateFloat’, ‘absFloat’, ‘moveFloatTo32’, and ‘0x80000000’.
- Other #if CPU(X86_64) vs generic register logic in Wasm JITsLook for register-allocation or spill code in the Wasm JITs that lacks per-CPU guards yet relies on x86_64-specific scratch/argument aliasing; grep across Source/JavaScriptCore/wasm for ‘CPU(X86_64)’, ‘wasmScratchGPR’, ‘scratchRegister’, and calleeSave handling to find un-conditionalized siblings.
- Cross-arch JIT scratch assumptions repo-wideSearch the broader assembler/JIT for places assuming the macro scratch register never (or always) aliases call arguments; grep for ‘scratchRegister()’ together with argument-shuffle or ValueRep/params loops in FTL and Wasm code, prioritizing ARM64-only or RISC paths.