CVE-2026-87601
Overview
Background
- `DirectStringData`
- CSA helper that computes the raw pointer to a string’s character backing store; the offset/interpretation it uses depends on the string’s representation (sequential, cons, sliced, thin), so it must be given a correct instance type.
- `SEQ_TWO_BYTE_STRING_TYPE`
- the instance type of a freshly-allocated sequential 2-byte (UTF-16) string, whose characters live inline in the object;
kSeqTwoByteStringTagcombines the not-a-string, representation, and encoding bits that identify it. - `String.prototype.toWellFormed`
- JS builtin that returns a copy of a string with lone/unpaired UTF-16 surrogates replaced by U+FFFD, implemented in CSA as
builtins-string-gen.cc. - `LoadInstanceType`
- CSA op that re-reads the instance type out of an object’s map at runtime, observing whatever the heap currently says rather than a statically known constant.
Root Cause Analysis
Verify before building a PoC
What the commit does not establish: Nothing in the diff or advisory shows GC or string migration changes the type: the commit says the fresh SeqTwoByte type always holds, so the race needs an outside concurrent heap write. 'Exploitable controlled OOB writes' overstates a low-severity bug.
The toWellFormed builtin allocated a fresh sequential 2-byte destination string, then obtained the pointer to write the replaced characters via DirectStringData(dest, dest_instance_type). Instead of trusting the compile-time-known SEQ_TWO_BYTE_STRING_TYPE invariant, it called LoadInstanceType(dest) to re-read the destination’s instance type at write time. Because that read happens after allocation but before the string is safely published, a concurrent actor — another thread observing/mutating the object, or a GC/string-migration event — could change what the re-loaded instance type described. If the loaded type then denoted an indirect representation (cons, sliced, or thin), DirectStringData computed a pointer under the wrong layout, and the raw character write landed outside the sequential inline buffer.
The result is out-of-bounds writes into adjacent heap memory, i.e. memory corruption driven by a race window.
SEQ_TWO_BYTE_STRING_TYPE; re-loading the instance type at write time discards that guarantee and lets a racing thread or GC substitute a different (indirect) type, turning a safe inline write into an out-of-bounds one.Attack Path
- Trigger the copy
Call
String.prototype.toWellFormedon a two-byte string so the builtin allocates a fresh sequential 2-bytedestand reaches the direct-write path. - Race the type read
From a concurrent thread (or by inducing a GC/string-migration) mutate the observed representation of
destbetween its allocation and theLoadInstanceType(dest)at write time. - Mislead `DirectStringData`
Win the race so the re-loaded instance type describes an indirect representation, causing
DirectStringDatato return a pointer computed under the wrong string layout. - Corrupt memory Let the builtin write the well-formed characters through that mis-computed pointer, landing the raw bytes outside the intended sequential buffer.
- Exploit Shape adjacent heap objects so the out-of-bounds write overwrites controlled fields, building a primitive toward further corruption.
Impact Assessment
Files Changed
src/builtins/builtins-string-gen.cc
Audit Directions
- Re-loaded type on freshly-allocated objectsAudit CSA/Torque builtins that allocate an object and then re-read its instance type/map via
LoadInstanceTypebefore writing; prefer the known allocation-time constant instead of a runtime re-load. - `DirectStringData` callersReview every use of
DirectStringData(and similar layout-dependent pointer helpers) to confirm the instance type argument is a trusted invariant, not a value observable/mutable by another thread or GC. - Write-into-fresh-buffer windowsFlag any pattern where raw writes into a newly allocated backing store depend on state read after allocation but before publication, since GC, string migration (e.g. thin-string in-place changes), or concurrent threads can invalidate that state.
Patch
From c46cc7a032bd26cf6e7c2d8db8e6f849b83d9b57 Mon Sep 17 00:00:00 2001
From: pthier <pthier@chromium.org>
Date: Wed, 12 Aug 2026 10:11:16 +0200
Subject: [PATCH] Fix String.toWellFormed
Check that the destination is a sequential 2-byte string. Since the
destination is freshly allocated, this will always hold.
This prevents racy memory corruption between allocation and writing the
data.
Fixed: 541604100
Change-Id: I4201575476a40e690aee86d1aff0ca7a7f3ef9cf
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8234944
Reviewed-by: Olivier Flückiger <olivf@chromium.org>
Commit-Queue: Patrick Thier <pthier@chromium.org>
Auto-Submit: Patrick Thier <pthier@chromium.org>
Cr-Commit-Position: refs/heads/main@{#109196}
---
diff --git a/src/builtins/builtins-string-gen.cc b/src/builtins/builtins-string-gen.cc
index d763ebb..06c2de1 100644
--- a/src/builtins/builtins-string-gen.cc
+++ b/src/builtins/builtins-string-gen.cc
@@ -1859,13 +1859,16 @@
if_indirect);
TNode<RawPtrT> source_data = DirectStringData(source, source_instance_type);
- // The destination string is a freshly allocated SeqString, and so is always
- // direct.
- TNode<Uint16T> dest_instance_type = LoadInstanceType(dest);
- CSA_DCHECK(this, Word32Equal(Word32And(dest_instance_type,
- Int32Constant(kStringEncodingMask)),
- Int32Constant(kTwoByteStringTag)));
- TNode<RawPtrT> dest_data = DirectStringData(dest, dest_instance_type);
+ // The destination string is a freshly allocated TwoByteSeqString, and so is
+ // always direct.
+ CSA_DCHECK(this,
+ Word32Equal(
+ Word32And(LoadInstanceType(dest),
+ Int32Constant(kIsNotStringMask |
+ kStringRepresentationAndEncodingMask)),
+ Int32Constant(kSeqTwoByteStringTag)));
+ TNode<RawPtrT> dest_data =
+ DirectStringData(dest, Int32Constant(SEQ_TWO_BYTE_STRING_TYPE));
TNode<IntPtrT> length = LoadStringLengthAsWord(source);
CSA_DCHECK(this, IntPtrEqual(length, LoadStringLengthAsWord(dest)));
Original Bug Report
[V8 Sandbox Bypass] String.prototype.toWellFormed race yields a controlled native out-of-cage write
VULNERABILITY DETAILS
This is a controlled out-of-cage native write. The attacker controls the copy length and all UTF-16 words after the mandatory leading replacement character. The destination is selectable through an existing correctly tagged External Pointer Table (EPT) entry.
String.prototype.toWellFormed allocates a fresh SeqTwoByteString, but
ReplaceUnpairedSurrogates subsequently reloads the destination’s map from
attacker-writable sandbox memory. Under the V8 sandbox threat model, a
concurrent attacker can replace the map and external-pointer handles before
that reload. V8 then resolves an external string data pointer and writes
source.length UTF-16 words through it without validating the external
resource’s capacity.
The attached standalone test case uses the official memory corruption API and d8 Workers to trigger an 8,192-byte native write through the data pointer of a 21-byte external string backed by a 50-byte native buffer. The write reaches an inaccessible page outside the reported 1-TiB V8 sandbox.
Root cause
At the tested revision:
src/init/bootstrapper.cc:2836-2837installstoWellFormedwithout a feature gate.src/builtins/string-towellformed.tq:48-53allocates a freshSeqTwoByteStringand passes it toReplaceUnpairedSurrogates.src/builtins/builtins-string-gen.cc:1848-1877reloads the destination map after allocation. Its encoding and length checks are release-disabledCSA_DCHECKs. There is no release check that the destination remains sequential and no check of an external resource’s capacity.src/builtins/builtins-string-gen.cc:25-53sends every non-sequential map through the external-string path inDirectStringData.src/codegen/code-stub-assembler.h:1118-1128resolves the cached external string data handle withkExternalStringResourceDataTag.src/strings/unicode.cc:280-285callssimdutf::to_well_formed_utf16withsource.length, causing an unconditional native copy to the resolved pointer.
The missing post-allocation representation check therefore redirects a length-controlled and mostly data-controlled native write outside the sandbox.
Regression range
A source-level git bisect covered 30,527 V8 revisions between known-good
34f73cc75969dd57165262b8b504475cedd4df9c and current bad
a48b78348d46ca248ad7080eb29bf4545378198b. The predicate requires the exact
fresh allocation/call and unchecked destination-map reload used by the PoC.
First bad commit:
5c7fca4a61ef96a8834adb98aabe29825c6bb3a9
[string-iswellformed] Implement String#{is,to}WellFormed
Author: Shu-yu Guo <syg@chromium.org>
Author date: 2022-12-22T09:19:20-08:00
Change-Id: I6fa772c70d8307eca047fd839058279ce244f0e0
Cr-Commit-Position: refs/heads/main@{#85002}
Review: https://chromium-review.googlesource.com/c/v8/v8/+/4118066
Last good revision (the first bad commit’s parent):
c74a6cd257df45a816811f22af6004af51681898
VERSION
Chrome Version: N/A; reproduced in d8 from V8 main (development), V8 version
15.3.0 (candidate)
V8 revision:
a48b78348d46ca248ad7080eb29bf4545378198b
2026-08-01T09:04:18-07:00
Update V8 DEPS (trusted)
Operating System: Ubuntu 24.04.4 LTS, Linux 6.8.0-101-generic, x86_64
Build configuration:
is_debug = false
dcheck_always_on = false
v8_enable_sandbox = true
v8_enable_memory_corruption_api = true
d8 SHA-256: 45d04f8f8309595758303a5dbb44d61c8e3f8a6824afebd698b92894df3e2893
REPRODUCTION CASE
Attach poc.js directly and run it with only flags permitted by the V8 sandbox
submission rules:
d8 --sandbox-testing --expose-gc --allow-natives-syntax poc.js
Representative output from attached repro.log:
Sandbox testing mode is enabled. Only sandbox violations will be reported, all other crashes will be ignored.
Sandbox bounds: [0x217800000000,0x227800000000)
## V8 sandbox violation detected!
Received signal 11 SEGV_ACCERR 7fc709000000
Test-case mechanics
--expose-gc installs the registered GC extension
(src/init/bootstrapper.cc:164,6741-6749). V8 compiles its source as a cached
external one-byte string
(src/init/bootstrapper.cc:5178-5187). The string is
native function gc();, backed by GCExtension::buffer_[50]
(src/extensions/gc-extension.h:53-69).
The test case locates this external string and copies its valid resource_ and
resource_data_ EPT handles. Seven Workers use Sandbox.MemoryView to:
- Preinstall those valid handles in the predicted fresh result slot.
- Continuously replace the result map with the cached external-one-byte map.
- Race the destination-map reload in
ReplaceUnpairedSurrogates.
The external-one-byte map works because the encoding assertion is a
compiled-out CSA_DCHECK; DirectStringData uses the representation bits to
choose between sequential and external data.
The source contains 4,096 UTF-16 words. Its first word is an unpaired surrogate,
which forces allocation and becomes 0xfffd; the remaining words are
attacker-controlled 0x4242. The helper consequently attempts an 8,192-byte
native write through the GC extension’s external data pointer, overrunning its
50-byte backing buffer and reaching an inaccessible native page.
Allocation-buffer calibration only keeps the Worker race aimed at the exact fresh-object slot and avoids unrelated in-sandbox faults.
Attachments:
poc.js— standalone memory-corruption-API test case.repro.log— clean sandbox-violation transcript.
FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION
Type of crash: d8 process; V8 sandbox violation (SIGSEGV, SEGV_ACCERR)
Crash State:
Received signal 11 SEGV_ACCERR 7fc709000000
../../v8/out/a15.mca/d8(_ZN2v84base5debug10StackTraceC1Ev+0x1e)
../../v8/out/a15.mca/d8(+0x328fb16)
../../v8/out/a15.mca/d8(+0x1fc8025)
/lib/x86_64-linux-gnu/libc.so.6(+0x45330)
../../v8/out/a15.mca/d8(+0x18b6001)
../../v8/out/a15.mca/d8(+0x31d49bb)
The complete captured trace is attached as repro.log. Registers and an
exception record were not captured; reproduction uses d8’s required
--sandbox-testing handler without a debugger.
Client ID (if relevant): N/A
CREDIT INFORMATION
Reporter credit:
Salvatore Gulizia (nickname: Serotav)