← WebKit Silent-Fix Report — 2026-W34

958a13ecadda5806cf85cc0b61904b1d6cb6e868  [JSC] Avoid clobbering scratchRegister

severity high class TypeConfusion confidence 0.90 Wasm BBQ JIT exploitable-grade
Yusuke Suzuki Thu Aug 20 23:58:00 2026 -0700 full: 958a13ecadda5806cf85cc0b61904b1d6cb6e868 bug report ↗ view on GitHub ↗
Primitive: BBQ indirect-call profile update clobbers wasmContextInstancePointer
Triage note: Rewrites the addPtr sequence so the call-profile offset computation no longer overwrites GPRInfo::wasmContextInstancePointer with a live value; clobbering the context register is a JIT register-safety bug.
Contents

The bug at a glance

The bug is reachable whenever a wasm module performs an indirect call whose baseline call-profile slot lives at a large enough byte offset that the immediate no longer fits addPtr’s short-form encoding, which an attacker controls by placing many indirect callsites; forcing BBQ tier-up is trivial. The clobbered register is GPRInfo::wasmContextInstancePointer, the pinned pointer to the live Wasm instance, so a corrupted value flowing into the polymorphic-call thunk is a live-register JIT miscompile. The commit message frames the observed effect as merely a lost profiling optimization, but a dedicated context register holding a wrong value crossing into a call thunk is a register-safety defect, hence the LogicError-to-TypeConfusion reclassification and a 7.5 rather than a higher score.

On the polymorphic path of a wasm indirect call, BBQ needs to compute the address of this callsite’s CallProfile inside the baseline data block. It did so with a three-operand addPtr(imm, jitDataRegister, wasmContextInstancePointer) — add a constant offset to the JIT data base and write the result into the context register. The trap: MacroAssembler’s three-operand addPtr, when the immediate is too big to encode directly, silently borrows the internal scratchRegister() to materialize the constant. But on this path scratchRegister already holds a live value (the profile word just loaded and tested for the Megamorphic/Polymorphic branches), so a large callProfileIndex quietly corrupts it. The fix decomposes the operation into an explicit move of the immediate into the destination followed by a two-operand addPtr, which never touches the scratch register.

Root cause

In BBQJIT::emitIndirectCall (Source/JavaScriptCore/wasm/WasmBBQJIT.cpp) the polymorphic branch first loads a CallProfile word into m_jit.scratchRegister() and tests it: branchTestPtr(NonZero, scratchRegister(), TrustedImm32(CallProfile::Megamorphic)) and branchTestPtr(Zero, scratchRegister()). That value in scratchRegister is live across the following instruction, which computes the profile-slot address for the polymorphic-callee thunk.

The address computation was addPtr(TrustedImm32(safeCast<int32_t>(BaselineData::offsetOfData() + sizeof(CallProfile) * callProfileIndex)), GPRInfo::jitDataRegister, GPRInfo::wasmContextInstancePointer). This is the three-operand form: destination is wasmContextInstancePointer, source is jitDataRegister, and the immediate is the byte offset of profile slot callProfileIndex. When that offset is small the assembler encodes it as an immediate add. When the offset is large — driven by sizeof(CallProfile) * callProfileIndex, i.e. many indirect callsites in the module — x86-64/ARM64 macro-assembler cannot encode the constant inline and falls back to loading it into scratchRegister() first, then adding register-to-register.

The reaching path is attacker-controlled: a wasm module with enough indirect callsites makes some callProfileIndex large enough that its BaselineData::offsetOfData() + sizeof(CallProfile)*index exceeds the immediate range; that function then tiers up to BBQ and executes the polymorphic path. At that moment the address-computation’s internal use of scratchRegister overwrites the just-loaded, still-live CallProfile word.

Two things go wrong. First — the effect the author documents — the clobbered scratch value was feeding CallProfile::Polymorphic detection, so corrupting it merely drops the callsite to an unoptimized/megamorphic state, a correctness/perf regression. But the operation’s destination is GPRInfo::wasmContextInstancePointer, a pinned register that must hold the live Wasm instance pointer; routing a large-offset computation through the scratch register in the middle of a sequence that keeps a live value there is precisely the kind of live-register clobber that produces a wrong pinned-context value entering nearCallThunk(... callPolymorphicCalleeGenerator ...). A context/profile register holding an attacker-influenced wrong value across a call boundary is a register-safety miscompile that can surface as type confusion, which is why triage reclassified it from LogicError.

The fix replaces the fused three-operand form with two explicit instructions: move(TrustedImm32(...), GPRInfo::wasmContextInstancePointer) materializes the offset directly into the destination register (never using scratch), then addPtr(GPRInfo::jitDataRegister, GPRInfo::wasmContextInstancePointer) performs a plain two-operand register add. Neither instruction can spill through scratchRegister(), so the live CallProfile word — and any other live scratch contents — survive intact.

Key code

WasmBBQJIT.cpp: split the offset add so it never spills through scratchRegister

profilingDone.append(m_jit.branchTestPtr(CCallHelpers::NonZero, m_jit.scratchRegister(), TrustedImm32(CallProfile::Megamorphic)));

updateProfile.append(m_jit.branchTestPtr(CCallHelpers::Zero, m_jit.scratchRegister()));
-m_jit.addPtr(TrustedImm32(safeCast<int32_t>(BaselineData::offsetOfData() + sizeof(CallProfile) * callProfileIndex)), GPRInfo::jitDataRegister, GPRInfo::wasmContextInstancePointer);
+m_jit.move(TrustedImm32(safeCast<int32_t>(BaselineData::offsetOfData() + sizeof(CallProfile) * callProfileIndex)), GPRInfo::wasmContextInstancePointer);
+m_jit.addPtr(GPRInfo::jitDataRegister, GPRInfo::wasmContextInstancePointer);
m_jit.nearCallThunk(CodeLocationLabel<JITThunkPtrTag>(Thunks::singleton().stub(callPolymorphicCalleeGenerator).code()));

Patch walkthrough

  • Source/JavaScriptCore/wasm/WasmBBQJIT.cpp — The single three-operand addPtr(TrustedImm32(offset), GPRInfo::jitDataRegister, GPRInfo::wasmContextInstancePointer) is split into move(TrustedImm32(offset), GPRInfo::wasmContextInstancePointer) followed by addPtr(GPRInfo::jitDataRegister, GPRInfo::wasmContextInstancePointer). The move loads the (possibly large) BaselineData offset straight into the destination register, and the two-operand addPtr adds the JIT data base in place. Because a two-operand register-to-register add and an immediate move to a register never need the assembler’s internal scratchRegister, the CallProfile word previously loaded into scratchRegister() for the Megamorphic/Polymorphic branch tests is no longer clobbered.

Background

Three-operand addPtr and scratchRegister() — MacroAssembler’s addPtr(imm, src, dest) computes dest = src + imm. When the immediate is too wide for the target ISA’s add-immediate encoding, the assembler materializes it into the reserved internal scratchRegister() first, then does a register add — a hidden side effect that clobbers whatever the caller had left in scratch.

GPRInfo::wasmContextInstancePointer / jitDataRegister — wasmContextInstancePointer is a pinned register carrying the live Wasm instance across wasm code; jitDataRegister points at the module’s baseline JIT data block. The indirect-call path computes a CallProfile slot address relative to jitDataRegister.

CallProfile and indirect-call profiling — BBQ records call targets in per-callsite CallProfile records inside the BaselineData block; CallProfile::Megamorphic/Polymorphic states gate whether the polymorphic-callee thunk is invoked. The profile word is loaded into scratchRegister and branch-tested just before the slot address is computed.

Vulnerability window

  1. Introduction — The indirect-call profiling path used a compact three-operand addPtr(imm, jitDataRegister, wasmContextInstancePointer), correct only while the immediate stayed within the encodable range and unaware that large offsets spill through scratchRegister.
  2. Latent exposure — Only modules with enough indirect callsites to push a callProfileIndex offset past the immediate limit, once tiered up to BBQ and driven onto the polymorphic path, hit the clobber.
  3. Discovery — Register-safety review (bug 322248 / rdar://185480166) found the scratchRegister-spilling addPtr overwriting the live CallProfile value used for Polymorphic detection.
  4. Fix — Commit 958a13ecad (Yusuke Suzuki, 2026-08-20) rewrote the sequence as an explicit move + two-operand addPtr that never touches scratchRegister.

Triggering

No test ships with the patch and the defect only manifests when the encoded offset BaselineData::offsetOfData() + sizeof(CallProfile) * callProfileIndex exceeds the assembler’s immediate range, which requires constructing a module with a large number of indirect callsites so that some callProfileIndex is big enough. A conceptual trigger: emit a wasm module containing many call_indirect sites (enough that a late callsite’s profile offset overflows the short immediate form), tier it up to BBQ, and drive the polymorphic path so emitIndirectCall executes the address computation and clobbers the live profile word. The author states the visible consequence is loss of the polymorphic-optimization state; demonstrating a memory-safety consequence from the clobbered wasmContextInstancePointer would require a register-level harness not reconstructable from the diff without fabrication.

Exploitation

  1. Reach the vulnerable encoding — Author a wasm module with sufficiently many indirect callsites that a target callProfileIndex yields a BaselineData offset beyond the addPtr immediate range, and force BBQ tier-up of the containing function.
  2. Drive the polymorphic path — Exercise the indirect call with mixed callees so emitIndirectCall takes the polymorphic branch, executing the offset computation that spills the just-loaded CallProfile word (and destination wasmContextInstancePointer) through scratchRegister.
  3. Assess impact — The documented outcome is the callsite dropping to an unoptimized state; a register-safety escalation would depend on the wrong pinned-context/profile value crossing into callPolymorphicCalleeGenerator, which is where any type-confusion consequence would arise — honestly, no reliable primitive is demonstrable from the patch alone.

Detection & hunting

For defenders and SOC / detection engineers:

  • Wasm indirect-call crashes on large modules — Crashes or profile inconsistencies that appear only in modules with many indirect callsites and only after BBQ tier-up, especially with a wrong wasmContextInstancePointer at a call boundary, match this offset-encoding clobber.
  • Generated-code review of the polymorphic path — Disassemble BBQ output for call_indirect at a high callProfileIndex and confirm the profile-slot address is built without reusing the register holding the freshly branch-tested CallProfile word.
  • Assembler scratch-usage assertions — Enable or add debug assertions that flag three-operand addPtr falling back to scratchRegister while a caller-live value is resident, catching analogous misuses.

Audit directions

  • Three-operand addPtr/subPtr with live scratch — Grep BBQ/OMG for addPtr(TrustedImm32(...), reg, reg) and similar three-operand forms on paths that keep a value live in scratchRegister(), since any large immediate silently spills through scratch.
  • Profile-index-scaled offsets — Review every offsetOf... + sizeof(...) * index immediate feeding an address computation to confirm it cannot outgrow the encodable immediate range unnoticed, or is materialized via an explicit move.
  • Pinned-register destinations — Audit any arithmetic whose destination is a pinned register (wasmContextInstancePointer, jitDataRegister) to ensure intermediate scratch use cannot leave a wrong live value crossing a call or branch.

Before / after

Loading diff…