Medium watch CVSS 8.8 chrome OOB 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in V8
DescriptionOut of bounds read in V8
ComponentV8
Bug ClassOOB
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker478659010
Fix commit134c3696cd53 (v8/v8) +1/-1
CWECWE-125 (Out-of-bounds read)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedqymag1c
Reported2026-01-26 · bounty $7000 (per vendor advisory)
Fix landed2026-01-28 (public git)
Disclosed2026-03-10
Patch gap41 days — fix public in source before the advisory

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 ConsString or a ThinString) 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.

Key insight
The single mistake is querying the surface string’s representation flag instead of the representation of the string underneath the indirect layers; switching to IsOneByteRepresentationUnderneath makes the width decision reflect the actual backing store so the character copy stays in bounds.

Attack Path

  1. 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.
  2. Call `RegExp.escape` on it Invoking RegExp.escape(str) reaches the C++ implementation, which calls String::Flatten and leaves the handle referring to the indirect wrapper.
  3. 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.
  4. 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

An attacker running crafted JavaScript in the renderer can cause 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 checks
    Grep for IsOneByteRepresentation() used to select a byte-width copy or access path, and verify IsOneByteRepresentationUnderneath is used wherever the string may be an indirect (ConsString/SlicedString/ThinString) wrapper.
  • Post-`Flatten` assumptions
    Audit code that assumes String::Flatten yields 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/SeqString buffers where a width mismatch between the assumed and actual representation would over-read or under-read the backing store.
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;
Loading diff…

Original Bug Report

reported by [email protected]

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
View on issue tracker