Low watch CVSS 7.5 chrome Race 🔧 Commit mapped

Overview

Low
Severity
7.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactRace in V8
DescriptionRace in V8
ComponentV8
Sub-componentTesting
Bug ClassRace
Action tierwatch — corruption primitive but gated (flag) — not default-reachable
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.
Tracker519664497
Fix commitd1273475a38b (v8/v8) +55/-59
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
CreditedGoogle
Reported2026-06-04 (per vendor advisory)
Fix landed2026-07-20 (public git)
Disclosed2026-07-29
Patch gap9 days — fix public in source before the advisory

Background

`SynchronizationPointSupport`
A V8 testing utility that lets JavaScript pause a background thread at a named “synchronization point” and resume it, so races can be reproduced deterministically in regression tests.
`SyncPointState`
The per-point record holding block_requested, blocked_threads, and a condition variable cv, stored inside a states_ map guarded by mutex_.
Sync-point runtime APIs
The %Resume() and %WaitUntilBlocked() intrinsics (backed by Runtime_Resume and Runtime_WaitUntilBlocked) that a test script uses to orchestrate the timing of two threads.
Race regression test
regress-519664497.js, a test that reproduces a ThinString mutation race by stopping a background optimization thread at NewConsString while the main thread internalizes a string.

Root Cause Analysis

The vulnerability being regression-tested is a race in V8’s string handling: a background compilation/optimization thread creates a ConsString (at the NewConsString sync point) while the main thread concurrently internalizes the same string and turns it into a ThinString, mutating an object another thread is reading. The regression test regress-519664497.js uses %WaitUntilBlocked() and %Resume() to force this precise interleaving, but the underlying sync-point APIs WaitUntilBlocked and Resume threw JavaScript exceptions whenever the observed event ordering differed from what the test expected — for example on slow debug builds where a thread had not yet reached the point, or where the block was never armed. Because throwing aborted the test with a failure, the regression test was flaky on the CQ/Waterfall infrastructure, which masked whether the actual security fix for the string race was holding.

The patch changes both APIs to print a diagnostic to stderr and return false instead of throwing, and refactors WaitUntilBlocked to capture the stable SyncPointState* pointer once under the lock (map entries are never removed) before spinning in wait_loop, so the polling loop re-checks block_requested on each iteration rather than relying on a one-shot timed_out out-parameter.

The fix works because it makes the harness tolerant of nondeterministic timing while the test itself asserts the return values, keeping the regression signal meaningful without introducing false failures.

Key insight
The core mistake was that the deterministic-timing test harness treated any deviation from the expected event sequence as a thrown, test-aborting error, which made a genuinely racy scenario impossible to test reliably; the fix demotes those conditions to a false return plus an stderr note so the test can assertTrue on them explicitly instead of crashing on timing jitter.

Attack Path

  1. Arm a background thread A crafted script triggers concurrent optimization so a background thread reaches NewConsString and constructs a ConsString that is shared with the main thread.
  2. Win the timing window Before the background thread finishes, the main thread calls %InternalizeString on the same string, converting it to a ThinString and mutating the object the background thread still references.
  3. Trigger the race With no reliable synchronization, the background thread reads the string mid-mutation, observing an inconsistent object layout that leads to memory corruption in the optimizing compiler.
  4. Exploit the corruption The resulting type confusion or out-of-bounds access on the mutated string object is leveraged into a controlled read/write primitive inside the renderer.

Impact Assessment

An attacker who lands the race gains memory corruption inside the V8 engine running in the renderer process, exploitable for information disclosure or, with a suitable primitive chain, arbitrary code execution in the sandboxed renderer. The precondition is executing attacker-controlled JavaScript that drives concurrent optimization and string internalization to hit the narrow NewConsString/ThinString window. This patch itself is test-infrastructure hardening for the fix, so its direct impact is restoring a reliable regression signal rather than closing the memory bug.

Changed Functions

FunctionChangeNotes
if
src/common/synchronization-point-support.cc
modified
while
src/common/synchronization-point-support.cc
modified
if
src/runtime/runtime-test.cc
modified

Files Changed

  • src/common/synchronization-point-support.cc
  • src/common/synchronization-point-support.h
  • src/runtime/runtime-test.cc
  • test/mjsunit/compiler/regress-519664497.js
  • test/mjsunit/regress/regress-511263221.js

Audit Directions

  • Test-harness exceptions masking races
    Look for other testing intrinsics or sync utilities that throw on timing deviations, since thrown errors can hide flaky-but-real concurrency regressions from CI.
  • Dangling pointers into locked maps
    Audit code that caches a raw pointer to a map value for use outside the lock; here it is safe only because states_ entries are never removed, an invariant worth verifying elsewhere.
  • Concurrent string mutation
    Review paths where the main thread internalizes or flattens a string (creating ThinString) while background compilation reads the same object, the class of race this test is guarding.
From d1273475a38b49b9d8281435e8567475b18f448b Mon Sep 17 00:00:00 2001
From: Maksim Ivanov <emaxx@chromium.org>
Date: Thu, 16 Jul 2026 14:51:14 +0000
Subject: [PATCH] [testing] Do not throw errors from sync point APIs

Just print an stderr message and return false instead of throwing an
error from %WaitUntilBlocked() or %Resume(). This allows regression
tests with highly specific scenarios to not break CQ/Waterfall when the
actual sequence of events differs from the expected one or the execution
is slower than expected.

This also fixes the flakiness of regress-519664497 on debug builds.

Bug: 519664497, 514998642, 532293195
Change-Id: I0fcf7f3015cb22b3236295379b041b502af5e2a4
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8091127
Reviewed-by: Marja Hölttä <marja@chromium.org>
Reviewed-by: Arash Kazemi <arashk@chromium.org>
Commit-Queue: Maksim Ivanov <emaxx@google.com>
Cr-Commit-Position: refs/heads/main@{#108758}
---

diff --git a/src/common/synchronization-point-support.cc b/src/common/synchronization-point-support.cc
index 34dcaae..89bcc47 100644
--- a/src/common/synchronization-point-support.cc
+++ b/src/common/synchronization-point-support.cc
@@ -45,32 +45,45 @@
     std::string_view synchronization_point) {
   base::MutexGuard lock(&mutex_);
   auto it = states_.find(synchronization_point);
-  if (it == states_.end()) return false;
-  SyncPointState* state = it->second.get();
-  if (!state->block_requested) return false;
-  state->block_requested = false;
-  state->cv.NotifyAll();
-  return true;
+  if (it != states_.end()) {
+    SyncPointState* state = it->second.get();
+    if (state->block_requested) {
+      state->block_requested = false;
+      state->cv.NotifyAll();
+      return true;
+    }
+  }
+  base::OS::PrintError("Synchronization point not armed\n");
+  return false;
 }
 
 bool SynchronizationPointSupport::WaitUntilBlocked(
-    std::string_view synchronization_point, base::TimeDelta timeout,
-    bool& timed_out) {
-  bool success = false;
-  auto wait_loop = [this, synchronization_point, timeout, &timed_out,
-                    &success]() {
+    std::string_view synchronization_point, base::TimeDelta timeout) {
+  // Safe: map elements are never removed, so the pointer is stable.
+  SyncPointState* state;
+  {
     base::MutexGuard lock(&mutex_);
     auto it = states_.find(synchronization_point);
-    if (it == states_.end()) return;
-    SyncPointState* state = it->second.get();
-    if (!state->block_requested && state->blocked_threads == 0) return;
+    if (it == states_.end()) {
+      base::OS::PrintError("Synchronization point not armed\n");
+      return false;
+    }
+    state = it->second.get();
+  }
 
+  bool success = false;
+  auto wait_loop = [this, state, timeout, &success]() {
+    base::MutexGuard lock(&mutex_);
     const base::TimeTicks start = base::TimeTicks::Now();
     while (state->blocked_threads == 0) {
+      if (!state->block_requested) {
+        base::OS::PrintError("Synchronization point not armed\n");
+        return;
+      }
       const base::TimeDelta remaining =
           start + timeout - base::TimeTicks::Now();
       if (remaining <= base::TimeDelta()) {
-        timed_out = true;
+        base::OS::PrintError("Synchronization point wait timed out\n");
         return;
       }
       std::ignore = state->cv.WaitFor(&mutex_, remaining);
diff --git a/src/common/synchronization-point-support.h b/src/common/synchronization-point-support.h
index 4ebd39d..c9830ef 100644
--- a/src/common/synchronization-point-support.h
+++ b/src/common/synchronization-point-support.h
@@ -44,11 +44,10 @@
   bool Resume(std::string_view synchronization_point);
 
   // Waits until the given synchronization point is reached by some thread.
-  // Returns false if a block wasn't requested or on timeout (in which case
-  // `timed_out` is set to true as well). Note: this does not request a block;
-  // it must be requested first.
+  // Returns false if a block wasn't requested or on timeout. Note: this does
+  // not request a block; it must be requested first.
   bool WaitUntilBlocked(std::string_view synchronization_point,
-                        base::TimeDelta timeout, bool& timed_out);
+                        base::TimeDelta timeout);
 
   // Called when the synchronization point is reached; blocks if requested.
   V8_INLINE void BlockIfRequested(std::string_view synchronization_point) {
diff --git a/src/runtime/runtime-test.cc b/src/runtime/runtime-test.cc
index 72a208a..b6884c2 100644
--- a/src/runtime/runtime-test.cc
+++ b/src/runtime/runtime-test.cc
@@ -2903,13 +2903,9 @@
   DCHECK_EQ(1, args.length());
   CHECK_UNLESS_FUZZING(IsString(args[0]));
   DirectHandle<String> phase_name = args.at<String>(0);
-  bool resumed =
+  bool success =
       SynchronizationPointSupport::Get()->Resume(phase_name->ToStdString());
-  if (!resumed) {
-    return isolate->Throw(*isolate->factory()->NewStringFromAsciiChecked(
-        "No thread is currently blocked at this synchronization point"));
-  }
-  return ReadOnlyRoots(isolate).undefined_value();
+  return isolate->heap()->ToBoolean(success);
 }
 
 // Waits until the given synchronization point is reached. Throws an exception
@@ -2926,15 +2922,9 @@
   base::TimeDelta timeout =
       base::TimeDelta::FromMilliseconds(args.smi_value_at(1));
 
-  bool timed_out = false;
-  bool blocked = SynchronizationPointSupport::Get()->WaitUntilBlocked(
-      phase_name->ToStdString(), timeout, timed_out);
-  if (!blocked) {
-    return isolate->Throw(*isolate->factory()->NewStringFromAsciiChecked(
-        timed_out ? "Synchronization point wait timed out"
-                  : "Synchronization point not found or not armed"));
-  }
-  return ReadOnlyRoots(isolate).undefined_value();
+  bool success = SynchronizationPointSupport::Get()->WaitUntilBlocked(
+      phase_name->ToStdString(), timeout);
+  return isolate->heap()->ToBoolean(success);
 }
 
 }  // namespace internal
diff --git a/test/mjsunit/compiler/regress-519664497.js b/test/mjsunit/compiler/regress-519664497.js
index 566a322..57a7321 100644
--- a/test/mjsunit/compiler/regress-519664497.js
+++ b/test/mjsunit/compiler/regress-519664497.js
@@ -26,13 +26,13 @@
 %OptimizeFunctionOnNextCall(f, "concurrent");
 assertEquals(expected, f());
 
-%WaitUntilBlocked('NewConsString', 1000);
+assertTrue(%WaitUntilBlocked('NewConsString', 1000));
 
 // Internalize the string. This will mutate the string on the main thread
 // and turn it into a ThinString, right while the background thread is
 // stopped at NewConsString.
 %InternalizeString(left);
 
-%Resume('NewConsString');
+assertTrue(%Resume('NewConsString'));
 %WaitForBackgroundOptimization();
 assertEquals(expected, f());
diff --git a/test/mjsunit/regress/regress-511263221.js b/test/mjsunit/regress/regress-511263221.js
index 18d63d6..85fad54 100644
--- a/test/mjsunit/regress/regress-511263221.js
+++ b/test/mjsunit/regress/regress-511263221.js
@@ -8,34 +8,28 @@
   onmessage = function(msg) {
     let sab = new Int32Array(msg.data);
 
-    try {
-      %BlockAt("ExternalizeStringExtensionMakeExternalOneByte", 10000);
-      %BlockAt("ConcurrentConcatenateStrings", 10000);
-      postMessage("ready");
+    %BlockAt("ExternalizeStringExtensionMakeExternalOneByte", 10000);
+    %BlockAt("ConcurrentConcatenateStrings", 10000);
+    postMessage("ready");
 
-      %WaitUntilBlocked("ConcurrentConcatenateStrings", 10000);
-      %WaitUntilBlocked("ExternalizeStringExtensionMakeExternalOneByte", 10000);
+    %WaitUntilBlocked("ConcurrentConcatenateStrings", 10000);
+    %WaitUntilBlocked("ExternalizeStringExtensionMakeExternalOneByte", 10000);
 
-      %BlockAt("StringWriteToFlatConsString", 10000);
-      %Resume("ConcurrentConcatenateStrings");
+    %BlockAt("StringWriteToFlatConsString", 10000);
+    %Resume("ConcurrentConcatenateStrings");
 
-      %WaitUntilBlocked("StringWriteToFlatConsString", 10000);
+    %WaitUntilBlocked("StringWriteToFlatConsString", 10000);
 
-      %Resume("ExternalizeStringExtensionMakeExternalOneByte");
+    %Resume("ExternalizeStringExtensionMakeExternalOneByte");
 
-      Atomics.wait(sab, 0, 0);
+    Atomics.wait(sab, 0, 0);
 
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/mjsunit/compiler/regress-519664497.js b/test/mjsunit/compiler/regress-519664497.js
index 566a322..57a7321 100644
--- a/test/mjsunit/compiler/regress-519664497.js
+++ b/test/mjsunit/compiler/regress-519664497.js
@@ -26,13 +26,13 @@
 %OptimizeFunctionOnNextCall(f, "concurrent");
 assertEquals(expected, f());
 
-%WaitUntilBlocked('NewConsString', 1000);
+assertTrue(%WaitUntilBlocked('NewConsString', 1000));
 
 // Internalize the string. This will mutate the string on the main thread
 // and turn it into a ThinString, right while the background thread is
 // stopped at NewConsString.
 %InternalizeString(left);
 
-%Resume('NewConsString');
+assertTrue(%Resume('NewConsString'));
 %WaitForBackgroundOptimization();
 assertEquals(expected, f());
diff --git a/test/mjsunit/regress/regress-511263221.js b/test/mjsunit/regress/regress-511263221.js
index 18d63d6..85fad54 100644
--- a/test/mjsunit/regress/regress-511263221.js
+++ b/test/mjsunit/regress/regress-511263221.js
@@ -8,34 +8,28 @@
   onmessage = function(msg) {
     let sab = new Int32Array(msg.data);
 
-    try {
-      %BlockAt("ExternalizeStringExtensionMakeExternalOneByte", 10000);
-      %BlockAt("ConcurrentConcatenateStrings", 10000);
-      postMessage("ready");
+    %BlockAt("ExternalizeStringExtensionMakeExternalOneByte", 10000);
+    %BlockAt("ConcurrentConcatenateStrings", 10000);
+    postMessage("ready");
 
-      %WaitUntilBlocked("ConcurrentConcatenateStrings", 10000);
-      %WaitUntilBlocked("ExternalizeStringExtensionMakeExternalOneByte", 10000);
+    %WaitUntilBlocked("ConcurrentConcatenateStrings", 10000);
+    %WaitUntilBlocked("ExternalizeStringExtensionMakeExternalOneByte", 10000);
 
-      %BlockAt("StringWriteToFlatConsString", 10000);
-      %Resume("ConcurrentConcatenateStrings");
+    %BlockAt("StringWriteToFlatConsString", 10000);
+    %Resume("ConcurrentConcatenateStrings");
 
-      %WaitUntilBlocked("StringWriteToFlatConsString", 10000);
+    %WaitUntilBlocked("StringWriteToFlatConsString", 10000);
 
-      %Resume("ExternalizeStringExtensionMakeExternalOneByte");
+    %Resume("ExternalizeStringExtensionMakeExternalOneByte");
 
-      Atomics.wait(sab, 0, 0);
+    Atomics.wait(sab, 0, 0);
 
-      %Resume("StringWriteToFlatConsString");
+    %Resume("StringWriteToFlatConsString");
 
-    } catch(e) {
-    } finally {
-      try {
-        %Resume("ExternalizeStringExtensionMakeExternalOneByte");
-      } catch(e) {}
-      try { %Resume("ConcurrentConcatenateStrings"); } catch(e) {}
-      try { %Resume("StringWriteToFlatConsString"); } catch(e) {}
-      close();
-    }
+    %Resume("ExternalizeStringExtensionMakeExternalOneByte");
+    %Resume("ConcurrentConcatenateStrings");
+    %Resume("StringWriteToFlatConsString");
+    close();
   }
 }
diff --git a/test/mjsunit/sandbox/regress/regress-502997649.js b/test/mjsunit/sandbox/regress/regress-502997649.js
index cbf6978..9b4529f 100644
--- a/test/mjsunit/sandbox/regress/regress-502997649.js
+++ b/test/mjsunit/sandbox/regress/regress-502997649.js
@@ -30,7 +30,7 @@
 %OptimizeFunctionOnNextCall(g, 'concurrent');
 g();
 
-%WaitUntilBlocked('TurbofanEarlyGraphTrimming', 10000);
+assertTrue(%WaitUntilBlocked('TurbofanEarlyGraphTrimming', 10000));
 
 const aMod = Sandbox.getAddressOf(mod);
 const aInst = Sandbox.getAddressOf(inst);
@@ -55,6 +55,6 @@
 
 gc(); gc(); gc();
 
-%Resume('TurbofanEarlyGraphTrimming');
+assertTrue(%Resume('TurbofanEarlyGraphTrimming'));
 
 %WaitForBackgroundOptimization();
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.