CVE-2026-14403
Overview
Background
- Native irregexp
- V8’s JIT-compiled regular-expression engine; each architecture has a
RegExpMacroAssemblerthat 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 atcheck_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.
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
- Enter the prologue stack-limit path
Script executes a regexp whose native matcher hits
stack_limit_hitat entry, causing the prologue to callCallCheckStackGuardState. - Run a reentrant interrupt
That call reaches
HandleInterrupts→InvokeApiInterruptCallbacks, running a pending interrupt/API callback (in the regression test,StalePrologueGrowStackInterrupt). - 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. - 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 (underV8_ENABLE_SANDBOX_HARDWARE_SUPPORTthe freed page is unmapped, so the stalePushfaults instead).
Impact Assessment
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.ccsrc/regexp/arm64/regexp-macro-assembler-arm64.ccsrc/regexp/ia32/regexp-macro-assembler-ia32.ccsrc/regexp/loong64/regexp-macro-assembler-loong64.ccsrc/regexp/mips64/regexp-macro-assembler-mips64.ccsrc/regexp/ppc/regexp-macro-assembler-ppc.ccsrc/regexp/regexp-stack.hsrc/regexp/riscv/regexp-macro-assembler-riscv.ccsrc/regexp/s390/regexp-macro-assembler-s390.ccsrc/regexp/x64/regexp-macro-assembler-x64.cc
Audit Directions
- Raw-register preservation across relocating callsAudit 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 callsReview 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 consistencyWhen 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.
Patch
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
Regression Test / PoC
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