CVE-2025-24162
Overview
Background
- 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
- 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.
- 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.
- 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.
- 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.
- 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
BBQJIT::emitTailCallSource/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::emitIndirectTailCallSource/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. |
Audit Directions
- Other terminal control ops in BBQIn 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 tierCheck 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 pathsFocus 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.