Medium CVSS 6.5 webkit Type Confusion 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to an unexpected process crash
ComponentJSC Wasm
Bug ClassType Confusion
Tracker303444
Fix commit4572dd488e4e (WebKit/WebKit) +86/-5
CWECWE-119, CWE-416, CWE-787, CWE-120 (Buffer bounds error, Use-after-free, Out-of-bounds write, Buffer overflow)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
CreditedHanQing from TSDubhe and Nan Wang (@eternalsakura13)
Disclosed2026-02-11

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.

Key insight
The bug is an architecture-portability defect in the Wasm tail-call prologue: scratch-register spill/frame-clobber logic written for x86_64’s aliasing scratch register was applied unchanged on other CPUs that need a frame restore and different guarantees instead, and the fix cleanly separates the x86_64 and non-x86_64 code paths and adds a proper emitRestore plus aliasing assertions.

Attack Path

  1. 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.
  2. 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.
  3. 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.
  4. 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

Classified LogicError / medium, the demonstrated effect is an unexpected crash of the WebContent (renderer) process, reachable from unprivileged web content that merely instantiates and calls a crafted Wasm module with tail calls, and it is architecture-dependent (the non-x86_64 path is the one being corrected). A JIT that clobbers the current frame or a live input register during a tail-call shuffle is in principle a strong corruption primitive (attacker-influenced values landing in wrong registers/stack slots), so escalation from crash toward controlled memory corruption is conceivable but is inference; the commit and reproducer only establish a crash. Impact is contained to the WebContent sandbox with no indicated sandbox escape.

Changed Functions

FunctionChangeNotes
prepareForTailCallImpl
Source/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.js
  • Source/JavaScriptCore/wasm/WasmBBQJIT.cpp
  • Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp

Audit Directions

  • Rest of prepareForTailCallImpl and tail-call codegen
    Review 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_64
    Check 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 JITs
    Look 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-wide
    Search 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.

Original Bug Report

The reporter's bug is still restricted on the tracker.