Released July 29, 2025

CVE-2025-43213

CVSS 6.5 severity medium CWE-119CWE-120 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H

Overview

Impact:

Description: Processing maliciously crafted web content may lead to an unexpected Safari crash

Researchers: Google V8 Security Team

Key insight. The JS-to-Wasm entry thunk must present a valid boxed callee in the frame’s Callee slot before any call that can trigger a stack walk (operationJSToWasmEntryWrapperBuildFrame), and must load/cage the Wasm memory registers only after the frame is built — the fix is purely a reordering plus save/restore of the Callee slot to keep the frame walkable and the memory registers fresh.

Patch Details

diff --git a/Source/JavaScriptCore/llint/WebAssembly.asm b/Source/JavaScriptCore/llint/WebAssembly.asm
index 61f0b45d3d4d..ea2c71f220f0 100644
--- a/Source/JavaScriptCore/llint/WebAssembly.asm
+++ b/Source/JavaScriptCore/llint/WebAssembly.asm
@@ -578,17 +578,6 @@ end
     loadp Callee[cfr], ws0 # WebAssemblyFunction*
     loadp WebAssemblyFunction::m_instance[ws0], wasmInstance
 
-    # Memory
-    if ARM64 or ARM64E
-        loadpairq JSWebAssemblyInstance::m_cachedMemory[wasmInstance], memoryBase, boundsCheckingSize
-    elsif X86_64
-        loadp JSWebAssemblyInstance::m_cachedMemory[wasmInstance], memoryBase
-        loadp JSWebAssemblyInstance::m_cachedBoundsCheckingSize[wasmInstance], boundsCheckingSize
-    end
-    if not ARMv7
-        cagedPrimitiveMayBeNull(memoryBase, wa0)
-    end
-
     # Allocate stack space
     loadi WebAssemblyFunction::m_frameSize[ws0], wa0
     subp sp, wa0, wa0
@@ -608,15 +597,51 @@ if ASSERT_ENABLED
     end)
 end
 
+    # a0 = current stack frame position
+    move sp, a0
+
+    # Save wasmInstance and put the correct Callee into the stack for building the frame
+    storep wasmInstance, CodeBlock[cfr]
+
+if JSVALUE64
+    loadp Callee[cfr], memoryBase
+    transferp WebAssemblyFunction::m_boxedJSToWasmCallee[ws0], Callee[cfr]
+else
+    # Store old Callee to the stack temporarily
+    loadp Callee[cfr], ws1
+    push ws1, ws1
+    loadp WebAssemblyFunction::m_boxedJSToWasmCallee[ws0], ws1
+    storep ws1, Callee[cfr]
+end
+
     # Prepare frame
     move ws0, a2
     move cfr, a1
-    move sp, a0
     cCall3(_operationJSToWasmEntryWrapperBuildFrame)
 
+    # Restore Callee slot
+if JSVALUE64
+    storep memoryBase, Callee[cfr]
+else
+    loadp [sp], ws0
+    addp 2 * SlotSize, sp
+    storep ws0, Callee[cfr]
+end
+
     btpnz r1, .buildEntryFrameThrew
     move r0, ws0
 
+    # Memory
+    if ARM64 or ARM64E
+        loadpairq JSWebAssemblyInstance::m_cachedMemory[wasmInstance], memoryBase, boundsCheckingSize
+    elsif X86_64
+        loadp JSWebAssemblyInstance::m_cachedMemory[wasmInstance], memoryBase
+        loadp JSWebAssemblyInstance::m_cachedBoundsCheckingSize[wasmInstance], boundsCheckingSize
+    end
+    if not ARMv7
+        cagedPrimitiveMayBeNull(memoryBase, wa0)
+    end
+
     # Arguments
 
     forEachWasmArgumentGPR(macro (index, gpr1, gpr2)
diff --git a/Source/JavaScriptCore/wasm/js/JSToWasm.cpp b/Source/JavaScriptCore/wasm/js/JSToWasm.cpp
index 4d17f96b9fed..e3559f4561e1 100644
--- a/Source/JavaScriptCore/wasm/js/JSToWasm.cpp
+++ b/Source/JavaScriptCore/wasm/js/JSToWasm.cpp
@@ -241,11 +241,6 @@ MacroAssemblerCodeRef<JITThunkPtrTag> createJSToWasmJITShared()
 
         jit.loadPtr(CCallHelpers::addressFor(CallFrameSlot::callee), GPRInfo::regWS0);
         jit.loadPtr(CCallHelpers::Address(GPRInfo::regWS0, WebAssemblyFunction::offsetOfInstance()), GPRInfo::wasmContextInstancePointer);
-        // Memory
-#if USE(JSVALUE64)
-        jit.loadPair64(GPRInfo::wasmContextInstancePointer, CCallHelpers::TrustedImm32(JSWebAssemblyInstance::offsetOfCachedMemory()), GPRInfo::wasmBaseMemoryPointer, GPRInfo::wasmBoundsCheckingSizeRegister);
-        jit.cageConditionally(Gigacage::Primitive, GPRInfo::wasmBaseMemoryPointer, GPRInfo::wasmBoundsCheckingSizeRegister, GPRInfo::regWA0);
-#endif
 
         // Now, the current frame is fully set up for exceptions.
         // Allocate stack space
@@ -259,15 +254,44 @@ MacroAssemblerCodeRef<JITThunkPtrTag> createJSToWasmJITShared()
         stackOverflow.append(jit.branchPtr(CCallHelpers::LessThanOrEqual, GPRInfo::regWS1, CCallHelpers::Address(GPRInfo::wasmContextInstancePointer, JSWebAssemblyInstance::offsetOfSoftStackLimit())));
 
         jit.move(GPRInfo::regWS1, CCallHelpers::stackPointerRegister);
+        jit.move(CCallHelpers::stackPointerRegister, GPRInfo::argumentGPR0);
+
+        jit.storePtr(GPRInfo::wasmContextInstancePointer, CCallHelpers::addressFor(CallFrameSlot::codeBlock));
+
+        // Save the current Callee before putting in our boxed callee for the stack visitor
+#if USE(JSVALUE64)
+        jit.loadPtr(CCallHelpers::addressFor(CallFrameSlot::callee), GPRInfo::wasmBaseMemoryPointer);
+        jit.transferPtr(CCallHelpers::Address(GPRInfo::regWS0, WebAssemblyFunction::offsetOfBoxedJSToWasmCallee()), CCallHelpers::addressFor(CallFrameSlot::callee));
+#else
+        jit.loadPtr(CCallHelpers::addressFor(CallFrameSlot::callee), GPRInfo::regWS1);
+        jit.push(GPRInfo::regWS1);
+        jit.push(GPRInfo::regWS1);
+        jit.transferPtr(CCallHelpers::Address(GPRInfo::regWS0, WebAssemblyFunction::offsetOfBoxedJSToWasmCallee()), CCallHelpers::addressFor(CallFrameSlot::callee));
+#endif
 
         // Prepare frame
-        jit.setupArguments<decltype(operationJSToWasmEntryWrapperBuildFrame)>(CCallHelpers::stackPointerRegister, GPRInfo::callFrameRegister, GPRInfo::regWS0);
+        jit.setupArguments<decltype(operationJSToWasmEntryWrapperBuildFrame)>(GPRInfo::argumentGPR0, GPRInfo::callFrameRegister, GPRInfo::regWS0);
         jit.callOperation<OperationPtrTag>(operationJSToWasmEntryWrapperBuildFrame);
+
+        // Restore Callee slot regardless
+#if USE(JSVALUE64)
+        jit.storePtr(GPRInfo::wasmBaseMemoryPointer, CCallHelpers::addressFor(CallFrameSlot::callee));
+#else
+        jit.transferPtr(CCallHelpers::Address(CCallHelpers::stackPointerRegister, 0), CCallHelpers::addressFor(CallFrameSlot::callee));
+        jit.addPtr(CCallHelpers::TrustedImm32(2 * 8), CCallHelpers::stackPointerRegister);
+#endif
+
         static_assert(CCallHelpers::operationExceptionRegister<operationJSToWasmEntryWrapperBuildFrame>() != InvalidGPRReg, "We don't have a VM readily available so we rely on exception being returned");
         JIT_COMMENT(jit, "Exception check: ", CCallHelpers::operationExceptionRegister<operationJSToWasmEntryWrapperBuildFrame>());
         buildEntryFrameThrew.append(jit.branchTestPtr(CCallHelpers::NonZero, CCallHelpers::operationExceptionRegister<operationJSToWasmEntryWrapperBuildFrame>()));
         jit.move(GPRInfo::returnValueGPR, GPRInfo::regWS0);
 
+        // Memory
+#if USE(JSVALUE64)
+        jit.loadPair64(GPRInfo::wasmContextInstancePointer, CCallHelpers::TrustedImm32(JSWebAssemblyInstance::offsetOfCachedMemory()), GPRInfo::wasmBaseMemoryPointer, GPRInfo::wasmBoundsCheckingSizeRegister);
+        jit.cageConditionally(Gigacage::Primitive, GPRInfo::wasmBaseMemoryPointer, GPRInfo::wasmBoundsCheckingSizeRegister, GPRInfo::regWA0);
+#endif
+
 #if CPU(ARM64)
         jit.loadPair64(CCallHelpers::Address(CCallHelpers::stackPointerRegister, 0 * 8), GPRInfo::regWA0, GPRInfo::regWA1);
         jit.loadPair64(CCallHelpers::Address(CCallHelpers::stackPointerRegister, 2 * 8), GPRInfo::regWA2, GPRInfo::regWA3);

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2025-43213
Bugzilla 292621
Component JSC
Bug Class LogicError
Severity medium
Commit 946696720edc253e…
Advisory Apple Advisory

Terminology

  • JS-to-Wasm entry thunk — The generated trampoline (JIT or LLInt) that transitions a call from JavaScript into an exported WebAssembly function and builds the Wasm entry frame.
  • CallFrameSlot::callee — The frame slot holding the current callee; stack visitors read it to identify a frame, so it must contain a recognizable (boxed) callee at all times a walk can occur.
  • Boxed JS-to-Wasm callee — A specially tagged callee value (WebAssemblyFunction::offsetOfBoxedJSToWasmCallee) that the stack walker understands, as opposed to the raw WebAssemblyFunction pointer.
  • cageConditionally / cagedPrimitiveMayBeNull — Gigacage pointer-caging applied to the Wasm memory base so out-of-bounds pointer arithmetic stays confined to the primitive cage region.
  • Cached memory base / bounds size — Per-instance values (JSWebAssemblyInstance::m_cachedMemory / m_cachedBoundsCheckingSize) loaded into dedicated registers for Wasm memory access and bounds checks.

Root Cause Analysis

The bug is in the JS-to-Wasm entry thunk, implemented twice: the JIT thunk createJSToWasmJITShared in Source/JavaScriptCore/wasm/js/JSToWasm.cpp and the equivalent LLInt path in Source/JavaScriptCore/llint/WebAssembly.asm. This thunk runs when JavaScript calls an exported WebAssembly function; it sets up the Wasm frame and then calls the C++ helper operationJSToWasmEntryWrapperBuildFrame to finish building the entry frame. Two ordering/consistency invariants were violated. First, the frame’s Callee slot: while the helper runs it may walk the stack (for exception handling, stack-overflow reporting, or GC), and a stack visitor must find a well-formed, recognizable callee in CallFrameSlot::callee. Before the patch the Callee slot still held the raw WebAssemblyFunction* pointer rather than the boxed JS-to-Wasm callee that the visitor expects, so a stack walk during operationJSToWasmEntryWrapperBuildFrame could misinterpret the frame and crash. Second, the cached memory registers: the thunk previously loaded the instance’s cached memory base and bounds-checking size (JSWebAssemblyInstance::offsetOfCachedMemory) and caged the pointer (cageConditionally / cagedPrimitiveMayBeNull) BEFORE calling the frame-build helper; those dedicated Wasm registers (wasmBaseMemoryPointer, wasmBoundsCheckingSizeRegister) are not preserved across the C++ call and, worse, the helper could grow/relocate memory, so the values loaded early were stale by the time Wasm code ran. The fix reorders and adds book-keeping: (1) it stores wasmContextInstancePointer into CallFrameSlot::codeBlock, then saves the current Callee and transfers WebAssemblyFunction::offsetOfBoxedJSToWasmCallee() into CallFrameSlot::callee before the helper call, so any stack visitor during operationJSToWasmEntryWrapperBuildFrame sees the correct boxed callee; (2) it restores the original Callee slot immediately after the helper returns; and (3) it moves the cachedMemory load + cageConditionally to AFTER the helper call, so the memory base/size registers are populated with fresh, post-frame-build values. The .asm change mirrors this exactly (storep wasmInstance, CodeBlock[cfr]; transferp m_boxedJSToWasmCallee into Callee[cfr]; restore afterward; move the m_cachedMemory load + cagedPrimitiveMayBeNull below the cCall3). The violated invariant is thus ’the frame must be walkable with a valid boxed callee across the entry-frame-building call, and memory registers must be loaded from the instance only after the frame is built.'

Attack Path

1. Call an exported Wasm function from JS

A web page instantiates a WebAssembly module and calls an exported function from JavaScript, entering the JS-to-Wasm thunk (createJSToWasmJITShared or the LLInt WebAssembly.asm path).

2. Provoke a stack walk during frame build

Arrange conditions (e.g. stack near the soft stack limit / stack overflow, an exception, or a GC) so operationJSToWasmEntryWrapperBuildFrame triggers a stack visitor while the entry frame is being constructed.

3. Visitor reads the wrong Callee

Pre-patch, CallFrameSlot::callee still holds the raw WebAssemblyFunction* instead of the boxed JS-to-Wasm callee, so the stack visitor misinterprets the partially built frame.

4. Crash the process

The malformed-frame interpretation (or use of stale/uncaged memory registers loaded too early) produces an unexpected Safari/WebContent crash — the reported denial-of-service.

Impact Assessment

The patch establishes a frame-consistency defect: a stack visitor running during entry-frame construction could read an invalid Callee, and memory registers were loaded before the frame-build call, yielding an unexpected crash (DoS) confined to the WebContent (renderer) process where the Wasm engine runs. Because the mishandled state involves the stack visitor’s view of a frame and the Wasm memory base/caging registers, a corruption-oriented escalation cannot be categorically excluded, but the diff only demonstrates a crash and the reordering fix; any path beyond DoS is inference, not shown. The caging (cageConditionally) that limits Wasm memory pointers is preserved and merely reordered, so the memory-safety confinement remains intact. Standard heap-grooming/ACE background is not what this commit proves.

Audit Directions

Other entry/exit thunks and the Callee slot

Grep the Wasm and JS entry thunks (JSToWasm.cpp, WasmToJS.cpp, WebAssembly.asm) for CallFrameSlot::callee / offsetOfBoxedJSToWasmCallee and verify a valid boxed callee is installed before any callOperation/cCall that could walk the stack.

Register lifetime across callOperation

Audit thunks that load wasmBaseMemoryPointer / wasmBoundsCheckingSize (offsetOfCachedMemory / m_cachedBoundsCheckingSize) before a C++ helper call; these callee-clobbered or instance-mutating registers should be (re)loaded after the call, as this patch enforces.

JIT vs LLInt parity

Diff createJSToWasmJITShared against WebAssembly.asm for every entry path to ensure the frame-slot and memory-load ordering match across both implementations, since this bug required fixing both in lockstep.

Stack visitors during frame build

Trace operationJSToWasmEntryWrapperBuildFrame and its stack-overflow/exception/GC paths to enumerate where a StackVisitor can run mid-frame-construction, then check other trampolines for the same ‘walkable frame’ precondition.

Changed Functions

Function File Change Note
createJSToWasmJITShared Source/JavaScriptCore/wasm/js/JSToWasm.cpp modified Reordered the entry thunk: store wasmContextInstancePointer into CallFrameSlot::codeBlock and swap in WebAssemblyFunction::offsetOfBoxedJSToWasmCallee() as the Callee before calling operationJSToWasmEntryWrapperBuildFrame (so a stack walk sees a valid boxed callee), restore the original Callee after, and move the cachedMemory load + cageConditionally to after the helper call so memory base/size are fresh.
JS-to-Wasm entry thunk (LLInt) Source/JavaScriptCore/llint/WebAssembly.asm modified Mirror of the C++ change: storep wasmInstance,CodeBlock[cfr]; transferp m_boxedJSToWasmCallee into Callee[cfr] before cCall3(_operationJSToWasmEntryWrapperBuildFrame); restore Callee after; relocate the m_cachedMemory load and cagedPrimitiveMayBeNull to run after the frame is built.

Files Changed

Source Files

  • Source/JavaScriptCore/llint/WebAssembly.asm
  • Source/JavaScriptCore/wasm/js/JSToWasm.cpp