Low patch-now ~ plausible CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds write in V8
DescriptionOut of bounds write in V8
ComponentV8
Sub-componentRegexp
Bug ClassOOB
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: Commit states a performance revert, not a security fix; diff shows no OOB sink. Claimed miscomputed-fixup values (e.g. '4 was 0') are not visible in the provided test diff; the OOB mechanism is inferred.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker511290389
Fix commit60524d5d9f05 (v8/v8) +60/-73
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
CreditedGoogle
Reported2026-05-08 (per vendor advisory)
Fix landed2026-05-11 (public git)
Disclosed2026-06-30
Patch gap50 days — fix public in source before the advisory

Background

Regexp bytecode peephole optimizer
a pass (BytecodePeepholeOptimization::OptimizeBytecode) that rewrites short bytecode sequences into fused single opcodes and rewrites all affected jump/label positions accordingly.
`kAdvanceCpAndGoto` fusion
the peephole rule that replaced an kAdvanceCurrentPosition immediately followed by kGoTo with the single fused kAdvanceCpAndGoto opcode.
`BytecodeWriter` / jump edges / label fixups
the writer holds the emitted bytecode buffer plus jump_edges_ bookkeeping; when a sequence is replaced, downstream label positions and jump targets must be fixed up by the size delta of the replacement.
Multi-pass ("ping-pong") optimization
a fixed-point loop introduced by crrev.com/c/7274603 that repeatedly ran the optimizer, std::swap-ing a source and destination BytecodeWriter and calling BytecodeWriter::Reset() between passes until no rule fired.

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: Commit states a performance revert, not a security fix; diff shows no OOB sink. Claimed miscomputed-fixup values (e.g. '4 was 0') are not visible in the provided test diff; the OOB mechanism is inferred.

The reverted change (crrev.com/c/7274603) turned the single peephole pass into a fixed-point loop that ping-ponged bytecode between two BytecodeWriters, calling BytecodeWriter::Reset() (clearing jump_edges_ and PC) before each subsequent pass. Across successive passes, the label-position and jump-target fixups produced by BytecodePeephole::OptimizeBytecode were miscomputed — the updated test expectations show that pre-patch pos_fixups/target_fixups and label positions were wrong (e.g. a fixup that should have been 4 was 0, and after-replacement positions differed by the wrong size deltas). Incorrect fixups mean the optimized regexp bytecode contains jump offsets and current-position advances that no longer match the real buffer layout, so during matching the interpreter/JIT can advance or index out of the intended bounds, corresponding to the reported out-of-bounds write (inferred — the specific OOB sink is not in the diff).

The fix reverts to a single peephole pass (did_optimize selecting either the single dst_writer or the untouched src_writer), removes BytecodeWriter::Reset(), and moves the kAdvanceCurrentPosition+kGoTo → kAdvanceCpAndGoto fusion out of the peephole table and back into BytecodeGenerator (tracking advance_current_start_/advance_current_offset_/advance_current_end_), so the fragile multi-pass fixup accumulation never happens.

Key insight
Running the peephole optimizer multiple times accumulated incorrect jump/position fixups, yielding regexp bytecode with out-of-range offsets; the fix removes the multi-pass loop and performs the advance+goto fusion during generation instead, restoring correct single-pass fixups.

Attack Path

  1. Craft a triggering regexp Script compiles a regular expression whose generated bytecode contains sequences the peephole optimizer rewrites (e.g. kAdvanceCurrentPosition+kGoTo plus a further fusable pattern) so that more than one optimization pass fires.
  2. Multi-pass fixup corruption In the vulnerable code, OptimizeBytecode loops, Reset()-ing and swapping writers; the second pass computes label positions/jump targets from state whose fixups are already wrong, emitting bytecode with mismatched offsets.
  3. Emit malformed bytecode The corrupted buffer is copied into the TrustedByteArray and used as the regexp’s executable bytecode with off-by-size jump targets and advance offsets.
  4. Out-of-bounds write during matching Executing the regexp against a subject drives the interpreter/JIT through the bad offsets, producing an out-of-bounds write (inferred — the diff shows the incorrect fixups and the revert, not the concrete write location).

Impact Assessment

An attacker who can run script in the renderer and compile/execute an attacker-chosen regular expression can cause V8 to emit miscompiled regexp bytecode and perform an out-of-bounds write during matching, corrupting the renderer heap. The advisory classifies this as an out-of-bounds write in V8; escalation to a controlled write primitive or code execution is (inferred) and not demonstrated by the patch, and the advisory does not report in-the-wild exploitation. The only precondition is ordinary regexp compilation/execution from script.

Changed Functions

FunctionChangeNotes
if
src/regexp/regexp-bytecode-generator.cc
modified
for
src/regexp/regexp-bytecode-peephole.cc
modified
if
src/regexp/regexp-bytecode-peephole.cc
modified

Files Changed

  • src/regexp/regexp-bytecode-generator.cc
  • src/regexp/regexp-bytecode-generator.h
  • src/regexp/regexp-bytecode-peephole.cc
  • test/unittests/regexp/regexp-unittest.cc

Audit Directions

  • Iterative optimizer fixups
    Audit any bytecode/IR pass that runs to a fixed point and rewrites positions/offsets, verifying that label and jump fixups compose correctly across repeated passes rather than only within one pass.
  • Buffer ping-pong with partial reset
    Review passes that swap source/destination buffers and reset bookkeeping (jump_edges_, PC) between iterations for state that must, but does not, survive the swap.
  • Generation-time vs. peephole fusion
    Where an optimization was moved between the generator and a later peephole pass, confirm both paths compute identical sizes/offsets and that CHECK/unit-test position expectations match the real emitted layout.
From 60524d5d9f05320d0ddbf83959b5cfb0c404bfb4 Mon Sep 17 00:00:00 2001
From: Jakob Linke <jgruber@chromium.org>
Date: Mon, 11 May 2026 14:14:42 +0200
Subject: [PATCH] [regexp] Revert multi-pass-peephole

Partially reverts crrev.com/c/7274603 (90084a5d5fd).

The peephole pass is sufficiently expensive that doing it twice is
noticeable in benchmarks. We go back to the old state: a single peephole
pass, with the AdvanceCurrentPosition+GoTo fusion moving back into the
BytecodeGenerator so existing peephole defs can remain the same.

Fixed: 511290389
Change-Id: Iadc0383612a0120f2e377ec2c5e2a24544b2d6a4
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7827784
Auto-Submit: Jakob Linke <jgruber@chromium.org>
Commit-Queue: Jakob Linke <jgruber@chromium.org>
Reviewed-by: Patrick Thier <pthier@chromium.org>
Cr-Commit-Position: refs/heads/main@{#107222}
---

diff --git a/src/regexp/regexp-bytecode-generator.cc b/src/regexp/regexp-bytecode-generator.cc
index 82bbbc4..0095942 100644
--- a/src/regexp/regexp-bytecode-generator.cc
+++ b/src/regexp/regexp-bytecode-generator.cc
@@ -42,12 +42,6 @@
   buffer_.resize(new_size);
 }
 
-void BytecodeWriter::Reset() {
-  // We keep the buffer_ storage; the next pass will overwrite its contents.
-  jump_edges_.clear();
-  ResetPc(0);
-}
-
 void BytecodeWriter::EmitRawBytecodeStream(const uint8_t* data, int len) {
   EnsureCapacity(len);
   // Must start at a bytecode boundary.
@@ -245,6 +239,7 @@
 }
 
 void BytecodeGenerator::Bind(Label* l) {
+  advance_current_end_ = kInvalidPC;
   DCHECK(!l->is_bound());
   if (l->is_linked()) {
     int pos = l->pos();
@@ -315,7 +310,16 @@
   Emit<Bytecode::kBacktrack>(error_code);
 }
 
-void BytecodeGenerator::GoTo(Label* label) { Emit<Bytecode::kGoTo>(label); }
+void BytecodeGenerator::GoTo(Label* label) {
+  if (advance_current_end_ == pc_) {
+    // Fuse the preceding AdvanceCurrentPosition into kAdvanceCpAndGoto.
+    ResetPc(advance_current_start_);
+    Emit<Bytecode::kAdvanceCpAndGoto>(advance_current_offset_, label);
+    advance_current_end_ = kInvalidPC;
+  } else {
+    Emit<Bytecode::kGoTo>(label);
+  }
+}
 
 void BytecodeGenerator::PushBacktrack(Label* label) {
   Emit<Bytecode::kPushBacktrack>(label);
@@ -329,7 +333,10 @@
 void BytecodeGenerator::Fail() { Emit<Bytecode::kFail>(); }
 
 void BytecodeGenerator::AdvanceCurrentPosition(int by) {
+  advance_current_start_ = pc_;
+  advance_current_offset_ = by;
   Emit<Bytecode::kAdvanceCurrentPosition>(by);
+  advance_current_end_ = pc_;
 }
 
 void BytecodeGenerator::CheckFixedLengthLoop(
diff --git a/src/regexp/regexp-bytecode-generator.h b/src/regexp/regexp-bytecode-generator.h
index 64bf47a..ee87fac 100644
--- a/src/regexp/regexp-bytecode-generator.h
+++ b/src/regexp/regexp-bytecode-generator.h
@@ -41,8 +41,6 @@
 
   // Update bookkeeping at bytecode boundaries.
   inline void ResetPc(int new_pc);
-  // Reset all state.
-  void Reset();
 
   // Templated code emission.
   template <Bytecode bytecode, typename... Args>
@@ -231,6 +229,13 @@
 
   Label backtrack_;
 
+  // Tracks a just-emitted AdvanceCurrentPosition so the next GoTo can fuse
+  // both into a single kAdvanceCpAndGoto bytecode.
+  static const int kInvalidPC = -1;
+  int advance_current_start_ = 0;
+  int advance_current_offset_ = 0;
+  int advance_current_end_ = kInvalidPC;
+
   Isolate* isolate_;
 
   DISALLOW_IMPLICIT_CONSTRUCTORS(BytecodeGenerator);
diff --git a/src/regexp/regexp-bytecode-peephole.cc b/src/regexp/regexp-bytecode-peephole.cc
index ec7cf8c..49397a4 100644
--- a/src/regexp/regexp-bytecode-peephole.cc
+++ b/src/regexp/regexp-bytecode-peephole.cc
@@ -583,15 +583,6 @@
   // (--trace-regexp-bytecodes) and using v8/tools/regexp-sequences.py.
 
   {
-    static constexpr auto Target = B::kAdvanceCpAndGoto;
-    CreateSequence(B::kAdvanceCurrentPosition)
-        .FollowedBy(B::kGoTo)
-        .ReplaceWith(Target)
-        .MapArgument(T(by), 0, I(B::kAdvanceCurrentPosition, by))
-        .MapArgument(T(on_goto), 1, I(B::kGoTo, label));
-  }
-
-  {
     static constexpr auto Target = B::kSkipUntilBitInTable;
     CreateSequence(B::kLoadCurrentCharacter)
         .FollowedBy(B::kCheckBitInTable)
@@ -1081,8 +1072,7 @@
 DirectHandle<TrustedByteArray> BytecodePeepholeOptimization::OptimizeBytecode(
     Isolate* isolate, Zone* zone, DirectHandle<RegExpData> re_data,
     BytecodeWriter* src_writer) {
-  BytecodeWriter second_writer(zone);
-  BytecodeWriter* dst_writer = &second_writer;
+  BytecodeWriter dst_writer(zone);
 
   // Preserve the original bytecode for tracing if needed.
   std::optional<ZoneVector<uint8_t>> original_bytecode;
@@ -1092,31 +1082,19 @@
     original_bytecode.emplace(begin, begin + src_writer->length(), zone);
   }
 
-  // Run the peephole optimizer until we've reached a fixed point. All relevant
-  // data structures ping-pong between src and dst_writer.
-  bool any_pass_optimized = false;
-  for (;;) {
-    dst_writer->Reset();
-    // TODO(jgruber): This currently recreates standard definitions for each
-    // pass. These should be global instead (or at the very least, once per
-    // compilation).
-    bool this_pass_optimized =
-        BytecodePeephole::OptimizeBytecode(zone, src_writer, dst_writer);
-    if (!this_pass_optimized) break;
-    any_pass_optimized = true;
-    std::swap(dst_writer, src_writer);
-  }
-
-  // The result is in the src_writer (not in dst, since the last pass did not
-  // change anything).
-  const uint8_t* optimized_bytecode = src_writer->buffer().data();
-  uint32_t optimized_length = src_writer->length();
+  const bool did_optimize =
+      BytecodePeephole::OptimizeBytecode(zone, src_writer, &dst_writer);
+  // The result is in dst_writer iff a peephole rule fired; otherwise the
+  // unchanged input bytecode is still in src_writer.
+  BytecodeWriter* result = did_optimize ? &dst_writer : src_writer;
+  const uint8_t* optimized_bytecode = result->buffer().data();
+  uint32_t optimized_length = result->length();
 
   DirectHandle<TrustedByteArray> array =
       isolate->factory()->NewTrustedByteArray(optimized_length);
   MemCopy(array->begin(), optimized_bytecode, optimized_length);
 
-  if (any_pass_optimized && v8_flags.trace_regexp_peephole_optimization) {
+  if (did_optimize && v8_flags.trace_regexp_peephole_optimization) {
     std::unique_ptr<char[]> pattern_cstring =
         re_data->escaped_source()->ToCString();
     PrintF("Original Bytecode:\n");
diff --git a/test/unittests/regexp/regexp-unittest.cc b/test/unittests/regexp/regexp-unittest.cc
index 66873c1..7837fa6 100644
--- a/test/unittests/regexp/regexp-unittest.cc
+++ b/test/unittests/regexp/regexp-unittest.cc
@@ -1936,8 +1936,7 @@
   const uint32_t length_expected =
       regexp::Bytecodes::Size(REB::kLoadCurrentCharacter) +
       regexp::Bytecodes::Size(REB::kCheckCharacter) +
-      regexp::Bytecodes::Size(REB::kAdvanceCurrentPosition) +
-      regexp::Bytecodes::Size(REB::kGoTo) +
+      regexp::Bytecodes::Size(REB::kAdvanceCpAndGoto) +
       regexp::Bytecodes::Size(REB::kBacktrack);
   const uint32_t length_optimized_expected =
       regexp::Bytecodes::Size(REB::kSkipUntilChar) +
@@ -1997,8 +1996,7 @@
   const uint32_t length_expected =
       regexp::Bytecodes::Size(REB::kLoadCurrentCharacter) +
       regexp::Bytecodes::Size(REB::kCheckBitInTable) +
-      regexp::Bytecodes::Size(REB::kAdvanceCurrentPosition) +
-      regexp::Bytecodes::Size(REB::kGoTo) +
+      regexp::Bytecodes::Size(REB::kAdvanceCpAndGoto) +
       regexp::Bytecodes::Size(REB::kBacktrack);
   const uint32_t length_optimized_expected =
       regexp::Bytecodes::Size(REB::kSkipUntilBitInTable) +
@@ -2053,8 +2051,7 @@
       regexp::Bytecodes::Size(REB::kCheckPosition) +
       regexp::Bytecodes::Size(REB::kLoadCurrentCharacterUnchecked) +
       regexp::Bytecodes::Size(REB::kCheckCharacter) +
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/unittests/regexp/regexp-unittest.cc b/test/unittests/regexp/regexp-unittest.cc
index 66873c1..7837fa6 100644
--- a/test/unittests/regexp/regexp-unittest.cc
+++ b/test/unittests/regexp/regexp-unittest.cc
@@ -1936,8 +1936,7 @@
   const uint32_t length_expected =
       regexp::Bytecodes::Size(REB::kLoadCurrentCharacter) +
       regexp::Bytecodes::Size(REB::kCheckCharacter) +
-      regexp::Bytecodes::Size(REB::kAdvanceCurrentPosition) +
-      regexp::Bytecodes::Size(REB::kGoTo) +
+      regexp::Bytecodes::Size(REB::kAdvanceCpAndGoto) +
       regexp::Bytecodes::Size(REB::kBacktrack);
   const uint32_t length_optimized_expected =
       regexp::Bytecodes::Size(REB::kSkipUntilChar) +
@@ -1997,8 +1996,7 @@
   const uint32_t length_expected =
       regexp::Bytecodes::Size(REB::kLoadCurrentCharacter) +
       regexp::Bytecodes::Size(REB::kCheckBitInTable) +
-      regexp::Bytecodes::Size(REB::kAdvanceCurrentPosition) +
-      regexp::Bytecodes::Size(REB::kGoTo) +
+      regexp::Bytecodes::Size(REB::kAdvanceCpAndGoto) +
       regexp::Bytecodes::Size(REB::kBacktrack);
   const uint32_t length_optimized_expected =
       regexp::Bytecodes::Size(REB::kSkipUntilBitInTable) +
@@ -2053,8 +2051,7 @@
       regexp::Bytecodes::Size(REB::kCheckPosition) +
       regexp::Bytecodes::Size(REB::kLoadCurrentCharacterUnchecked) +
       regexp::Bytecodes::Size(REB::kCheckCharacter) +
-      regexp::Bytecodes::Size(REB::kAdvanceCurrentPosition) +
-      regexp::Bytecodes::Size(REB::kGoTo) +
+      regexp::Bytecodes::Size(REB::kAdvanceCpAndGoto) +
       regexp::Bytecodes::Size(REB::kBacktrack);
   const uint32_t length_optimized_expected =
       regexp::Bytecodes::Size(REB::kSkipUntilCharPosChecked) +
@@ -2108,8 +2105,7 @@
       regexp::Bytecodes::Size(REB::kCheckPosition) +
       regexp::Bytecodes::Size(REB::kLoadCurrentCharacterUnchecked) +
       regexp::Bytecodes::Size(REB::kCheckCharacterAfterAnd) +
-      regexp::Bytecodes::Size(REB::kAdvanceCurrentPosition) +
-      regexp::Bytecodes::Size(REB::kGoTo) +
+      regexp::Bytecodes::Size(REB::kAdvanceCpAndGoto) +
       regexp::Bytecodes::Size(REB::kBacktrack);
   const uint32_t length_optimized_expected =
       regexp::Bytecodes::Size(REB::kSkipUntilCharAnd) +
@@ -2165,8 +2161,7 @@
       regexp::Bytecodes::Size(REB::kLoadCurrentCharacter) +
       regexp::Bytecodes::Size(REB::kCheckCharacter) +
       regexp::Bytecodes::Size(REB::kCheckCharacter) +
-      regexp::Bytecodes::Size(REB::kAdvanceCurrentPosition) +
-      regexp::Bytecodes::Size(REB::kGoTo) +
+      regexp::Bytecodes::Size(REB::kAdvanceCpAndGoto) +
       regexp::Bytecodes::Size(REB::kBacktrack);
   const uint32_t length_optimized_expected =
       regexp::Bytecodes::Size(REB::kSkipUntilCharOrChar) +
@@ -2232,8 +2227,7 @@
       regexp::Bytecodes::Size(REB::kCheckCharacterGT) +
       regexp::Bytecodes::Size(REB::kCheckBitInTable) +
       regexp::Bytecodes::Size(REB::kGoTo) +
-      regexp::Bytecodes::Size(REB::kAdvanceCurrentPosition) +
-      regexp::Bytecodes::Size(REB::kGoTo) +
+      regexp::Bytecodes::Size(REB::kAdvanceCpAndGoto) +
       regexp::Bytecodes::Size(REB::kBacktrack);
   const uint32_t length_optimized_expected =
       regexp::Bytecodes::Size(REB::kSkipUntilGtOrNotBitInTable) +
@@ -2291,13 +2285,13 @@
 
   CHECK_EQ(0x00, dummy_before.pos());
   CHECK_EQ(0x28, dummy_inside.pos());
-  CHECK_EQ(0x3C, dummy_after.pos());
+  CHECK_EQ(0x38, dummy_after.pos());
 
   const Label* labels[] = {&dummy_before, &dummy_after, &dummy_inside};
   const int label_positions[4][3] = {
-      {0x04, 0x40},  // dummy_before
-      {0x0C, 0x48},  // dummy after
-      {0x14, 0x50}   // dummy inside
+      {0x04, 0x3C},  // dummy_before
+      {0x0C, 0x44},  // dummy after
+      {0x14, 0x4C}   // dummy inside
   };
 
   DirectHandle<String> source = factory->NewStringFromStaticChars("dummy");
@@ -2320,13 +2314,13 @@
 
   const int pos_fixups[] = {
       0,  // Position before optimization should be unchanged.
-      0,  // Position after first replacement should be 0 (optimized size (36) -
-          // original size (36)).
+      4,  // Position after first replacement should be 4 (optimized size (20) -
+          // original size (32) + preserve length (16)).
   };
   const int target_fixups[] = {
       0,  // dummy_before should be unchanged
-      0,  // dummy_after should be unchanged
-      4   // dummy_inside should be 4
+      4,  // dummy_inside should be 4
+      4   // dummy_after should be 4
   };
 
   for (int label_idx = 0; label_idx < 3; label_idx++) {
@@ -2397,17 +2391,17 @@
                                            &dummy_after, &dummy_inside);
 
   CHECK_EQ(0x00, dummy_before.pos());
-  CHECK_EQ(0x44, dummy_between.pos());
-  CHECK_EQ(0x74, dummy_inside.pos());
-  CHECK_EQ(0x88, dummy_after.pos());
+  CHECK_EQ(0x40, dummy_between.pos());
+  CHECK_EQ(0x70, dummy_inside.pos());
+  CHECK_EQ(0x80, dummy_after.pos());
 
   const Label* labels[] = {&dummy_before, &dummy_between, &dummy_after,
                            &dummy_inside};
   const int label_positions[4][3] = {
-      {0x04, 0x48, 0x8C},  // dummy_before
-      {0x0C, 0x50, 0x94},  // dummy between
-      {0x14, 0x58, 0x9C},  // dummy after
-      {0x1C, 0x60, 0xA4}   // dummy inside
+      {0x04, 0x44, 0x84},  // dummy_before
+      {0x0C, 0x4C, 0x8C},  // dummy between
+      {0x14, 0x54, 0x94},  // dummy after
+      {0x1C, 0x5C, 0x9C}   // dummy inside
   };
 
   DirectHandle<String> source = factory->NewStringFromStaticChars("dummy");
@@ -2430,14 +2424,17 @@
 
   const int pos_fixups[] = {
       0,    // Position before optimization should be unchanged.
-      -16,  // Position after first replacement.
-      -16   // Position after second replacement.
+      -12,  // Position after first replacement should be -12 (optimized size =
+            // 20 - 32 = original size).
+      -8    // Position after second replacement should be -8 (-12 from first
+            // optimization -12 from second optimization + 16 preserved
+            // bytecodes).
   };
   const int target_fixups[] = {
-      0,    // dummy_before
-      -16,  // dummy_between
-      -16,  // dummy_after
-      -12   // dummy_inside
+      0,    // dummy_before should be unchanged
+      -12,  // dummy_between should be -12
+      -8,   // dummy_inside should be -8
+      -8    // dummy_after should be -8
   };
 
   for (int label_idx = 0; label_idx < 4; label_idx++) {
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential OOB Write in V8 RegExp JIT Compiler via Stale Peephole Jump Offsets

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: V8’s Irregexp peephole optimizer fails to track jump offsets for kOffsetAfterSequence, causing them to become stale if preceding bytecodes are shrunk in subsequent optimization passes. During native JIT tier-up, these stale offsets trigger an out-of-bounds write in CodeGenerator::PreVisitBytecodes, which can zero out jump_targets_ data and lead to wild branches.

Affected files:

  • v8/src/regexp/regexp-code-generator.cc
  • v8/src/regexp/regexp-bytecode-peephole.cc
  • v8/src/regexp/regexp-bytecode-generator.cc
  • v8/src/regexp/regexp-bytecode-generator-inl.h

Estimated timestamp from git blame: 2026-01-08

Root Cause

V8’s Irregexp peephole optimizer (v8/src/regexp/regexp-bytecode-peephole.cc) applies templates to shorten bytecodes in a fixpoint loop until no changes occur. For certain optimizations (e.g., B::kSkipUntilOneOfMasked3 and B::kSkipUntilOneOfMasked), the jump target is calculated using EmitOffsetAfterSequence(), which records a kOffsetAfterSequence mapping type.

When emitting the optimization, EmitOptimization writes the target as an absolute PC offset via dst_writer_->OverwriteValue<uint32_t>(pc(), offset). Crucially, unlike standard jump targets handled via EmitArgument(), this raw write bypasses the jump_edges_ map in the BytecodeWriter, which is responsible for tracking all jump targets so they can be adjusted if bytecodes are moved.

The Trigger

If a subsequent pass in the peephole optimizer’s fixpoint loop matches a template that shrinks a bytecode sequence located before the SkipUntilOneOfMasked instruction (e.g., replacing a 2-byte sequence with a 1-byte sequence), all following bytecodes shift to lower PC offsets.

When the code is copied to the new buffer via EmitRawBytecodeStream, standard jump edges are adjusted. However, because the kOffsetAfterSequence offset was not added to jump_edges_, it is copied verbatim and becomes stale. Because preceding code was shrunk, this absolute offset now points beyond the final length of the shortened bytecode array.

Potential Impact

To execute RegExps efficiently, V8 tiers up to native JIT compilation after a certain number of executions. The tier-up process instantiates a CodeGenerator (v8/src/regexp/regexp-code-generator.cc).

The CodeGenerator constructor allocates an array of Label objects and a BitVector for jump targets sequentially in a deterministic bump-pointer Zone heap. Due to alignment and the 8-byte size of Label, the start of the jump_targets_ internal data array immediately follows the end of the labels_ array in memory.

CodeGenerator::PreVisitBytecodes extracts jump target offsets from the bytecode to initialize labels:

uint32_t offset = Operands::template Get<operand>(pc, no_gc);
if (!jump_targets_.Contains(offset)) {
  jump_targets_.Add(offset);
  ...
  Label* label = &labels_[offset];
  new (label) Label();
}

There are no release-build bounds checks on offset. When the stale offset (which is >= ulength) is processed, jump_targets_.Add(offset) performs an out-of-bounds bitwise write, and new (&labels_[offset]) Label() performs an out-of-bounds placement-new.

Because labels_ and jump_targets_ are adjacent in the Zone, &labels_[offset] points directly into the bit storage of jump_targets_. The Label constructor initializes its fields to 0, effectively zeroing out 8 bytes (64 bits) of jump_targets_.

This can erase the bits marking legitimate forward jump targets for other bytecodes. Later, during CodeGenerator::VisitBytecodes(), the compiler will check jump_targets_.Contains(iter_.current_offset()), see a 0, and skip binding the label (__ Bind(&labels_[iter_.current_offset()])).

Consequently, forward branches to that unbound label will remain unresolved. The JIT compiler emits them with intermediate relative link-chain displacements, creating wild branches in the generated machine code. When the RegExp is executed, these wild branches cause the CPU to jump to unintended instructions, allowing a potential attacker to achieve deterministic control flow hijacking and Remote Code Execution (RCE) in the renderer process.

(Note: These are suggested steps based on source code analysis; a working Proof of Concept has not been run.)

Suggested Fix

  1. Modify BytecodePeepholeOptimization to ensure that offsets generated by EmitOffsetAfterSequence are properly added to the jump_edges_ tracking map so they are adjusted during subsequent shrinking passes.
  2. Add release-build bounds checks in CodeGenerator::PreVisitBytecodes (e.g., CHECK_LT(offset, bytecode_->ulength().value())) to harden the JIT compiler against corrupted bytecode and prevent out-of-bounds memory accesses.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker