CVE-2025-6191
Overview
Background
- `EscapeRegExpSource`
- the routine that produces the escaped textual source of a
RegExp(e.g. forRegExp.prototype.source), escaping characters like unescaped/and line terminators. - `CountAdditionalEscapeChars`
- a first pass that counts how many extra characters escaping will add, so the destination string can be sized before a second write pass fills it.
- Growth factor
- the worst per-character expansion is 5 (line separators `
- `String::kMaxLength`
- V8’s maximum string length; a source can approach it, so intermediate counts can be large enough to overflow a 32-bit signed accumulator.
Root Cause Analysis
In src/objects/js-regexp.cc, CountAdditionalEscapeChars accumulated the number of added escape characters in a signed int escapes. For a regexp source near String::kMaxLength consisting largely of characters with the maximum 5× growth factor, the true additional count can exceed INT_MAX, overflowing the signed accumulator (undefined behavior / a wrapped, possibly negative value). The caller then computed int length = source->length() + additional_escape_chars, also in signed int, and used that truncated/negative length to allocate the result SeqOneByteString/SeqTwoByteString; the second pass then wrote all the real escaped output into that under-sized buffer, writing out of bounds.
The fix changes escapes, the WriteStringToCharVector cursor d, the write-pass indices s/d, and additional_escape_chars/length to uint32_t, and adds a static_assert(uint64_t{String::kMaxLength} * 5 < numeric_limits<uint32_t>::max()) proving a uint32_t can always hold the maximum escaped length, plus a DCHECK_LE bounding source->length() + additional_escape_chars to uint32_t range. This guarantees the counted length matches the buffer that is actually allocated, eliminating the under-allocation.
int, which can overflow for near-maximum-length sources and yield an under-sized allocation; the fix moves the counter and length arithmetic to uint32_t and statically proves that type always suffices for kMaxLength * 5.Attack Path
- Build a huge escapable source
Script constructs a
RegExpwhose source string is close toString::kMaxLengthand dominated by characters that escape to the 5× worst case (e.g. line separators). - Trigger escaping
Script reads the escaped source (e.g. via
RegExp.prototype.source/ a path callingEscapeRegExpSource), invokingCountAdditionalEscapeChars. - Overflow the count
The signed
int escapes/lengtharithmetic overflows, producing a wrapped (too-small or negative) length used to allocate the result string. - Out-of-bounds write
The write pass emits the full escaped output into the under-allocated
Seq{One,Two}ByteString, writing past its end and corrupting adjacent heap memory (the diff shows the overflow and the sizing fix; the corruption is the direct consequence of the under-sized allocation).
Impact Assessment
EscapeRegExpSource (advisory: “Integer overflow in V8”), a corruption primitive suitable for further exploitation. The precondition is the ability to allocate and escape a regexp source near String::kMaxLength, which requires substantial memory but is reachable from ordinary JavaScript. The advisory does not report in-the-wild exploitation, and any escalation to a read/write primitive or code execution beyond the OOB write is not demonstrated by this diff (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/objects/js-regexp.cc |
modified |
Files Changed
src/objects/js-regexp.cc
Audit Directions
- Two-pass size-then-writeAudit count-then-allocate-then-fill patterns for accumulators or length sums in
intthat can overflow when inputs approachString::kMaxLengthor other large bounds. - Growth-factor arithmeticReview string transformations (escaping, encoding, normalization) whose output can be a multiple of the input length and confirm the size type can hold
maxInput * maxGrowth(ideally enforced bystatic_assert). - Signed length mixingSearch for
int length = a + bsizing an allocation wherea/bderive from lengths; prefer unsigned/size_tand range-checkingDCHECKs as added here.
Patch
From 62ee3244f3b212b92e22b8e2651afbed35de9768 Mon Sep 17 00:00:00 2001
From: pthier <pthier@chromium.org>
Date: Tue, 03 Jun 2025 09:16:02 +0200
Subject: [PATCH] [regexp] Use uint32_t over int in EscapeRegExpSource
While escaping the regexp source, a regular signed int might overflow,
while an uint32_t can always hold the maximum length for the escaped
source.
Fixed: 420697404
Change-Id: I61084b93d51a2729204332b4285922fd82ee8fc7
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/6611425
Reviewed-by: Jakob Linke <jgruber@chromium.org>
Auto-Submit: Patrick Thier <pthier@chromium.org>
Commit-Queue: Patrick Thier <pthier@chromium.org>
Cr-Commit-Position: refs/heads/main@{#100636}
---
diff --git a/src/objects/js-regexp.cc b/src/objects/js-regexp.cc
index e365ac4..6049036 100644
--- a/src/objects/js-regexp.cc
+++ b/src/objects/js-regexp.cc
@@ -189,10 +189,14 @@
// WriteEscapedRegExpSource into a single function to deduplicate dispatch logic
// and move related code closer to each other.
template <typename Char>
-int CountAdditionalEscapeChars(DirectHandle<String> source,
- bool* needs_escapes_out) {
+uint32_t CountAdditionalEscapeChars(DirectHandle<String> source,
+ bool* needs_escapes_out) {
DisallowGarbageCollection no_gc;
- int escapes = 0;
+ uint32_t escapes = 0;
+ // The maximum growth-factor is 5 (for \u2028 and \u2029). Make sure that we
+ // won't overflow |escapes| given the current constraints on string length.
+ static_assert(uint64_t{String::kMaxLength} * 5 <
+ std::numeric_limits<decltype(escapes)>::max());
bool needs_escapes = false;
bool in_character_class = false;
base::Vector<const Char> src = source->GetCharVector<Char>(no_gc);
@@ -231,14 +235,14 @@
}
}
DCHECK(!in_character_class);
- DCHECK_GE(escapes, 0);
DCHECK_IMPLIES(escapes != 0, needs_escapes);
*needs_escapes_out = needs_escapes;
return escapes;
}
template <typename Char>
-void WriteStringToCharVector(base::Vector<Char> v, int* d, const char* string) {
+void WriteStringToCharVector(base::Vector<Char> v, uint32_t* d,
+ const char* string) {
int s = 0;
while (string[s] != '\0') v[(*d)++] = string[s++];
}
@@ -249,13 +253,13 @@
DisallowGarbageCollection no_gc;
base::Vector<const Char> src = source->GetCharVector<Char>(no_gc);
base::Vector<Char> dst(result->GetChars(no_gc), result->length());
- int s = 0;
- int d = 0;
+ uint32_t s = 0;
+ uint32_t d = 0;
bool in_character_class = false;
- while (s < src.length()) {
+ while (s < src.size()) {
const Char c = src[s];
if (c == '\\') {
- if (s + 1 < src.length() && IsLineTerminator(src[s + 1])) {
+ if (s + 1 < src.size() && IsLineTerminator(src[s + 1])) {
// This '\' is ignored since the next character itself will be escaped.
s++;
continue;
@@ -263,7 +267,7 @@
// Escape. Copy this and next character.
dst[d++] = src[s++];
}
- if (s == src.length()) break;
+ if (s == src.size()) break;
} else if (c == '/' && !in_character_class) {
// Not escaped forward-slash needs escape.
dst[d++] = '\\';
@@ -303,11 +307,13 @@
if (source->length() == 0) return isolate->factory()->query_colon_string();
bool one_byte = String::IsOneByteRepresentationUnderneath(*source);
bool needs_escapes = false;
- int additional_escape_chars =
+ uint32_t additional_escape_chars =
one_byte ? CountAdditionalEscapeChars<uint8_t>(source, &needs_escapes)
: CountAdditionalEscapeChars<base::uc16>(source, &needs_escapes);
if (!needs_escapes) return source;
- int length = source->length() + additional_escape_chars;
+ DCHECK_LE(static_cast<uint64_t>(source->length()) + additional_escape_chars,
+ std::numeric_limits<uint32_t>::max());
+ uint32_t length = source->length() + additional_escape_chars;
if (one_byte) {
DirectHandle<SeqOneByteString> result;
ASSIGN_RETURN_ON_EXCEPTION(isolate, result,
Original Bug Report
Debug check failed: escapes >= 0 (-2005397586 vs. 0)
VULNERABILITY DETAILS
#
# Fatal error in ..\..\src\objects\js-regexp.cc, line 234
# Debug check failed: escapes >= 0 (-2005397586 vs. 0).
#
#
#
#FailureMessage Object: 00000012179FD550
==== C stack trace ===============================
v8::base::debug::StackTrace::StackTrace [0x0x7fff2bb5b685+37] (D:\Browser\v8\v8\src\base\debug\stack_trace_win.cc:173)
v8::platform::`anonymous namespace'::PrintStackTrace [0x0x7fff2bcdab89+57] (D:\Browser\v8\v8\src\libplatform\default-platform.cc:28)
V8_Fatal [0x0x7fff2bb35463+323] (D:\Browser\v8\v8\src\base\logging.cc:214)
v8::base::`anonymous namespace'::DefaultDcheckHandler [0x0x7fff2bb34e8c+44] (D:\Browser\v8\v8\src\base\logging.cc:59)
V8_Dcheck [0x0x7fff2bb35571+81] (D:\Browser\v8\v8\src\base\logging.cc:228)
v8::internal::`anonymous namespace'::CountAdditionalEscapeChars<unsigned short> [0x0x7ffeaae0b824+740] (D:\Browser\v8\v8\src\objects\js-regexp.cc:234)
v8::internal::`anonymous namespace'::EscapeRegExpSource [0x0x7ffeaae08824+420] (D:\Browser\v8\v8\src\objects\js-regexp.cc:306)
v8::internal::JSRegExp::Initialize [0x0x7ffeaae07dfa+618] (D:\Browser\v8\v8\src\objects\js-regexp.cc:344)
v8::internal::JSRegExp::Initialize [0x0x7ffeaae08581+529] (D:\Browser\v8\v8\src\objects\js-regexp.cc:179)
v8::internal::__RT_impl_Runtime_RegExpInitializeAndCompile [0x0x7ffeab3fe615+549] (D:\Browser\v8\v8\src\runtime\runtime-regexp.cc:2147)
v8::internal::Runtime_RegExpInitializeAndCompile [0x0x7ffeab3fe10d+413] (D:\Browser\v8\v8\src\runtime\runtime-regexp.cc:2137)
Builtins_CEntry_Return1_ArgvOnStack_NoBuiltinExit [0x0x7ffeaf6b5b41+65]
Builtins_RegExpConstructor [0x0x7ffeaf5f40db+7579]
Builtins_InterpreterPushArgsThenFastConstructFunction [0x0x7ffeaf247501+1089]
Builtins_ConstructHandler [0x0x7ffeafe522c5+8261]
Builtins_InterpreterEntryTrampoline [0x0x7ffeaf2467f2+370]
Builtins_InterpreterEntryTrampoline [0x0x7ffeaf2467f2+370]
Builtins_JSEntryTrampoline [0x0x7ffeaf239ce7+103]
Builtins_JSEntry [0x0x7ffeaf23983f+255]
v8::internal::GeneratedCode<unsigned long long,unsigned long long,unsigned long long,unsigned long long,unsigned long long,long long,unsigned long long **>::Call [0x0x7ffea9f5438c+108] (D:\Browser\v8\v8\src\execution\simulator.h:212)
v8::internal::`anonymous namespace'::Invoke [0x0x7ffea9f503cd+5469] (D:\Browser\v8\v8\src\execution\execution.cc:441)
v8::internal::Execution::CallScript [0x0x7ffea9f50c61+513] (D:\Browser\v8\v8\src\execution\execution.cc:542)
v8::Script::Run [0x0x7ffea978b5cf+1135] (D:\Browser\v8\v8\src\api\api.cc:1964)
v8::Script::Run [0x0x7ffea978b148+120] (D:\Browser\v8\v8\src\api\api.cc:1929)
v8::Shell::ExecuteString [0x0x7ff6ae1d21b6+3142] (D:\Browser\v8\v8\src\d8\d8.cc:1030)
v8::SourceGroup::Execute [0x0x7ff6ae1f7e26+1174] (D:\Browser\v8\v8\src\d8\d8.cc:5073)
v8::Shell::RunMainIsolate [0x0x7ff6ae1ff287+631] (D:\Browser\v8\v8\src\d8\d8.cc:6027)
v8::Shell::RunMain [0x0x7ff6ae1feced+205] (D:\Browser\v8\v8\src\d8\d8.cc:5935)
v8::Shell::Main [0x0x7ff6ae201901+3873] (D:\Browser\v8\v8\src\d8\d8.cc:6801)
main [0x0x7ff6ae202333+35] (D:\Browser\v8\v8\src\d8\d8.cc:6893)
invoke_main [0x0x7ff6ae368489+57] (D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl:79)
__scrt_common_main_seh [0x0x7ff6ae3685c2+306] (D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl:288)
__scrt_common_main [0x0x7ff6ae36864e+14] (D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_common.inl:331)
mainCRTStartup [0x0x7ff6ae36866e+14] (D:\a\_work\1\s\src\vctools\crt\vcstartup\src\startup\exe_main.cpp:17)
BaseThreadInitThunk [0x0x7fff6adfe8d7+23]
RtlUserThreadStart [0x0x7fff6cafc5dc+44]
VERSION
V8 version 13.9.0 (candidate)
REPRODUCTION CASE
Build: python3 tools/dev/gm.py x64.debug
Run: ./d8 poc.js
Reporter credit: Shaheen Fazim