Medium CVSS 6.5 webkit Logic Error 🔧 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 ClassLogic Error
Tracker284159
Fix commitca83d835b014 (WebKit/WebKit)
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H
CISA KEVNot listed
Creditedlinjy of HKUS3Lab and chluo of WHUSecLab
Disclosed2025-01-27

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.

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.

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.

Changed Functions

FunctionChangeNotes
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.

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.

Original Bug Report

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