CVE-2026-14394
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifBUILD.gn |
modified | |
ifsrc/extensions/externalize-string-extension.cc |
modified |
Files Changed
BUILD.gnbazel/defs.bzlsrc/common/synchronization-point-support.hsrc/compiler/common-utils.ccsrc/extensions/externalize-string-extension.ccsrc/objects/string.cc
Patch
From 67412ec04b79e362a82527d851e5d631f82e77bb Mon Sep 17 00:00:00 2001
From: Maksim Ivanov <emaxx@google.com>
Date: Fri, 03 Jul 2026 00:47:06 +0000
Subject: [PATCH] Reland "[testing] Sync points for concat, externalization, flattening"
Unlike the original CL, make the sync points in String::WriteToFlat()
compiled-out in release builds (using a new GN flag). This should avoid
performance regressions caused by the original CL.
Note: The majority of other sync points remains compiled into the
release builds, since we don't see regressions on benchmarks and since
some analysis pipelines use prebuilt V8 binaries where we won't be able
to set this custom GN flag.
Bug: 511263221, 514998642
Original change's description:
> [testing] Sync points for concat, externalization, flattening
>
> Add synchronization points for some string externalization and
> flattening operations and the compiler's string concatenation. This
> allows %BlockAt/%Resume/%WaitUntilBlocked primitives to time these
> precisely in tests.
>
> Use these to create a regression test for crbug.com/511263221.
>
> Bug: 511263221, 514998642
> Change-Id: I8f3a0850e44d75c8e297185c3cc641325a70877d
> Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7967804
> Reviewed-by: Patrick Thier <pthier@chromium.org>
> Commit-Queue: Maksim Ivanov <emaxx@google.com>
> Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
> Cr-Commit-Position: refs/heads/main@{#108140}
Change-Id: I66beb429f1bf8342a7d2de27322db6ebe8e814fd
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8024911
Reviewed-by: Arash Kazemi <arashk@chromium.org>
Reviewed-by: Darius Mercadier <dmercadier@chromium.org>
Commit-Queue: Maksim Ivanov <emaxx@google.com>
Reviewed-by: Patrick Thier <pthier@chromium.org>
Reviewed-by: Andreas Haas <ahaas@chromium.org>
Cr-Commit-Position: refs/heads/main@{#108419}
---
diff --git a/BUILD.gn b/BUILD.gn
index 81c3652..b1de22c 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -262,6 +262,10 @@
# Enables various testing features.
v8_enable_test_features = ""
+ # Enables testing-only synchronization points via the
+ # `SYNCHRONIZATION_POINT_TEST_ONLY` macro.
+ v8_enable_test_only_sync_points = ""
+
# Enable short builtins call instruction sequences by un-embedding builtins.
# Sets -DV8_SHORT_BUILTIN_CALLS
v8_enable_short_builtin_calls = ""
@@ -565,6 +569,9 @@
v8_enable_test_features =
v8_enable_verification_features || v8_dcheck_always_on
}
+if (v8_enable_test_only_sync_points == "") {
+ v8_enable_test_only_sync_points = v8_enable_test_features
+}
if (v8_enable_v8_checks == "") {
v8_enable_v8_checks = v8_enable_verification_features
}
@@ -1358,6 +1365,9 @@
# hash collisions all the time).
defines += [ "V8_HASHES_COLLIDE" ]
}
+ if (v8_enable_test_only_sync_points) {
+ defines += [ "V8_ENABLE_TEST_ONLY_SYNC_POINTS" ]
+ }
if (v8_enable_i18n_support) {
defines += [ "V8_INTL_SUPPORT" ]
}
@@ -3166,6 +3176,7 @@
"memory_corruption_api=$v8_enable_memory_corruption_api",
"lower_limits_mode=$v8_lower_limits_mode",
"cppgc_microtask_queue=$v8_cppgc_microtask_queue",
+ "test_only_sync_points=$v8_enable_test_only_sync_points",
# Please add new switches also in `build_config_content` in `bazel/defs.bzl`
# so that the switches also work for tests triggered by bazel.
diff --git a/bazel/defs.bzl b/bazel/defs.bzl
index 70f12b1..1b1234b 100644
--- a/bazel/defs.bzl
+++ b/bazel/defs.bzl
@@ -617,6 +617,7 @@
("verify_heap", "false"),
("verify_predictable", "false"),
("wasm_random_fuzzers", "false"),
+ ("test_only_sync_points", "false"),
("write_barriers", "false"),
])
diff --git a/src/common/synchronization-point-support.h b/src/common/synchronization-point-support.h
index 723c7db..f5bdd8b 100644
--- a/src/common/synchronization-point-support.h
+++ b/src/common/synchronization-point-support.h
@@ -86,6 +86,16 @@
v8::internal::SynchronizationPointSupport::Get()->BlockIfRequested( \
sync_point_name)
+// Similar to `SYNCHRONIZATION_POINT`, but is intended for being used on hot
+// paths. Unlike the former, it compiles to nop unless the
+// `v8_enable_test_only_sync_points` GN flag is true.
+#ifdef V8_ENABLE_TEST_ONLY_SYNC_POINTS
+#define SYNCHRONIZATION_POINT_TEST_ONLY(sync_point_name) \
+ SYNCHRONIZATION_POINT(sync_point_name)
+#else
+#define SYNCHRONIZATION_POINT_TEST_ONLY(sync_point_name) ((void)0)
+#endif
+
} // namespace internal
} // namespace v8
diff --git a/src/compiler/common-utils.cc b/src/compiler/common-utils.cc
index 807278b..02aefdc 100644
--- a/src/compiler/common-utils.cc
+++ b/src/compiler/common-utils.cc
@@ -4,6 +4,7 @@
#include "src/compiler/common-utils.h"
+#include "src/common/synchronization-point-support.h"
#include "src/compiler/js-heap-broker.h"
#include "src/heap/local-heap.h"
#include "src/objects/string-inl.h"
@@ -19,6 +20,7 @@
MaybeHandle<String> ConcatenateStrings(Handle<String> left,
Handle<String> right,
JSHeapBroker* broker) {
+ SYNCHRONIZATION_POINT("ConcurrentConcatenateStrings");
if (left->length() == 0) return right;
if (right->length() == 0) return left;
diff --git a/src/extensions/externalize-string-extension.cc b/src/extensions/externalize-string-extension.cc
index 682edca..4f871cc6 100644
--- a/src/extensions/externalize-string-extension.cc
+++ b/src/extensions/externalize-string-extension.cc
@@ -7,6 +7,7 @@
#include "include/v8-template.h"
#include "src/api/api-inl.h"
#include "src/base/strings.h"
+#include "src/common/synchronization-point-support.h"
#include "src/execution/isolate.h"
#include "src/handles/handles.h"
#include "src/heap/heap-layout-inl.h"
@@ -108,10 +109,12 @@
}
if (externalize_as_one_byte) {
auto* resource = new OwningExternalOneByteStringResource(*string);
+ SYNCHRONIZATION_POINT("ExternalizeStringExtensionMakeExternalOneByte");
result = Utils::ToLocal(string)->MakeExternal(info.GetIsolate(), resource);
if (!result) delete resource;
} else {
auto* resource = new OwningExternalStringResource(*string);
+ SYNCHRONIZATION_POINT("ExternalizeStringExtensionMakeExternalTwoByte");
result = Utils::ToLocal(string)->MakeExternal(info.GetIsolate(), resource);
if (!result) delete resource;
}
diff --git a/src/objects/string.cc b/src/objects/string.cc
index efd159ac..ad86de5 100644
--- a/src/objects/string.cc
+++ b/src/objects/string.cc
@@ -800,24 +800,33 @@
if (source->DispatchToSpecificType(absl::Overload{
[&](Tagged<SeqOneByteString> str) {
+ SYNCHRONIZATION_POINT_TEST_ONLY(
+ "StringWriteToFlatSeqOneByteString");
CopyChars(sink, str->GetChars(no_gc, access_guard) + start,
length);
return true;
},
[&](Tagged<SeqTwoByteString> str) {
+ SYNCHRONIZATION_POINT_TEST_ONLY(
+ "StringWriteToFlatSeqTwoByteString");
CopyChars(sink, str->GetChars(no_gc, access_guard) + start,
length);
return true;
},
[&](Tagged<ExternalOneByteString> str) {
+ SYNCHRONIZATION_POINT_TEST_ONLY(
+ "StringWriteToFlatExternalOneByteString");
CopyChars(sink, str->GetChars() + start, length);
return true;
},
[&](Tagged<ExternalTwoByteString> str) {
+ SYNCHRONIZATION_POINT_TEST_ONLY(
+ "StringWriteToFlatExternalTwoByteString");
CopyChars(sink, str->GetChars() + start, length);
return true;
},
[&](Tagged<ConsString> cons_string) {
+ SYNCHRONIZATION_POINT_TEST_ONLY("StringWriteToFlatConsString");
Regression Test / PoC
diff --git a/test/mjsunit/mjsunit.status b/test/mjsunit/mjsunit.status
index c7fc4f6..35af4c4 100644
--- a/test/mjsunit/mjsunit.status
+++ b/test/mjsunit/mjsunit.status
@@ -3488,4 +3488,10 @@
'regress/regress-495503781': [SKIP],
'regress/regress-temporal-zoneinfo': [SKIP],
}],
+
+##############################################################################
+['not test_only_sync_points', {
+ # Requires test-only sync points
+ 'regress/regress-511263221': [SKIP],
+}], # not test_only_sync_points
]
diff --git a/test/mjsunit/regress/regress-511263221.js b/test/mjsunit/regress/regress-511263221.js
new file mode 100644
index 0000000..18d63d6
--- /dev/null
+++ b/test/mjsunit/regress/regress-511263221.js
@@ -0,0 +1,66 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax --expose-externalize-string --expose-gc
+
+function workerScript() {
+ onmessage = function(msg) {
+ let sab = new Int32Array(msg.data);
+
+ try {
+ %BlockAt("ExternalizeStringExtensionMakeExternalOneByte", 10000);
+ %BlockAt("ConcurrentConcatenateStrings", 10000);
+ postMessage("ready");
+
+ %WaitUntilBlocked("ConcurrentConcatenateStrings", 10000);
+ %WaitUntilBlocked("ExternalizeStringExtensionMakeExternalOneByte", 10000);
+
+ %BlockAt("StringWriteToFlatConsString", 10000);
+ %Resume("ConcurrentConcatenateStrings");
+
+ %WaitUntilBlocked("StringWriteToFlatConsString", 10000);
+
+ %Resume("ExternalizeStringExtensionMakeExternalOneByte");
+
+ Atomics.wait(sab, 0, 0);
+
+ %Resume("StringWriteToFlatConsString");
+
+ } catch(e) {
+ } finally {
+ try {
+ %Resume("ExternalizeStringExtensionMakeExternalOneByte");
+ } catch(e) {}
+ try { %Resume("ConcurrentConcatenateStrings"); } catch(e) {}
+ try { %Resume("StringWriteToFlatConsString"); } catch(e) {}
+ close();
+ }
+ }
+}
+
+var G = "A".repeat(12) + "B".repeat(12);
+gc(); gc(); gc();
+try { Number(G); } catch(e) {}
+
+function f(b) {
+ if (b) return G + "x";
+}
+%PrepareFunctionForOptimization(f);
+f(true);
+f(true);
+
+let sab = new SharedArrayBuffer(4);
+let sabView = new Int32Array(sab);
+
+let worker = new Worker(workerScript, {type: 'function'});
+worker.onmessage = function(msg) {
+ if (msg.data === "ready") {
+ %OptimizeMaglevOnNextCall(f, "concurrent");
+ f(false);
+ try { externalizeString(G); } catch(e) {}
+ Atomics.store(sabView, 0, 1);
+ Atomics.notify(sabView, 0, 1);
+ }
+};
+worker.postMessage(sab);
Original Bug Report
Race condition in Maglev's BuildStringConcat leads to out-of-bounds write
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: Maglev’s BuildStringConcat fails to verify if a string constant is safe to read on a background thread. This creates a data race with in-place string externalization on the main thread, potentially leading to an out-of-bounds write within the V8 sandbox.
Affected files:
v8/src/maglev/maglev-graph-builder.ccv8/src/objects/string.cc
Estimated timestamp from git blame: 2026-01-13
Summary
A race condition exists in V8’s Maglev compiler during the constant-folding of string concatenations. The MaglevGraphBuilder::BuildStringConcat function retrieves string constants from the graph and attempts to concatenate them on a background compiler thread. However, unlike TurboFan, it fails to verify if the string’s content is safely accessible from a background thread (i.e., whether it is an internalized or thin string).
Concurrently, the main thread can externalize these same non-internalized strings (e.g., via Blink’s ToBlinkString calling v8::String::MakeExternal). Because String::MakeExternal does not acquire the internalized_string_access mutex for non-internalized strings, it performs an in-place layout change without synchronization against the Maglev background thread reading the string.
Technical Details
In v8/src/maglev/maglev-graph-builder.cc, BuildStringConcat calls TryGetStringConstant, which returns a Handle<String> from a Constant node. Maglev passes the raw handle directly to compiler::utils::ConcatenateStrings, which eventually invokes String::WriteToFlat on the background thread.
While the background thread is reading the string, the main thread may execute String::MakeExternal on the same object. For non-internalized strings, MakeExternal avoids taking the internalized_string_access lock and proceeds to:
- Update the object’s Map to an external string Map.
- Initialize external pointer fields (overwriting existing internal fields).
If the string is a ConsString, the new 32-bit ExternalPointerHandle allocated for the resource_ field overwrites the first_ child pointer, as both are located at offset 12.
When the background thread executing WriteToFlat reads first = cons_string->first(), it reads this ExternalPointerHandle. Because the handle is shifted by 8 (kExternalPointerIndexShift), its lowest bit is 0, so V8 interprets it as a compressed Smi. Pointer decompression adds the V8 cage base to this value, resolving it to a pointer within the V8 sandbox.
If an attacker inflates the ExternalPointerTable (by allocating many external strings), they can control the offset of this handle and point it to an attacker-controlled object (e.g., an ArrayBuffer) within the sandbox, creating a fake String.
When WriteToFlat accesses first->length(), it reads the length from the attacker-controlled fake string. By providing a massive length, the attacker causes WriteToFlat to miscalculate second_length as a negative number. This triggers a logic path where WriteToFlat replaces its internal length bound with the massive length from the fake string, and loops to copy characters from the fake string.
This results in a massive CopyChars operation into a small sink buffer, causing an out-of-bounds write that can corrupt memory within the V8 sandbox.
Potential Reproduction Steps
Note: These are suggested steps based on static analysis, as this agent cannot execute code.
- Create a non-internalized
ConsStringin old-space, large enough for in-place externalization (>= 24 bytes). - Store this string in a constant-tracked data field of a globally accessible object.
- Define and warm up a JavaScript function that performs string concatenation using this constant field, triggering Maglev tier-up.
- Concurrently, repeatedly allocate ExternalStrings to inflate the
ExternalPointerTableso that the next handle points into an attacker-controlled ArrayBuffer containing a fake String payload (with a huge length). - Pass the non-internalized
ConsStringto a DOM sink (e.g.,node.textContent = S) on the main thread to triggerv8::String::MakeExternal. - Maglev’s background thread races with
MakeExternal, reads the overwrittenfirst_pointer, resolves it to the fake String, and performs an out-of-bounds write duringWriteToFlat.
Suggested Fix
In MaglevGraphBuilder::TryGetStringConstant, restrict the returned string constants to those that are safe to read on a background thread. Similar to TurboFan’s StringCanSafelyBeRead, only return handles for strings where IsContentAccessible() or SupportedStringKind() is true (i.e., internalized or thin strings).
MaybeHandle<String> MaglevGraphBuilder::TryGetStringConstant(ValueNode* value) {
if (Constant* constant = value->TryCast<Constant>()) {
if (constant->object().IsString() && constant->object().IsContentAccessible()) {
return handle(Cast<String>(*constant->object().object()), local_isolate());
}
} ...
}
Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.