Low watch ~ plausible CVSS 7.5 chrome Race 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace condition in V8
DescriptionRace condition in V8
ComponentV8
Bug ClassRace
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable — revised by independent review by AI
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.
Independent review by AIPlausible (tier revised from patch-now)
Reviewer notes: 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.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker541604100
Fix commitc46cc7a032bd (v8/v8) +10/-7
CWECWE-362 (Race condition)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedSalvatore Gulizia (nickname: Serotav)
Reported2026-08-01 (per vendor advisory)
Fix landed2026-08-12 (public git)
Disclosed2026-09-08
Patch gap27 days — fix public in source before the advisory

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; kSeqTwoByteStringTag combines 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

This analysis is consistent with the fixing commit but not fully provable from it: part of the vulnerable path lives in code the fix commit does not touch. Confirm the trigger and the sink against the source before relying on it for exploit development.
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.

Key insight
The destination is freshly allocated by the builtin itself, so its representation is invariantly 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

  1. Trigger the copy Call String.prototype.toWellFormed on a two-byte string so the builtin allocates a fresh sequential 2-byte dest and reaches the direct-write path.
  2. Race the type read From a concurrent thread (or by inducing a GC/string-migration) mutate the observed representation of dest between its allocation and the LoadInstanceType(dest) at write time.
  3. Mislead `DirectStringData` Win the race so the re-loaded instance type describes an indirect representation, causing DirectStringData to return a pointer computed under the wrong string layout.
  4. Corrupt memory Let the builtin write the well-formed characters through that mis-computed pointer, landing the raw bytes outside the intended sequential buffer.
  5. Exploit Shape adjacent heap objects so the out-of-bounds write overwrites controlled fields, building a primitive toward further corruption.

Impact Assessment

This is an exploitable race condition in V8 causing controlled out-of-bounds heap writes from within a renderer-reachable JS builtin. Because the corruption is driven by attacker-timed writes past a sequential string’s backing store, it can be developed into a strong exploitation primitive leading to renderer memory corruption and potential code execution.

Files Changed

  • src/builtins/builtins-string-gen.cc

Audit Directions

  • Re-loaded type on freshly-allocated objects
    Audit CSA/Torque builtins that allocate an object and then re-read its instance type/map via LoadInstanceType before writing; prefer the known allocation-time constant instead of a runtime re-load.
  • `DirectStringData` callers
    Review 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 windows
    Flag 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.
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)));
 
Loading diff…

Original Bug Report

reported by gu...@gmail.com

[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:

  1. src/init/bootstrapper.cc:2836-2837 installs toWellFormed without a feature gate.
  2. src/builtins/string-towellformed.tq:48-53 allocates a fresh SeqTwoByteString and passes it to ReplaceUnpairedSurrogates.
  3. src/builtins/builtins-string-gen.cc:1848-1877 reloads the destination map after allocation. Its encoding and length checks are release-disabled CSA_DCHECKs. There is no release check that the destination remains sequential and no check of an external resource’s capacity.
  4. src/builtins/builtins-string-gen.cc:25-53 sends every non-sequential map through the external-string path in DirectStringData.
  5. src/codegen/code-stub-assembler.h:1118-1128 resolves the cached external string data handle with kExternalStringResourceDataTag.
  6. src/strings/unicode.cc:280-285 calls simdutf::to_well_formed_utf16 with source.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:

  1. Preinstall those valid handles in the predicted fresh result slot.
  2. Continuously replace the result map with the cached external-one-byte map.
  3. 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)
View on issue tracker