CVE-2026-3926
Overview
Background
- `RegExp.escape`
- a JavaScript builtin that escapes regex-special characters in a string; its C++ implementation flattens the input and copies the characters into an
IncrementalStringBuilder. - One-byte vs two-byte strings
- V8 stores strings either as one byte per character (Latin-1) or two bytes per character (UTF-16), and code must read the backing store with the stride matching the actual representation.
- `IsOneByteRepresentation` vs `IsOneByteRepresentationUnderneath`
- the former reports the representation flag on the string object itself, while the latter follows through indirect strings (
ConsString,SlicedString,ThinString) to the representation of the actual underlying sequential string. - `String::Flatten`
- produces a flat string but may leave the handle pointing at an indirect wrapper (e.g. a flattened
ConsStringor aThinString) whose own representation flag can differ from the flat data underneath.
Root Cause Analysis
After str = String::Flatten(isolate, str), the code decided which character-width path to take by testing str->IsOneByteRepresentation(), which inspects only the top-level string object’s representation flag. The commit message states the code “wrongly used IsOneByteRepresentation() instead of IsOneByteRepresentationUnderneath(),” meaning that for an indirect string whose surface flag disagrees with its underlying sequential string, the classification is wrong. When the surface reports one-byte but the actual backing characters are two-byte, the one-byte branch (which builds an OwnedVector<const uint8_t> copy under DisallowGarbageCollection) reads the characters with a one-byte stride/length assumption against a two-byte backing store, producing an out-of-bounds read.
The fix replaces the test with String::IsOneByteRepresentationUnderneath(*str), which resolves through indirect string layers to the true representation, so the correct-width path is always taken. This restores the invariant that the byte-width used to copy characters matches the string’s real backing store.
IsOneByteRepresentationUnderneath makes the width decision reflect the actual backing store so the character copy stays in bounds.Attack Path
- Construct a misclassifiable string An attacker builds a string (e.g. via cons/slice/internalization) whose top-level representation flag reports one-byte while the underlying flat data is two-byte.
- Call `RegExp.escape` on it
Invoking
RegExp.escape(str)reaches the C++ implementation, which callsString::Flattenand leaves the handle referring to the indirect wrapper. - Wrong branch selection
str->IsOneByteRepresentation()returns the surface (one-byte) answer, so the code enters the one-byte copy path even though the real characters are two-byte. - Out-of-bounds read The one-byte character copy reads the two-byte backing store with a mismatched width/length, reading beyond the intended character data. (inferred)
Impact Assessment
RegExp.escape to read out of bounds in the V8 heap, which the advisory classifies as an out-of-bounds read in V8; leaked bytes could be observed through the escaped output. (inferred) Preconditions are only the ability to run script and pass an adversarially constructed string; the advisory does not report exploitation in the wild, and any use of the leaked data for further compromise is (inferred).Files Changed
src/builtins/builtins-regexp.cc
Audit Directions
- Surface vs underlying representation checksGrep for
IsOneByteRepresentation()used to select a byte-width copy or access path, and verifyIsOneByteRepresentationUnderneathis used wherever the string may be an indirect (ConsString/SlicedString/ThinString) wrapper. - Post-`Flatten` assumptionsAudit code that assumes
String::Flattenyields a sequential string; the returned handle can still be indirect, so representation and pointer-to-chars queries must account for that. - Character-copy loops under `DisallowGarbageCollection`Review raw character extraction into
OwnedVector/SeqStringbuffers where a width mismatch between the assumed and actual representation would over-read or under-read the backing store.
Patch
From 134c3696cd53593d7a668bf3cc88a06a1750c67d Mon Sep 17 00:00:00 2001 From: pthier <[email protected]> Date: Tue, 27 Jan 2026 16:58:47 +0100 Subject: [PATCH] Fix RegExp.escape byte check RegExp.escape wrongly used IsOneByteRepresentation() instead of IsOneByteRepresentationUnderneath(). Fixed: 478659010 Bug: 353856236 Change-Id: Icbee422d6e76f423c447877956554156287b72d8 Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7520539 Commit-Queue: Jakob Linke <[email protected]> Auto-Submit: Patrick Thier <[email protected]> Reviewed-by: Jakob Linke <[email protected]> Cr-Commit-Position: refs/heads/main@{#104948} --- diff --git a/src/builtins/builtins-regexp.cc b/src/builtins/builtins-regexp.cc index fc2ddc1..8566a84 100644 --- a/src/builtins/builtins-regexp.cc +++ b/src/builtins/builtins-regexp.cc @@ -370,7 +370,7 @@ // A copy of the input characters is needed because RegExpEscapeImpl builds up // the escaped string using IncrementalStringBuilder, which may allocate. str = String::Flatten(isolate, str); - if (str->IsOneByteRepresentation()) { + if (String::IsOneByteRepresentationUnderneath(*str)) { base::OwnedVector<const uint8_t> copy; { DisallowGarbageCollection no_gc;
Original Bug Report
RegExp.escape byteness mismatch OOB read
#VULNERABILITY DETAILS RegExp.escape determines whether to copy the input as one-byte or two-byte based on the string’s map (String::IsOneByteRepresentation). A crafted SlicedString can keep a two-byte map while its underlying storage becomes one-byte after internalization/thinning, causing RegExp.escape to read the string as UC16 from one-byte backing storage. This results in an out-of-bounds read.
#VERSION d8 Version: [14.6.0] + [candidate] Operating System: [Ubuntu 24.04 x64]
#Root cause RegExp.escape flattens the input, then branches on IsOneByteRepresentation():
- builtins-regexp.cc:352-388
- String::Flatten(isolate, str)
- if (str->IsOneByteRepresentation())
copy = OwnedCopyOf(str->GetFlatContent().ToOneByteVector())
else
copy = OwnedCopyOf(str->GetFlatContent().ToUC16Vector())
IsOneByteRepresentation() is map-based (string shape), not content-based:
- string-inl.h:521-523
String::IsOneByteRepresentation() -> InstanceTypeChecker::IsOneByteString(map)
When a non-internalized string is internalized, it can become a ThinString that points to a canonical internalized string whose map encoding is one-byte:
- string.cc:155-200
String::MakeThin uses internalized->IsOneByteRepresentation() to select the
thin map and rewrites the original to ThinString(actual = internalized)
For sliced strings, the slice keeps its own map (two-byte) even if the parent string becomes thin to a one-byte internalized canonical. This creates a byteness mismatch: map says two-byte, backing storage is one-byte.
#Crash State
=================================================================
==125374==ERROR: AddressSanitizer: use-after-poison on address 0x6e12c0005028 at pc 0x57ee1e60cffb bp 0x7ffcef6cb890 sp 0x7ffcef6cb050
READ of size 40 at 0x6e12c0005028 thread T0
#0 0x57ee1e60cffa in __asan_memcpy (/home/qy/new2/v8/out/x64.asan/d8+0x13b8ffa) (BuildId: e276665bb2c58cdf)
#1 0x57ee1eb00b1f in MemCopy src/base/memcopy.h
#2 0x57ee1eb00b1f in Copy<const unsigned short *, unsigned short *> src/base/algorithm.h:38:3
#3 0x57ee1eb00b1f in NewByCopying<unsigned short> src/base/vector.h:304:5
#4 0x57ee1eb00b1f in OwnedCopyOf<unsigned short> src/base/vector.h:386:10
#5 0x57ee1eb00b1f in OwnedCopyOf<v8::base::Vector<const unsigned short> > src/base/vector.h:395:10
#6 0x57ee1eb00b1f in v8::internal::Builtin_Impl_RegExpEscape(v8::internal::BuiltinArguments, v8::internal::Isolate*) src/builtins/builtins-regexp.cc:385:14
#7 0x57ee23415235 in Builtins_CEntry_Return1_ArgvOnStack_BuiltinExit setup-isolate-deserialize.cc
#8 0x57ee23364829 in Builtins_InterpreterEntryTrampoline setup-isolate-deserialize.cc
#9 0x57ee233615db in Builtins_JSEntryTrampoline setup-isolate-deserialize.cc
#10 0x57ee2336132a in Builtins_JSEntry setup-isolate-deserialize.cc
#11 0x57ee1ed1a126 in Call src/execution/simulator.h:216:12
#12 0x57ee1ed1a126 in v8::internal::(anonymous namespace)::Invoke(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) src/execution/execution.cc:442:22
#13 0x57ee1ed1b5a8 in v8::internal::Execution::CallScript(v8::internal::Isolate*, v8::internal::DirectHandle<v8::internal::JSFunction>, v8::internal::DirectHandle<v8::internal::Object>, v8::internal::DirectHandle<v8::internal::Object>) src/execution/execution.cc:542:10
#14 0x57ee1e936b4b in v8::Script::Run(v8::Local<v8::Context>, v8::Local<v8::Data>) src/api/api.cc:2015:7
#15 0x57ee1e6780e7 in v8::Shell::ExecuteString(v8::Isolate*, v8::Local<v8::String>, v8::Local<v8::String>, v8::Shell::ReportExceptions, v8::Global<v8::Value>*) src/d8/d8.cc:1037:44
#16 0x57ee1e6b0549 in v8::SourceGroup::Execute(v8::Isolate*) src/d8/d8.cc:5591:10
#17 0x57ee1e6bc84d in v8::Shell::RunMainIsolate(v8::Isolate*, bool) src/d8/d8.cc:6599:37
#18 0x57ee1e6bbc85 in v8::Shell::RunMain(v8::Isolate*, bool) src/d8/d8.cc:6507:18
#19 0x57ee1e6bf327 in v8::Shell::Main(int, char**) src/d8/d8.cc:7404:18
#20 0x736220e2a1c9 in __libc_start_call_main csu/../sysdeps/nptl/libc_start_call_main.h:58:16
#21 0x736220e2a28a in __libc_start_main csu/../csu/libc-start.c:360:3
#22 0x57ee1e56d029 in _start (/home/qy/new2/v8/out/x64.asan/d8+0x1319029) (BuildId: e276665bb2c58cdf)
Address 0x6e12c0005028 is a wild pointer inside of access range of size 0x000000000028.
SUMMARY: AddressSanitizer: use-after-poison (/home/qy/new2/v8/out/x64.asan/d8+0x13b8ffa) (BuildId: e276665bb2c58cdf) in __asan_memcpy
Shadow bytes around the buggy address:
0x6e12c0004d80: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0004e00: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0004e80: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0004f00: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0004f80: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
=>0x6e12c0005000: 00 00 00 00 00[f7]f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0005080: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0005100: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0005180: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0005200: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
0x6e12c0005280: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
NOTE: the stack trace above identifies the code that *accessed* the poisoned memory.
To identify the code that *poisoned* the memory, try the experimental setting ASAN_OPTIONS=poison_history_size=<size>.
==125374==ABORTING
Received signal 6
==== C stack trace ===============================
out/x64.asan/d8(__interceptor_backtrace+0x46)[0x57ee1e5b4b36]
out/x64.asan/d8(+0x63976c0)[0x57ee235eb6c0]
/lib/x86_64-linux-gnu/libc.so.6(+0x45330)[0x736220e45330]
/lib/x86_64-linux-gnu/libc.so.6(pthread_kill+0x11c)[0x736220e9eb2c]
/lib/x86_64-linux-gnu/libc.so.6(gsignal+0x1e)[0x736220e4527e]
/lib/x86_64-linux-gnu/libc.so.6(abort+0xdf)[0x736220e288ff]
out/x64.asan/d8(+0x13dbc5c)[0x57ee1e62fc5c]
out/x64.asan/d8(+0x13da44e)[0x57ee1e62e44e]
out/x64.asan/d8(+0x13c188b)[0x57ee1e61588b]
out/x64.asan/d8(+0x13c366d)[0x57ee1e61766d]
out/x64.asan/d8(__asan_memcpy+0x3ab)[0x57ee1e60d02b]
out/x64.asan/d8(+0x18acb20)[0x57ee1eb00b20]
out/x64.asan/d8(+0x61c1236)[0x57ee23415236]
[end of stack trace]
Aborted