Low patch-now ✔︎ verified CVSS 8.8 chrome UAF 🔧 Commit mapped

Overview

Low
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentRegexp
Bug ClassUAF
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — 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 AIConfirmed
Reviewer notes: none; diff adds Store/Load RegExpStackPointer around the prologue stack-guard call on every port, matching the claimed GrowStack-relocation UAF write.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker513298483
Fix commite11fdcc64813 (v8/v8) +82/-5
CWECWE-416 (Use-after-free)
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-14 (per vendor advisory)
Fix landed2026-05-21 (public git)
Disclosed2026-06-30
Patch gap40 days — fix public in source before the advisory

Background

Native irregexp
V8’s JIT-compiled regular-expression engine; each architecture has a RegExpMacroAssembler that emits the matcher, including a prologue that checks the C stack limit.
Backtrack stack (`RegExpStack`) and `backtrack_stackpointer()`
a per-isolate growable buffer holding backtrack state during matching; a dedicated machine register (the “BSP”) points into the currently-allocated buffer and Push() writes through it.
Prologue `stack_limit_hit` handler
the code emitted at matcher entry that, when the stack limit is hit, calls CallCheckStackGuardState, which can run pending interrupts/API callbacks.
`StoreRegExpStackPointerToMemory` / `LoadRegExpStackPointerFromMemory`
helpers that round-trip the BSP through RegExpStack::stack_pointer_ in memory, so if the backing buffer is relocated the register is reloaded to the new location (the protocol already used at check_preempt_label_).

Root Cause Analysis

In every port’s native irregexp prologue, the stack_limit_hit handler saved the backtrack_stackpointer() register across CallCheckStackGuardState by pushing it to the machine stack (x64, ia32) or by relying on the C ABI callee-saved guarantee (arm64, arm, loong64, mips64, ppc, riscv, s390), then restored the same register value afterward. Because it saved and restored the raw register rather than round-tripping through RegExpStack::stack_pointer_, a relocation of the backtrack buffer during the call was invisible to it: if CallCheckStackGuardState ran an API interrupt callback that re-entered irregexp and grew the backtrack stack, GrowStack freed the original buffer and allocated a new one, but the restored register still pointed into the freed buffer. Subsequent Push() operations during matching then wrote through that dangling pointer into freed memory — a use-after-free write.

The fix stores the BSP to memory via StoreRegExpStackPointerToMemory before the call and reloads it via LoadRegExpStackPointerFromMemory after the non-zero-return early exit, so any GrowStack-induced relocation is observed, mirroring the existing check_preempt_label_ protocol; RegExpStack::EnsureCapacity is also exported (V8_EXPORT_PRIVATE) for the regression test.

Key insight
The prologue preserved the backtrack stack pointer as a raw register across a call that could relocate (and free) the backtrack buffer, so after a reentrant GrowStack the register dangled; the fix round-trips the pointer through RegExpStack::stack_pointer_ in memory so the reloaded value reflects the relocated buffer.

Attack Path

  1. Enter the prologue stack-limit path Script executes a regexp whose native matcher hits stack_limit_hit at entry, causing the prologue to call CallCheckStackGuardState.
  2. Run a reentrant interrupt That call reaches HandleInterrupts → InvokeApiInterruptCallbacks, running a pending interrupt/API callback (in the regression test, StalePrologueGrowStackInterrupt).
  3. Grow and free the backtrack buffer The callback re-enters and grows the backtrack stack (RegExpStack::EnsureCapacity/GrowStack), freeing the original buffer and allocating a larger one at a new address, while the saved BSP register still holds the old address.
  4. Write through the dangling pointer On return, the prologue restores the stale register and matching proceeds; the next Push() writes into the freed buffer — a use-after-free write (under V8_ENABLE_SANDBOX_HARDWARE_SUPPORT the freed page is unmapped, so the stale Push faults instead).

Impact Assessment

An attacker running script in the renderer can arrange a pending interrupt/API callback that re-enters and grows the backtrack stack during a regexp’s prologue stack-guard check, causing Push() to write through a freed backtrack-buffer pointer — a use-after-free write in the V8 heap. The advisory classifies this as a use-after-free in V8; escalation to a controlled write or code execution is (inferred) and not shown by the patch, and the advisory does not report in-the-wild exploitation. Preconditions are the ability to run a regexp that hits the prologue stack-limit path plus a mechanism to trigger a reentrant interrupt that grows the backtrack stack.

Files Changed

  • src/regexp/arm/regexp-macro-assembler-arm.cc
  • src/regexp/arm64/regexp-macro-assembler-arm64.cc
  • src/regexp/ia32/regexp-macro-assembler-ia32.cc
  • src/regexp/loong64/regexp-macro-assembler-loong64.cc
  • src/regexp/mips64/regexp-macro-assembler-mips64.cc
  • src/regexp/ppc/regexp-macro-assembler-ppc.cc
  • src/regexp/regexp-stack.h
  • src/regexp/riscv/regexp-macro-assembler-riscv.cc
  • src/regexp/s390/regexp-macro-assembler-s390.cc
  • src/regexp/x64/regexp-macro-assembler-x64.cc

Audit Directions

  • Raw-register preservation across relocating calls
    Audit assembler sequences that save/restore pointers into growable/relocatable buffers via push/pop or callee-saved registers around calls that can free or move those buffers; such pointers must be round-tripped through canonical memory.
  • Reentrancy through stack-guard/interrupt calls
    Review every CallCheckStackGuardState / interrupt-invoking call site for state (buffer pointers, cached lengths) that can be invalidated by a reentrant callback and must be reloaded afterward.
  • Cross-port protocol consistency
    When one register-save protocol (here check_preempt_label_) is known-correct, verify all architecture ports and all sibling handlers (e.g. other prologue exits) use the same protocol rather than an ABI shortcut.
From e11fdcc64813b765779e970dc0dcba0674ffcdfc Mon Sep 17 00:00:00 2001
From: Jakob Linke <[email protected]>
Date: Tue, 19 May 2026 09:17:25 +0200
Subject: [PATCH] Reland "[regexp] Refresh backtrack SP across prologue stack-guard call"

This is a reland of commit 379e61d3994ddbab474d3344cfee87585c41f3b5

Only the test changed st we no longer write sentinel patterns into
unmapped memory. The test is now limited to
V8_ENABLE_SANDBOX_HARDWARE_SUPPORT, where it reliably triggers on the
original bug.

Original change's description:
> [regexp] Refresh backtrack SP across prologue stack-guard call
>
> The native irregexp prologue's stack_limit_hit handler saved the
> backtrack_stackpointer register across CallCheckStackGuardState via
> push/pop (x64, ia32) or relied on the C ABI's callee-saved guarantee
> (arm64, arm, loong64, mips64, ppc, riscv, s390). If that call ran an
> API interrupt callback that re-entered irregexp and grew the backtrack
> stack, the original buffer was freed but the register kept pointing
> into it. Subsequent Push() operations during matching wrote into freed
> memory.
>
> Mirror the check_preempt_label_ protocol on every port: store the
> backtrack_stackpointer to RegExpStack::stack_pointer_ before the call,
> reload it after, so GrowStack-induced relocation is observed.
>
> Fixed: 513298483
> Change-Id: I8ab7e850bb4963c69afa1552fc96bb1316c1c0f6
> Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7849379
> Auto-Submit: Jakob Linke <[email protected]>
> Commit-Queue: Jakob Linke <[email protected]>
> Reviewed-by: Patrick Thier <[email protected]>
> Cr-Commit-Position: refs/heads/main@{#107370}

Bug: 513298483
Cq-Include-Trybots: luci.v8.try:v8_linux64_pku_dbg,v8_linux64_pku_rel
Change-Id: I230219c9602f515013efcd353423a23d882d0cc8
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7852919
Auto-Submit: Jakob Linke <[email protected]>
Reviewed-by: Patrick Thier <[email protected]>
Commit-Queue: Jakob Linke <[email protected]>
Cr-Commit-Position: refs/heads/main@{#107475}
---

diff --git a/src/regexp/arm/regexp-macro-assembler-arm.cc b/src/regexp/arm/regexp-macro-assembler-arm.cc
index 5c19dd8..25294b9 100644
--- a/src/regexp/arm/regexp-macro-assembler-arm.cc
+++ b/src/regexp/arm/regexp-macro-assembler-arm.cc
@@ -762,10 +762,12 @@
     __ jmp(&return_r0);
 
     __ bind(&stack_limit_hit);
+    StoreRegExpStackPointerToMemory(backtrack_stackpointer(), r1);
     CallCheckStackGuardState(extra_space_for_variables);
     __ cmp(r0, Operand::Zero());
     // If returned value is non-zero, we exit with the returned value as result.
     __ b(ne, &return_r0);
+    LoadRegExpStackPointerFromMemory(backtrack_stackpointer());
 
     __ bind(&stack_ok);
   }
diff --git a/src/regexp/arm64/regexp-macro-assembler-arm64.cc b/src/regexp/arm64/regexp-macro-assembler-arm64.cc
index 24d5700..f47bce4 100644
--- a/src/regexp/arm64/regexp-macro-assembler-arm64.cc
+++ b/src/regexp/arm64/regexp-macro-assembler-arm64.cc
@@ -1289,9 +1289,11 @@
     __ B(&return_w0);
 
     __ Bind(&stack_limit_hit);
+    StoreRegExpStackPointerToMemory(backtrack_stackpointer(), x10);
     CallCheckStackGuardState(x10, extra_space_for_variables);
     // If returned value is non-zero, we exit with the returned value as result.
     __ Cbnz(w0, &return_w0);
+    LoadRegExpStackPointerFromMemory(backtrack_stackpointer());
 
     __ Bind(&stack_ok);
   }
diff --git a/src/regexp/ia32/regexp-macro-assembler-ia32.cc b/src/regexp/ia32/regexp-macro-assembler-ia32.cc
index 7206237..4136eaf 100644
--- a/src/regexp/ia32/regexp-macro-assembler-ia32.cc
+++ b/src/regexp/ia32/regexp-macro-assembler-ia32.cc
@@ -807,12 +807,12 @@
     __ jmp(&return_eax);
 
     __ bind(&stack_limit_hit);
-    __ push(backtrack_stackpointer());
+    StoreRegExpStackPointerToMemory(backtrack_stackpointer(), edi);
     CallCheckStackGuardState(ebx, extra_space_for_variables);
-    __ pop(backtrack_stackpointer());
     __ or_(eax, eax);
     // If returned value is non-zero, we exit with the returned value as result.
     __ j(not_zero, &return_eax);
+    LoadRegExpStackPointerFromMemory(backtrack_stackpointer());
 
     __ bind(&stack_ok);
   }
diff --git a/src/regexp/loong64/regexp-macro-assembler-loong64.cc b/src/regexp/loong64/regexp-macro-assembler-loong64.cc
index 9f451cc..b7dcd8b 100644
--- a/src/regexp/loong64/regexp-macro-assembler-loong64.cc
+++ b/src/regexp/loong64/regexp-macro-assembler-loong64.cc
@@ -741,10 +741,12 @@
       __ jmp(&return_v0);
 
       __ bind(&stack_limit_hit);
+      StoreRegExpStackPointerToMemory(backtrack_stackpointer(), a1);
       CallCheckStackGuardState(a0, extra_space_for_variables);
       // If returned value is non-zero, we exit with the returned value as
       // result.
       __ Branch(&return_v0, ne, a0, Operand(zero_reg));
+      LoadRegExpStackPointerFromMemory(backtrack_stackpointer());
 
       __ bind(&stack_ok);
     }
diff --git a/src/regexp/mips64/regexp-macro-assembler-mips64.cc b/src/regexp/mips64/regexp-macro-assembler-mips64.cc
index 0d4f58e..b0abf9e 100644
--- a/src/regexp/mips64/regexp-macro-assembler-mips64.cc
+++ b/src/regexp/mips64/regexp-macro-assembler-mips64.cc
@@ -775,10 +775,12 @@
       __ jmp(&return_v0);
 
       __ bind(&stack_limit_hit);
+      StoreRegExpStackPointerToMemory(backtrack_stackpointer(), a0);
       CallCheckStackGuardState(a0, extra_space_for_variables);
       // If returned value is non-zero, we exit with the returned value as
       // result.
       __ Branch(&return_v0, ne, v0, Operand(zero_reg));
+      LoadRegExpStackPointerFromMemory(backtrack_stackpointer());
 
       __ bind(&stack_ok);
     }
diff --git a/src/regexp/ppc/regexp-macro-assembler-ppc.cc b/src/regexp/ppc/regexp-macro-assembler-ppc.cc
index 9add970..2fa0fd7 100644
--- a/src/regexp/ppc/regexp-macro-assembler-ppc.cc
+++ b/src/regexp/ppc/regexp-macro-assembler-ppc.cc
@@ -808,11 +808,13 @@
       __ b(&return_r3);
 
       __ bind(&stack_limit_hit);
+      StoreRegExpStackPointerToMemory(backtrack_stackpointer(), r4);
       CallCheckStackGuardState(r3, extra_space_for_variables);
       __ cmpi(r3, Operand::Zero());
       // If returned value is non-zero, we exit with the returned value as
       // result.
       __ bne(&return_r3);
+      LoadRegExpStackPointerFromMemory(backtrack_stackpointer());
 
       __ bind(&stack_ok);
     }
diff --git a/src/regexp/regexp-stack.h b/src/regexp/regexp-stack.h
index 3a5d796..cae82b0 100644
--- a/src/regexp/regexp-stack.h
+++ b/src/regexp/regexp-stack.h
@@ -82,7 +82,7 @@
 
   // Ensures that there is a memory area with at least the specified size.
   // If passing zero, the default/minimum size buffer is allocated.
-  Address EnsureCapacity(size_t size);
+  V8_EXPORT_PRIVATE Address EnsureCapacity(size_t size);
 
   // Thread local archiving.
   static constexpr int ArchiveSpacePerThread() {
diff --git a/src/regexp/riscv/regexp-macro-assembler-riscv.cc b/src/regexp/riscv/regexp-macro-assembler-riscv.cc
index 7b59334..8abad4c 100644
--- a/src/regexp/riscv/regexp-macro-assembler-riscv.cc
+++ b/src/regexp/riscv/regexp-macro-assembler-riscv.cc
@@ -876,10 +876,12 @@
     __ jmp(&return_a0);
 
     __ bind(&stack_limit_hit);
+    StoreRegExpStackPointerToMemory(backtrack_stackpointer(), a1);
     CallCheckStackGuardState(a0, extra_space_for_variables);
     // If returned value is non-zero, we exit with the returned value as
     // result.
     __ Branch(&return_a0, ne, a0, Operand(zero_reg));
+    LoadRegExpStackPointerFromMemory(backtrack_stackpointer());
 
     __ bind(&stack_ok);
   }
diff --git a/src/regexp/s390/regexp-macro-assembler-s390.cc b/src/regexp/s390/regexp-macro-assembler-s390.cc
index acb7993..a2f3fc3 100644
--- a/src/regexp/s390/regexp-macro-assembler-s390.cc
+++ b/src/regexp/s390/regexp-macro-assembler-s390.cc
@@ -827,10 +827,12 @@
     __ b(&return_r2);
 
     __ bind(&stack_limit_hit);
+    StoreRegExpStackPointerToMemory(backtrack_stackpointer(), r3);
     CallCheckStackGuardState(r2, extra_space_for_variables);
     __ CmpS64(r2, Operand::Zero());
     // If returned value is non-zero, we exit with the returned value as result.
     __ bne(&return_r2);
+    LoadRegExpStackPointerFromMemory(backtrack_stackpointer());
 
     __ bind(&stack_ok);
   }
diff --git a/src/regexp/x64/regexp-macro-assembler-x64.cc b/src/regexp/x64/regexp-macro-assembler-x64.cc
index 8cba65e..cb36bd8 100644
--- a/src/regexp/x64/regexp-macro-assembler-x64.cc
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 0fed7fd..5d9ebbf 100644
--- a/test/unittests/regexp/regexp-unittest.cc
+++ b/test/unittests/regexp/regexp-unittest.cc
@@ -26,6 +26,7 @@
 #include "src/regexp/regexp-interpreter.h"
 #include "src/regexp/regexp-macro-assembler-arch.h"
 #include "src/regexp/regexp-parser.h"
+#include "src/regexp/regexp-stack.h"
 #include "src/strings/char-predicates-inl.h"
 #include "src/strings/string-stream.h"
 #include "src/strings/unicode-inl.h"
@@ -2626,6 +2627,68 @@
   CHECK(IsNull(*result));
 }
 
+// The bug below is only reliably observable under sandbox hardware support,
+// where the freed RegExpStack page is unmapped and a buggy JIT Push through
+// the dangling BSP segfaults. On other builds the success-path BSP recovery
+// hides the divergence.
+#ifdef V8_ENABLE_SANDBOX_HARDWARE_SUPPORT
+
+namespace {
+
+// Runs from the prologue's stack_limit_hit handler via CheckStackGuardState ->
+// HandleInterrupts -> InvokeApiInterruptCallbacks. Doubles the backtrack
+// stack to force GrowStack to relocate the per-isolate buffer; under sandbox
+// hardware support the freed page is unmapped (vas->FreePages -> munmap), so
+// any subsequent JIT Push through a stale BSP faults.
+void StalePrologueGrowStackInterrupt(v8::Isolate*, void* data) {
+  auto* i_iso = static_cast<i::Isolate*>(data);
+  i_iso->regexp_stack()->EnsureCapacity(i_iso->regexp_stack()->memory_size() *
+                                        2);
+}
+
+}  // namespace
+
+// Regression test for crbug.com/513298483. The native irregexp prologue's
+// stack_limit_hit handler used to save/restore the backtrack_stackpointer with
+// pushq/popq (x64, ia32) or via callee-saved registers (other arches) around
+// CallCheckStackGuardState, instead of round-tripping it through
+// RegExpStack::stack_pointer_. If the call ran an API interrupt that
+// re-entered irregexp and grew the backtrack stack (freeing the original
+// buffer), the restored register held a dangling pointer that subsequent
+// Push() operations wrote through. The fix replaces the push/pop with
+// Store/LoadRegExpStackPointerToMemory on every port, mirroring
+// check_preempt_label_.
+TEST_F(RegExpTestWithContext, RegExpInterruptStalePrologueBacktrackPointer) {
+  if (v8_flags.jitless) return;
+  v8_flags.regexp_tier_up_ticks = 0;  // Compile to native on first exec.
+
+  v8::HandleScope scope(isolate());
+  i::Isolate* i_iso = i_isolate();
+
+  // Pre-grow the per-isolate backtrack stack so EnsureCapacity in the
+  // interrupt actually frees a heap buffer (rather than transitioning off the
+  // static stack).
+  i_iso->regexp_stack()->EnsureCapacity(2 * i::KB);
+
+  isolate()->RequestInterrupt(&StalePrologueGrowStackInterrupt, i_iso);
+
+  // Any regexp that hits the prologue's stack_limit_hit path will do.
+  i::DirectHandle<i::JSRegExp> regexp = v8::Utils::OpenDirectHandle(
+      *v8::RegExp::New(context(), NewString("(a|b)(a|b)(a|b)c"),
+                       v8::RegExp::kNone)
+           .ToLocalChecked());
+  i::DirectHandle<i::String> subject =
+      v8::Utils::OpenDirectHandle(*NewString("aaac"));
+
+  i::DirectHandle<i::Object> result =
+      i::RegExp::Exec_Single(i_iso, regexp, subject, 0,
+                             i_iso->regexp_last_match_info())
+          .ToHandleChecked();
+  USE(result);
+}
+
+#endif  // V8_ENABLE_SANDBOX_HARDWARE_SUPPORT
+
 #undef CHECK_PARSE_ERROR
 #undef CHECK_SIMPLE
 #undef CHECK_MIN_MAX
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.