CVE-2025-43441
Overview
Background
- MacroAssembler scratch register
- A register the JIT reserves for internal use when lowering a single macro instruction; code must declare AllowMacroScratchRegisterUsage before any macro op is permitted to clobber it.
- AllowMacroScratchRegisterUsage / DisallowMacroScratchRegisterUsage
- RAII guard objects that toggle, for their lexical scope, whether emitted macro instructions may use (and thus overwrite) the scratch register.
- Patchpoint generator
- A callback the B3/Air backend invokes at code-emission time to emit custom machine code for a call site, receiving the physical register/stack assignments of its inputs via StackmapGenerationParams.
- WebAssembly tail call
- A call that reuses the caller’s stack frame instead of pushing a new one; the JIT must shuffle arguments into their final positions and adjust SP without a valid intermediate frame, making register/stack scratch management delicate.
- OMG JIT
- JavaScriptCore’s Optimizing Maximal-Grade WebAssembly tier that recompiles hot wasm functions through the B3/Air optimizing backend.
Root Cause Analysis
The patch fixes an unsafe use of the MacroAssembler scratch register in the OMG (optimizing) WebAssembly JIT during two related code-generation paths: the indirect-call patchpoint generator in emitIndirectCall and the tail-call setup helper prepareForTailCallImpl (both the 64-bit WasmOMGIRGenerator.cpp and the 32/64-bit WasmOMGIRGenerator32_64.cpp variants). WebKit’s MacroAssembler reserves a dedicated scratch/tmp register whose use is gated by AllowMacroScratchRegisterUsage / DisallowMacroScratchRegisterUsage RAII guards; any macro instruction emitted while AllowMacroScratchRegisterUsage is in scope is permitted to silently clobber that register.
The invariant that was violated is that the scratch register must not be marked allowable while it still holds a live input value. In emitIndirectCall, AllowMacroScratchRegisterUsage was constructed at the very top of the patchpoint generator, so the subsequent storeWasmCalleeToCalleeCallFrame (which may itself materialize the callee value via the scratch register) and the surrounding sequencing could clobber the callee-code pointer held in a param GPR before the farJump consumed it.
The fix moves the AllowMacroScratchRegisterUsage construction to after storeWasmCalleeToCalleeCallFrame, with the comment ‘Allow scratch after the callee is stored, which could be in the scratch register.’ In prepareForTailCallImpl the same class of bug existed across the whole argument-shuffling routine: the old code held a single AllowMacroScratchRegisterUsage over the entire body while it restored callee-saves, spilled/moved tail-call arguments, and finally adjusted SP and jumped, so an argument that happened to live in the scratch (tmp) register could be destroyed by any macro instruction that reached for scratch. The rewrite hoists the frame-size and spill-area computation up front, then wraps the input-live regions in explicit DisallowMacroScratchRegisterUsage scopes (‘Nothing before saving tmp can use the scratch register since it might clobber an input’ and ‘Nothing after restoring tmp can use the scratch register since it might clobber an input’), spills tmp to a dedicated stack slot (renamed tmpSpill -> tmpSpillOffsetRelativeToOriginalSP and clobbersTmp -> tmpNeedsSaving) when it is a live argument, and only re-enables scratch in the narrow window where it is safe. The net effect is that no live wasm argument or callee pointer can be silently overwritten by an internal scratch-register spill during tail-call/indirect-call lowering, which previously produced miscompiled machine code and a crash on the added regression test omg-tail-call-clobber-scratch-register.js.
Attack Path
- Deliver crafted wasm module Serve a WebAssembly module whose exported function performs an indirect call or a tail call with an argument layout that places a live value (or the callee-code pointer) into the JIT’s scratch/tmp register, matching the encoding in the regression test.
- Force OMG tier-up Drive the function hot (the test uses –jitPolicyScale=0) so it is compiled by the OMG optimizing JIT, exercising emitIndirectCall’s patchpoint generator and prepareForTailCallImpl rather than the interpreter or BBQ tier.
- Trigger scratch clobber during lowering During code generation the AllowMacroScratchRegisterUsage window causes a macro instruction (e.g. materializing the callee pointer or an argument move) to overwrite the scratch register while it still holds a live input, producing miscompiled code.
- Execute the miscompiled call At runtime the tail/indirect call jumps with a corrupted callee-code pointer or shuffles arguments incorrectly, yielding an unexpected process crash (the observed impact) and, in principle, a corrupted control-flow target.
- (Inference) Escalate a controlled clobber If an attacker can influence which value lands in the scratch register and the resulting incorrect jump target or argument, this could in theory be steered beyond a crash; the commit itself only establishes the miscompile-and-crash, so any control-flow-hijack escalation is inferred, not shown.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
OMGIRGenerator::emitIndirectCall (patchpoint generator lambda)Source/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp |
modified | Moved AllowMacroScratchRegisterUsage from the top of the generator to after storeWasmCalleeToCalleeCallFrame, so the scratch register is only allowed once the callee (which may occupy it) has been stored, just before farJump. |
prepareForTailCallImplSource/JavaScriptCore/wasm/WasmOMGIRGenerator.cpp |
modified | Hoisted frame-size/spill-area setup, wrapped the callee-save-restore/argument-scan and the final SP-adjust/jump in DisallowMacroScratchRegisterUsage scopes, and renamed clobbersTmp->tmpNeedsSaving and tmpSpill->tmpSpillOffsetRelativeToOriginalSP with an eager spill of tmp when it is a live argument. |
OMGIRGenerator::emitIndirectCall (patchpoint generator lambda)Source/JavaScriptCore/wasm/WasmOMGIRGenerator32_64.cpp |
modified | Same reordering for the 32/64-bit generator: AllowMacroScratchRegisterUsage now constructed after storeWasmCalleeToCalleeCallFrame and before loading the call target. |
prepareForTailCallImplSource/JavaScriptCore/wasm/WasmOMGIRGenerator32_64.cpp |
modified | Mirror of the 64-bit fix, additionally guarding the stack-argument stash loop (tailCallPatchpointScratch) inside the DisallowMacroScratchRegisterUsage region. |
Files Changed
JSTests/wasm/stress/omg-tail-call-clobber-scratch-register.jsSource/JavaScriptCore/wasm/WasmOMGIRGenerator.cppSource/JavaScriptCore/wasm/WasmOMGIRGenerator32_64.cpp
Audit Directions
- Other patchpoint generators enabling scratch too earlyIn the wasm OMG/BBQ generators grep for ‘AllowMacroScratchRegisterUsage allowScratch(jit)’ placed at the top of a patchpoint setGenerator lambda and check whether any params[…].gpr()/fpr() input is still live (used by a later store/jump) after that point; the fix pattern is to construct the guard only immediately before the instruction that consumes the scratch.
- Tail-call and CallFrameShuffler scratch handlingAudit prepareForTailCallImpl analogues and CallFrameShuffler/Air Shuffle code for a single wide AllowMacroScratchRegisterUsage scope surrounding emitRestore(calleeSaves), argument moves and SP adjustment; look for tmp = jit.scratchRegister() where tmp may alias a param GPR without a spill guarded by DisallowMacroScratchRegisterUsage.
- scratchRegister() aliasing live inputsAcross MacroAssembler users grep for jit.scratchRegister()/dataTempRegister/addressTempRegister used near loops over params[i].gpr() and verify a spill exists when arg.gpr() == scratch; the renamed tmpNeedsSaving/tmpSpillOffsetRelativeToOriginalSP logic is the reference safe pattern.
- Missing Disallow scopes around SP transitionsSearch for jit.addPtr(…stackPointerRegister) or farJump sequences that lack a surrounding DisallowMacroScratchRegisterUsage, since adjusting SP or jumping while scratch is enabled and inputs are live is the exact miscompile condition this patch closes.