CVE-2026-14394
Overview
Background
- Maglev
- V8’s mid-tier optimizing compiler, which builds its graph on a background thread that reads heap objects concurrently with the main thread.
- `TryGetStringConstant`
MaglevGraphBuilderhelper (src/maglev/maglev-graph-builder.cc:18242) that extracts aHandle<String>from a constant graph node for constant-folding of string operations.- Content-accessible string
- a string whose character data can be safely read from a background thread (e.g. internalized/thin), as opposed to one the main thread may mutate in place.
Root Cause Analysis
5873b818a1c0. An earlier analysis was withdrawn because it had been written against a commit that was not the security fix (67412ec04b79).MaglevGraphBuilder::TryGetStringConstant (src/maglev/maglev-graph-builder.cc:18242) is called while building the Maglev graph on a background compiler thread. Pre-fix, when the input was a HeapConstant, it accepted any string: if (constant->object().IsString()) (src/maglev/maglev-graph-builder.cc:18244) and returned a Handle<String> to the raw object (:18245-18246). The missing invariant is that only strings whose contents are safe to read concurrently may be materialized and read on a background thread; an ordinary (non-internalized) string can be changed in place by the main thread (for example by externalization) while the compiler thread is reading it, a data race the advisory records as a use-after-free. The reporter frames this as BuildStringConcat/WriteToFlat racing MakeExternal; the shown diff does not touch those functions, so that specific chain is (inferred), but it agrees with the code that the defect is the unguarded string access.
The fix tightens the guard to constant->object().IsString() && constant->object().AsString().IsContentAccessible() (added diff line), so Maglev only turns a string into a compile-time constant when its contents are safe to read off-thread, eliminating the race on everything else.
IsContentAccessible() check so only safe-to-read strings are used.Attack Path
- Set up a racy string constant JavaScript arranges for a non-internalized string to be referenced as a constant in code that tiers up to Maglev. (inferred — trigger from reporter, not the diff)
- Compile on the background thread
Maglev calls
TryGetStringConstant(maglev-graph-builder.cc:18242), which pre-fix returns the raw string handle regardless of type (:18244-18246) without reading its contents; a caller then reads those contents off-thread (per the reporter,BuildStringConcat/WriteToFlat). (inferred — the read is in callers, not in the shown diff) - Mutate concurrently on the main thread The main thread changes the same string’s representation in place (e.g. externalization), racing the background read and leaving it dereferencing stale/overwritten layout. (inferred)
- Memory corruption The racing background read observes an inconsistent string object, leading to the memory-safety error the advisory classifies as use-after-free. (inferred)
Impact Assessment
Files Changed
src/maglev/maglev-graph-builder.cc
Audit Directions
- Off-thread heap readsFlag background-thread compiler code that reads object payloads (strings, elements) without a content-accessibility or snapshot guard against concurrent main-thread mutation.
- Cross-tier parityWhen one tier (TurboFan) applies a safety predicate like
StringCanSafelyBeRead/IsContentAccessiblebefore constant-folding, verify the other tiers (Maglev) apply the equivalent guard on the same path.
Patch
diff --git a/src/maglev/maglev-graph-builder.cc b/src/maglev/maglev-graph-builder.cc
index e56cd01..bdba638 100644
--- a/src/maglev/maglev-graph-builder.cc
+++ b/src/maglev/maglev-graph-builder.cc
@@ -18241,7 +18241,8 @@
MaybeHandle<String> MaglevGraphBuilder::TryGetStringConstant(ValueNode* value) {
if (HeapConstant* constant = value->TryCast<HeapConstant>()) {
- if (constant->object().IsString()) {
+ if (constant->object().IsString() &&
+ constant->object().AsString().IsContentAccessible()) {
return handle(Cast<String>(*constant->object().object()),
local_isolate());
}
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.