High watch ✔︎ verified CVSS 8.8 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in V8
DescriptionInappropriate implementation in V8
ComponentV8
Sub-componentMaglev
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety — revised by independent review by AI
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Independent review by AIConfirmed (tier revised from patch-now)
Reviewer notes: Mechanism (regalloc leaves stale allocations, codegen uses them) is clearly shown. But diff only demonstrates a miscompile/stale-register value; no memory corruption shown, so primitive 'corruption' is overstated (advisory itself says inappropriate implementation).
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker457351015
Fix commit7ef5ae531a9e (v8/v8) +25/-4
CWECWE-787 (Out-of-bounds write)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Credited303f06e3
Reported2025-11-03 (per vendor advisory)
Fix landed2025-11-05 (public git)
Disclosed2025-11-11
Patch gap6 days — fix public in source before the advisory

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.

Key insight
The allocator never reset its per-node register bookkeeping when it finished, so codegen read stale “in-register” state; the fix clears all register allocations at the end of allocation and asserts none remain when codegen processes each value node.

Attack Path

  1. 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.
  2. Leave stale register state Register allocation completes without clearing regalloc_info(), so some ValueNode remains marked as living in a register that codegen will not actually keep valid at the handler.
  3. 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.
  4. 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

An attacker able to get a suitable function Maglev-compiled and to force an exception into a handler can cause codegen to use a stale register instead of the spilled value, yielding incorrect/confused values in JIT-compiled code within the renderer’s V8 sandbox (inferred). The advisory classifies this as a LogicError / inappropriate implementation in V8 and does not report exploitation in the wild; escalation to a concrete memory-corruption or read/write primitive is (inferred), as the patch only demonstrates the stale-allocation logic error. The main precondition is that the vulnerable function is optimized by Maglev and reaches the exception-handler path.

Changed Functions

FunctionChangeNotes
if
src/maglev/maglev-code-generator.cc
modified
if
src/maglev/maglev-regalloc.cc
modified

Files Changed

  • src/maglev/maglev-code-generator.cc
  • src/maglev/maglev-regalloc.cc
  • src/maglev/maglev-regalloc.h

Audit Directions

  • Pass-teardown state hygiene
    Audit 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 paths
    Review 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" flag
    Scrutinize new boolean template/params like spill that let a routine skip an action (spilling) previously always performed, ensuring every call site uses the mode that keeps invariants intact.
From 7ef5ae531a9e79a084b5f0bebd5496d5d481e0ea Mon Sep 17 00:00:00 2001
From: Olivier Flückiger <olivf@chromium.org>
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 <olivf@chromium.org>
Commit-Queue: Leszek Swirski <leszeks@chromium.org>
Reviewed-by: Leszek Swirski <leszeks@chromium.org>
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);
Loading diff…

Original Bug Report

reported by hu...@gmail.com

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

  1. 36/44: CallBuiltin(...). If an exception is thrown from here, the value of r1 will be n35.
  2. 44/62: Call(...). If an exception is thrown from here, the value of r1 will be n33.

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 the 36/44: CallBuiltin(...) node, it correctly calls SpillAndClearRegisters() to spill all registers to the stack. Therefore, the location information of the 32/35: φᵀ r1 (n33, n85) node is (spilled: [stack:15|t]).
  • However, when processing Block b9, InitializeRegisterValues() is called to reload the 32/35: φᵀ r1 (n33, n85) node into rax. 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]

View on issue tracker