CVE-2025-13042
Overview
Background
- Maglev
- V8’s mid-tier optimizing JIT that allocates registers to
ValueNodes and then emits machine code from that allocation. - `StraightForwardRegisterAllocator`
- Maglev’s linear register allocator that assigns physical registers to values and tracks, per node, whether it currently lives in a register or a spill slot (
regalloc_info()). - Spill slot
- a stack location where a value’s canonical copy is stored when it is not (or no longer) held in a register.
- Exception handler trampoline
- codegen glue that, when an exception unwinds into a handler, reloads live values; it must load from the spill slot rather than assume a value is still in a register.
Root Cause Analysis
After StraightForwardRegisterAllocator finished processing all blocks (allocating control nodes and applying patches), it left per-node register allocations set in regalloc_info() instead of clearing them. Codegen then consumed this stale register state: in the reported case an exception-handler trampoline saw a leftover “value is in register R” annotation and therefore skipped loading the value from its spill slot, using whatever happened to be in that register. This breaks the invariant that, by the time codegen runs, no ValueNode should still be marked as holding a register from the allocation pass.
The fix adds a call to the new ClearRegisters() at the end of the allocation loop so every register annotation is dropped before codegen, and adds a DEBUG DCHECK(!node->regalloc_info()->has_register()) in the code generator to enforce the invariant. To support clearing without spilling, SpillAndClearRegisters is refactored into a templated ClearRegisters<RegisterT, spill> where SpillAndClearRegisters sets spill = true and the new end-of-pass cleanup sets spill = false.
Attack Path
- Compile a function with an exception handler
Get Maglev to compile JavaScript containing a construct (e.g.
try/catch) that installs an exception-handler trampoline over live values. - Leave stale register state
Register allocation completes without clearing
regalloc_info(), so someValueNoderemains marked as living in a register that codegen will not actually keep valid at the handler. - Skip the spill reload When an exception unwinds into the handler, the trampoline trusts the leftover allocation and does not load the value from its spill slot, so the register’s stale/attacker-influenced contents are used in place of the real value.
- Consume the wrong value Subsequent code operates on a value that is not the intended object, producing a type/value confusion in optimized code (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/maglev/maglev-code-generator.cc |
modified | |
ifsrc/maglev/maglev-regalloc.cc |
modified |
Files Changed
src/maglev/maglev-code-generator.ccsrc/maglev/maglev-regalloc.ccsrc/maglev/maglev-regalloc.h
Audit Directions
- Pass-teardown state hygieneAudit compiler passes that annotate nodes with transient state (register/slot/liveness) and confirm each pass clears that state before a later phase reads it, rather than leaving it for the next consumer to interpret.
- Exception-handler / deopt reload pathsReview trampolines and deopt frames that decide whether to reload a value from a spill slot versus trust a register annotation; a stale annotation there directly yields wrong-value use.
- Refactors adding a "skip side effect" flagScrutinize new boolean template/params like
spillthat let a routine skip an action (spilling) previously always performed, ensuring every call site uses the mode that keeps invariants intact.
Patch
From 7ef5ae531a9e79a084b5f0bebd5496d5d481e0ea Mon Sep 17 00:00:00 2001 From: Olivier Flückiger <[email protected]> Date: Wed, 05 Nov 2025 14:11:51 +0100 Subject: [PATCH] [maglev] Fix left over register allocations from regalloc The regalloc should clear the node allocations when it is done. Failing to do so can cause the codegen to use stale register state. In this concrete example the exception handler trampolines would not load from the spill slot due to the left over allocation. Bug: 457351015 Change-Id: Ia113c0b3373b5b11250e11d66d63b6a67b63b13f Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7119379 Auto-Submit: Olivier Flückiger <[email protected]> Commit-Queue: Leszek Swirski <[email protected]> Reviewed-by: Leszek Swirski <[email protected]> Cr-Commit-Position: refs/heads/main@{#103535} --- diff --git a/src/maglev/maglev-code-generator.cc b/src/maglev/maglev-code-generator.cc index d5600f1..45a6f77 100644 --- a/src/maglev/maglev-code-generator.cc +++ b/src/maglev/maglev-code-generator.cc @@ -811,6 +811,12 @@ template <typename NodeT> ProcessResult Process(NodeT* node, const ProcessingState& state) { +#ifdef DEBUG + if constexpr (std::is_base_of_v<ValueNode, NodeT>) { + // Regalloc must clear its temp allocations. + DCHECK(!node->regalloc_info()->has_register()); + } +#endif if (v8_flags.code_comments) { std::stringstream ss; ss << "-- " << graph_labeller()->NodeId(node) << ": " diff --git a/src/maglev/maglev-regalloc.cc b/src/maglev/maglev-regalloc.cc index 55731b5..2a566b9 100644 --- a/src/maglev/maglev-regalloc.cc +++ b/src/maglev/maglev-regalloc.cc @@ -635,6 +635,9 @@ AllocateControlNode(block->control_node(), block); ApplyPatches(block); } + + // Clean up remaining register allocations at the end + ClearRegisters(); } void StraightForwardRegisterAllocator::FreeRegistersUsedBy(ValueNode* node) { @@ -1622,8 +1625,8 @@ double_registers_.ForEachUsedRegister(spill); } -template <typename RegisterT> -void StraightForwardRegisterAllocator::SpillAndClearRegisters( +template <typename RegisterT, bool spill> +void StraightForwardRegisterAllocator::ClearRegisters( RegisterFrameState<RegisterT>& registers) { while (registers.used() != registers.empty()) { RegisterT reg = registers.used().first(); @@ -1632,7 +1635,9 @@ printing_visitor_->os() << " clearing registers with " << PrintNodeLabel(node) << "\n"; } - Spill(node); + if (spill) { + Spill(node); + } registers.FreeRegistersUsedBy(node); DCHECK(!registers.used().has(reg)); } @@ -1643,6 +1648,11 @@ SpillAndClearRegisters(double_registers_); } +void StraightForwardRegisterAllocator::ClearRegisters() { + ClearRegisters(general_registers_); + ClearRegisters(double_registers_); +} + void StraightForwardRegisterAllocator::SaveRegisterSnapshot(NodeBase* node) { RegisterSnapshot snapshot; general_registers_.ForEachUsedRegister([&](Register reg, ValueNode* node) { diff --git a/src/maglev/maglev-regalloc.h b/src/maglev/maglev-regalloc.h index ff9ae3a..ba00530 100644 --- a/src/maglev/maglev-regalloc.h +++ b/src/maglev/maglev-regalloc.h @@ -226,8 +226,13 @@ void Spill(ValueNode* node); void SpillRegisters(); + template <typename RegisterT, bool spill = false> + void ClearRegisters(RegisterFrameState<RegisterT>& registers); template <typename RegisterT> - void SpillAndClearRegisters(RegisterFrameState<RegisterT>& registers); + void SpillAndClearRegisters(RegisterFrameState<RegisterT>& registers) { + ClearRegisters<RegisterT, true>(registers); + } + void ClearRegisters(); void SpillAndClearRegisters(); void SaveRegisterSnapshot(NodeBase* node);
Original Bug Report
DCHECK Fail when Maglev Generates Exception Handler Trampoline Instructions
VULNERABILITY DETAILS
I am still analyzing this PoC and have not yet identified the root cause. I can only explain the reason for the crash.
This crash is related to the Maglev register allocation process. The relevant Maglev graph is as follows:
After register allocation
Graph
....
│╭───►Block b2 (effects: ua)
││ 32/35: φᵀ r1 (n33, n85) (compressed) → [rax|R|t] (spilled: [stack:15|t]), live range: [32-66]
││ 33/36: φᵀ r5 (n32, n89) (decompressed) → [rdx|R|t] (spilled: [stack:7|t]), live range: [33-60]
││ 98: GapMove([rdx|R|t] → [rbx|R|t])
││ 79 : TestLessThan r5, [14]
││ ↱ eager @79 (11 live vars)
││ 34/38: CheckedSmiUntag [v33/n36:[rdx|R|t]] → [rdx|R|w32] (spilled: [stack:1|w32]), live range: [34-60]
││ 99: ConstantGapMove(n39 → [rsi|R|w32])
││ 82 : JumpIfFalse [81]
││╭─────35/41: BranchIfInt32Compare(LessThan) [v34/n38:[rdx|R|w32], v13/n39:[rsi|R|w32]] b3 b9
│││ ↓
│││ Block b3 /*=== k[9] = k; ===*/
||| ...
│││ 0x09b1008384b1 <SharedFunctionInfo> (0x09b100848915 <String[8]: "./poc.js">:5:21)
│││ 93 : SetKeyedProperty r5, r17, [15]
│││ 36/44: 🐢 CallBuiltin(KeyedStoreIC_Megamorphic) [v33/n36:[rdx|R|t], v9/n43:[rcx|R|t], v33/n36:[rax|R|t], v16/n2:[rsi|R|t]] → [rax|R|t]
│││ ↳ lazy @93 (11 live vars)
│││ ↳ throw @126 (b7) : {<this>:n1, <context>:n2, r1:n35, r4:n26, r5:n36, r7:n11, r8:n12, r10:n14, r13:n17, r14:n18}
.... /*=== for (let m = 0; m<10; m++) { } ===*/
│││ ╰►Block b6 /*=== undefined(); ===*/
│││ 108: ConstantGapMove(n33 → [rdi|R|t])
│││ 109: GapMove([stack:-3|t] → [rsi|R|t])
│││ 0x09b1008384b1 <SharedFunctionInfo> (0x09b100848915 <String[8]: "./poc.js">:8:16)
│││ 120 : CallUndefinedReceiver0 r16, [20]
│││ 44/62: 🐢 Call(NULL_OR_UNDEFINED, Any) [v4/n33:[rdi|R|t], v16/n2:[rsi|R|t], v4/n33:[rdi|R|t]] → [rax|R|t], live range: [44-45]
│││ ↳ lazy @120 (11 live vars)
│││ ↳ throw @126 (b7) : {<this>:n1, <context>:n2, r1:n33, r4:n26, r5:n36, r7:n11, r8:n12, r10:n14, r13:n17, r14:n18}
│││ 124 : Jump [30]
│││╭────45/63: Jump b8
││││ with gap moves:
││││ - v44/n62:[rax|R|t] → 85: φᵀ r1 [rax|R|t]
││││ with register merges:
││││
││││ Block b7 (exception handler) /*=== catch(exception) { ... } ===*/
││││ 46/64: φᵀₑ <accumulator> (compressed) → [rax|R|t], live range: [46-54]
││││ 47/65: φᵀₑ r1 (compressed) → [rcx|R|t] (spilled: [stack:14|t]), live range: [47-57]
....
In the PoC, there are two nodes that may throw exceptions, and the exceptions thrown by both will be handled by Block b7 (exception handler).
36/44: CallBuiltin(...). If an exception is thrown from here, the value ofr1will ben35.44/62: Call(...). If an exception is thrown from here, the value ofr1will ben33.
Since the value of r1 differs in the two paths, an exception phi node 47/65: φᵀₑ r1 is additionally added in Block b7.
The crash occurs during the Maglev assembly process. According to the comments in the ExceptionHandlerTrampolineBuilder::EmitTrampolineFor() method, when handling exception phis, the NewHeapNumber builtin may be called to create a HeapNumber object for float64, which may overwrite registers. Therefore, it is required that the source of every exception phi must be spilled to the stack during processing.
However, when ExceptionHandlerTrampolineBuilder processes the 36/44: CallBuiltin node, n35 is the source of the exception phi node, but n35 is not spilled to the stack. Instead, it is simultaneously located in [rax|R|t] and (spilled: [stack:15|t]), causing the DCHECK to fail.
In the release version, ExceptionHandlerTrampolineBuilder directly loads the value from rax, which is unsafe because the NewHeapNumber builtin may overwrite the value in rax at any time.
As I continued to study the Maglev register allocation process, I found:
- When
StraightForwardRegisterAllocator::AllocateNode()processes the36/44: CallBuiltin(...)node, it correctly callsSpillAndClearRegisters()to spill all registers to the stack. Therefore, the location information of the32/35: φᵀ r1 (n33, n85)node is(spilled: [stack:15|t]). - However, when processing
Block b9,InitializeRegisterValues()is called to reload the32/35: φᵀ r1 (n33, n85)node intorax. This results in the location information (i.e.,regalloc_info) of the node being[rax|R|t] (spilled: [stack:15|t]).
│││ Block b3
│││ live regs: rax=v32, rcx=v1, rdx=v34, rbx=v33, rsi=v13
│││ [holes: 45-59 63↰]
.....
│││ Using n2...
│││ clearing registers with n36 /* <=== Spill and Clean */
│││ spill: [stack:7|t] ← n36
│││ clearing registers with n43
│││ clearing registers with n2
│││ clearing registers with n38
│││ spill: [stack:1|w32] ← n38
│││ clearing registers with n35
│││ spill: [stack:15|t] ← n35
│││ Allocating result...
│││ forcing rax to n44...
│││ Allocating lazy deopt inputs...
│││ Using n3...
│││ Using n1...
│││ Using n2...
│││ Using n35...
│││ Using n26...
│││ Using n36...
│││ Using n11...
│││ Using n12...
│││ Using n14...
│││ Using n17...
│││ Using n18...
│││ Using n2...
│││ 0x09b1008384b1 <SharedFunctionInfo> (0x09b100848915 <String[8]: "./poc.js">:5:21)
│││ 93 : SetKeyedProperty r5, r17, [15]
│││ 44: 🐢 CallBuiltin(KeyedStoreIC_Megamorphic) [n36, n43, n36, n2], 0 uses, but required
│││ ↳ lazy @93 (11 live vars)
│││ ↳ throw @126 (b7) : {<this>:n1, <context>:n2, r1:n35, r4:n26, r5:n36, r7:n11, r8:n12, r10:n14, r13:n17, r14:n18}
....
│ ╰──►Block b9
│ live regs: rax=v32, rcx=v1, rsi=v13 /* Force Load */
RecordMoves() retrieves the location information of the node from source->regalloc_info()->allocation(). If a node’s value is simultaneously in a register and on the stack, allocation() will choose to load the value from the register.
class RegallocValueNodeInfo : public RegallocNodeInfo {
...
compiler::InstructionOperand allocation() const {
if (has_register()) {
return compiler::AllocatedOperand(compiler::LocationOperand::REGISTER,
representation_, FirstRegisterCode());
}
CHECK(is_loadable());
return loadable_slot();
}
...
}
That is all I know so far. I cannot determine the root cause of this crash. I believe you have a better understanding of Maglev than I do, so I report it to you.
REPRODUCTION CASE
poc.js:
let arr = [1, 2, 3];
for (const i of arr) {
for (let j = 0; j < 50, true; j++) {
for (let k = 0; k < 5; k++) {
try {
k[9] = k;
for (let m = 0; m<10; m++) {
}
undefined();
} catch(exception) {
print(exception);
}
}
}
}
V8 must be built with a debug configuration, Execute v8 as follows:
./d8 \
--predictable \
--jit-fuzzing \
./poc.js
This will result in the following crash:
#
# Fatal error in ../../src/maglev/maglev-code-generator.cc, line 623
# Debug check failed: !source->regalloc_info()->allocation().IsRegister().
#
CREDIT INFORMATION
Reporter credit: [303f06e3]