CVE-2026-14430
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/builtins/array-join.tq |
modified | |
typeswitchsrc/builtins/array-join.tq |
modified |
Files Changed
src/builtins/array-join.tqtest/mjsunit/regress/regress-522126182.jstest/mjsunit/regress/regress-crbug-897404.js
Patch
From 7893ac23bc1e640904892810891d144e4894ccd0 Mon Sep 17 00:00:00 2001 From: pthier <[email protected]> Date: Fri, 12 Jun 2026 14:52:04 +0200 Subject: [PATCH] Check early for separator overflow in Array.prototype.join() Instead of checking if separators overflow the maximum string length each time we add a (or multiple) separator(s) to the buffer, we check once in the beginning (based on the separator- and array-length). Besides avoiding a division in the hot loop, this also fixes a potential nofSeparators overflow on 32-bit platforms. Fixed: 522126182 Change-Id: Iab05848b9ec1960a925e56b11bb74569438ebafb Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7927166 Reviewed-by: Igor Sheludko <[email protected]> Commit-Queue: Patrick Thier <[email protected]> Cr-Commit-Position: refs/heads/main@{#107953} --- diff --git a/src/builtins/array-join.tq b/src/builtins/array-join.tq index dd1f45c..c9ec15e 100644 --- a/src/builtins/array-join.tq +++ b/src/builtins/array-join.tq @@ -206,11 +206,6 @@ const nofSeparatorsInt: intptr = nofSeparators; const sepsLen: intptr = separatorLength * nofSeparatorsInt; - // Detect integer overflow - // TODO(turbofan): Replace with overflow-checked multiplication. - if (sepsLen / separatorLength != nofSeparatorsInt) deferred { - ThrowInvalidStringLength(context); - } this.totalStringLength = AddStringLength(this.totalStringLength, sepsLen); if (write) deferred { @@ -357,11 +352,33 @@ transitioning macro FastArrayJoin( implicit context: Context)(kind: constexpr ElementsKind, array: FastJSArrayForRead, sep: String, lengthNumber: Number): String { - const elements = array.elements; - + // Check early if the separators might overflow. let len: uintptr = Convert<uintptr>(lengthNumber); + // Fast array length is guaranteed to fit in Smi. + dcheck(len <= Convert<uintptr>(kMaxFastArrayLength)); + static_assert(Convert<uintptr>(kMaxFastArrayLength) < kSmiMaxValue); + // If separatorLength <= 1, fast arrays cannot overflow the separator counter + // or exceed kStringMaxLength from separators alone, because their maximum + // length is bounded by kMaxFastArrayLength (32MB) which is much smaller + // than kStringMaxLength (268MB/536MB). Thus we only need to perform checks + // if separatorLength > 1. + static_assert( + (Convert<uintptr>(kMaxFastArrayLength) - 1) <= + Convert<uintptr>(kStringMaxLength)); const separatorLength: intptr = sep.length_intptr; + if (separatorLength > 1 && len > 1) { + const lenInt: int32 = Convert<int32>(Signed(len)); + const sepLenInt: int32 = Convert<int32>(separatorLength); + try { + const res = TryInt32Mul(lenInt - 1, sepLenInt) otherwise IfOverflow; + if (res > Convert<int32>(kStringMaxLength)) goto IfOverflow; + } label IfOverflow { + ThrowInvalidStringLength(context); + } + } + + const elements = array.elements; let nofSeparators: intptr = 0; let buffer: Buffer = NewBuffer(len, sep); @@ -752,6 +769,38 @@ implicit context: Context)(useToLocaleString: constexpr bool, o: JSReceiver, len: Number, sep: String, locales: JSAny, options: JSAny): JSAny { + // Check early if the separators might overflow. + const separatorLength: intptr = sep.length_intptr; + if (separatorLength >= 1) { + typeswitch (len) { + case (lenSmi: Smi): { + const lenInt: int32 = Convert<int32>(lenSmi); + if (lenInt > 1) { + try { + // Optimization for the most common case of 1-character separators + // (e.g. ','). In this case, the total separator length is simply + // `len - 1`. We can bypass the multiplication entirely and perform + // a direct comparison on `len`. + if (separatorLength == 1 && len - 1 > kStringMaxLength) { + goto IfOverflow; + } + const sepLenInt: int32 = Convert<int32>(separatorLength); + const res = TryInt32Mul(lenInt - 1, sepLenInt) otherwise IfOverflow; + if (res > Convert<int32>(kStringMaxLength)) goto IfOverflow; + } label IfOverflow { + ThrowInvalidStringLength(context); + } + } + } + case (HeapNumber): { + // If len is a HeapNumber (and positive), it must be > kSmiMaxValue. + // Since kSmiMaxValue > kStringMaxLength, any join with a non-empty + // separator will inevitably exceed the maximum string length. + static_assert(kSmiMaxValue > Convert<uintptr>(kStringMaxLength)); + ThrowInvalidStringLength(context); + } + } + } // If the receiver is not empty and not already being joined, continue with // the normal join algorithm. if (len > 0 && JoinStackPushInline(o)) { @@ -829,6 +878,7 @@ } label NotFastJSArray { // Fall through. } + // TODO(ishell): perform stack checks rarely. PerformStackCheck(context); return CycleProtectedArrayJoin<JSArray>( diff --git a/test/mjsunit/regress/regress-522126182.js b/test/mjsunit/regress/regress-522126182.js new file mode 100644 index 0000000..777055f --- /dev/null +++ b/test/mjsunit/regress/regress-522126182.js @@ -0,0 +1,17 @@ +// 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. + +(function TestLargeArrayJoinWithSeparator() { + let len = 0xfffffffc; + let a = new Array(len); + a[0] = "A".repeat(0x100000); // Force slow path loop (dictionary elements) + assertThrows(() => a.join(","), RangeError); +})(); + +(function TestLargeArrayJoinEmptySeparator() { + let len = 0xfffffffc; + let a = new Array(len); + // Should not throw because separator is empty, and array has no elements. + assertEquals("", a.join("")); +})(); diff --git a/test/mjsunit/regress/regress-crbug-897404.js b/test/mjsunit/regress/regress-crbug-897404.js index 7e8b48d..3b11208 100644 --- a/test/mjsunit/regress/regress-crbug-897404.js +++ b/test/mjsunit/regress/regress-crbug-897404.js @@ -4,7 +4,7 @@ function TestError() {} -const a = new Array(2**32 - 1); +const a = new Array(2**16 - 1); // Force early exit to avoid an unreasonably long test. a[0] = {
Regression Test / PoC
diff --git a/test/mjsunit/regress/regress-522126182.js b/test/mjsunit/regress/regress-522126182.js
new file mode 100644
index 0000000..777055f
--- /dev/null
+++ b/test/mjsunit/regress/regress-522126182.js
@@ -0,0 +1,17 @@
+// 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.
+
+(function TestLargeArrayJoinWithSeparator() {
+ let len = 0xfffffffc;
+ let a = new Array(len);
+ a[0] = "A".repeat(0x100000); // Force slow path loop (dictionary elements)
+ assertThrows(() => a.join(","), RangeError);
+})();
+
+(function TestLargeArrayJoinEmptySeparator() {
+ let len = 0xfffffffc;
+ let a = new Array(len);
+ // Should not throw because separator is empty, and array has no elements.
+ assertEquals("", a.join(""));
+})();
diff --git a/test/mjsunit/regress/regress-crbug-897404.js b/test/mjsunit/regress/regress-crbug-897404.js
index 7e8b48d..3b11208 100644
--- a/test/mjsunit/regress/regress-crbug-897404.js
+++ b/test/mjsunit/regress/regress-crbug-897404.js
@@ -4,7 +4,7 @@
function TestError() {}
-const a = new Array(2**32 - 1);
+const a = new Array(2**16 - 1);
// Force early exit to avoid an unreasonably long test.
a[0] = {
Original Bug Report
Array.prototype.join signed intptr wrap on 32-bit platforms leads to potential heap OOB 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: On 32-bit V8 architectures (arm32/ia32), the signed intptr separator counter in Array.prototype.join can wrap negative when iterating over arrays with lengths approaching 2^32. Due to missing negative bounds checks in the Torque layer, the negative value is stored in the chunk list and interpreted by the C++ string flattening sink as a repeat-last count, resulting in a large linear heap OOB write.
Affected files:
src/builtins/array-join.tqsrc/objects/objects.cc
Estimated timestamp from git blame: 2025-08-14
1. Summary of the Issue (Meant for Human Triage)
A highly severe, potential heap out-of-bounds (OOB) write vulnerability has been identified in V8’s Array.prototype.join implementation when executed on 32-bit architectures (arm32 and ia32).
In the Torque implementation ArrayJoinImpl, a signed intptr counter named nofSeparators is used to keep track of consecutive separators. When joining arrays with length properties approaching kMaxArrayLength (which is kMaxUInt32, or 0xFFFFFFFF), the loop can iterate billions of times. On 32-bit architectures, where an intptr is a signed 32-bit integer, this counter eventually overflows and wraps around to a negative value.
Because AddSeparators and AddStringLength lack lower-bounds checking or negativity guards, this negative counter value is processed as valid and stored as a negative Smi in the internal chunk list. When rendering the final string in the C++ routine WriteChunkListToFlat, the negative Smi is interpreted as a repeat_last directive. Since the bounds checks in the C++ memcpy loops are compiled out in production/release builds (being gated behind DCHECK macros), this results in a massive, linear heap buffer overflow (memcpy) of attacker-controlled data past a freshly allocated SeqOneByteString buffer.
Since 32-bit V8 architectures run with V8_ENABLE_SANDBOX=0 and lack pointer-compression cages or guard regions, this translates to a highly reliable and direct renderer heap corruption primitive. 64-bit systems are structurally unaffected because intptr is a 64-bit signed integer, meaning the counter cannot wrap negative.
2. Proof-of-Concept & Detailed Execution Flow
(Note: The steps below represent a potential trigger flow verified through static analysis and tooling; our agent tooling does not currently have the capability to execute this code dynamically.)
Step-by-Step Execution Flow Analysis
-
Entry Point & Vector Initialization: An attacker invokes
Array.prototype.joinon a receiver with a length close tokMaxUInt32(e.g.,0xFFFFFFFC) on a 32-bit platform:const L = 0x1000; const s = "A".repeat(L); Array.prototype.join.call({0: s, length: 0xFFFFFFFC}, ",");This invokes the
GenericElementsAccessoror dictionary elements accessor fallback paths. Atsrc/builtins/array-join.tq:450, the length is converted to auintptr:const len: uintptr = Convert<uintptr>(lengthNumber); // len = 0xFFFFFFFC -
Signed Counter Overflow: At
src/builtins/array-join.tq:452,nofSeparatorsis initialized as a signedintptr(which is a 32-bit signed integer on 32-bit targets):let nofSeparators: intptr = 0;As the loop iterates over the
lenempty entries/holes, it bypasses adding string fragments but repeatedly increments the separator counter atsrc/builtins/array-join.tq:469:nofSeparators = nofSeparators + 1;Once
nofSeparatorsexceeds0x7FFFFFFF(2,147,483,647), it wraps around to negative values. For0xFFFFFFFCiterations with empty elements,nofSeparatorswraps to0xFFFFFFFB, which represents-5as a signed 32-bit integer. -
Bypassing AddSeparators and AddStringLength Guards: At the end of the loop,
buffer.AddSeparators(nofSeparators, separatorLength, true)is called withnofSeparators = -5. Inside the macroAddSeparators(src/builtins/array-join.tq:202-220):if (nofSeparators == 0 || separatorLength == 0) return; // -5 != 0, bypasses const nofSeparatorsInt: intptr = nofSeparators; const sepsLen: intptr = separatorLength * nofSeparatorsInt; // 1 * -5 = -5 if (sepsLen / separatorLength != nofSeparatorsInt) deferred { ... } // -5 / 1 == -5, bypassesThe macro then calls
AddStringLengthwith a negative length addition:this.totalStringLength = AddStringLength(this.totalStringLength, sepsLen); // totalStringLength decrements by 5AddStringLength(src/builtins/array-join.tq:142-150) usesTryIntPtrAddand guards only against upper bounds exceedingkStringMaxLength, allowing negative values/decrements to slip through:const length: intptr = TryIntPtrAdd(lenA, lenB) otherwise IfOverflow; if (length > kStringMaxLength) goto IfOverflow; // No lower-bound check! return length;Finally,
AddSeparatorsappends the negative value as a Smi into the chunk array:this.AppendToChunk(Convert<Smi>(nofSeparatorsInt)); // Stores Smi(-5) -
Buffer Allocation:
BufferJoininsrc/builtins/array-join.tqallocates aSeqOneByteStringof sizetotalStringLength(which isL - 5whereLis the length of the string at index 0). The positivity check at line 325 (dcheck(IsValidPositiveSmi)) is aDCHECKonly and is disabled in release builds. -
C++ Out-of-Bounds Sink (memcpy): The C++ helper
WriteChunkListToFlatinsrc/objects/objects.ccprocesses the chunk list to fill the allocated sequential string. When it encounters the negative Smi element (value-5):int count; CHECK(Object::ToInt32(element, &count)); // count = -5 if (count > 0) { ... } else { repeat_last = static_cast<uint32_t>(-count); // -(-5) = 5 DCHECK(IsString(last_element)); // Bypassed in release builds }When
repeat_last > 0(which is now5), the doubling-memcpy routine calculates the end destination:uint32_t string_length = Cast<String>(last_element)->length(); uint32_t length_with_sep = string_length + separator_length; sinkchar* copy_end = sink + (length_with_sep * repeat_last) - separator_length;The loop duplicates the string data:
while (sink < copy_end - copy_length) { DCHECK_LE(sink + copy_length, sink_end); // DCHECK ONLY (disabled in release) memcpy(sink, sink - copy_length, copy_length * sizeof(sinkchar)); sink += copy_length; copy_length *= 2; }Because the destination buffer
sinkwas allocated with lengthL - 5but the logic copies6L + 5bytes, the loop writes5L + 10bytes beyond the allocated boundaries of the flat string, overwriting subsequent heap objects.
Suggested Fix
Either change nofSeparators to a uintptr type in ArrayJoinImpl (propagating the unsigned type to AddSeparators), or add a strict non-negative bounds check in AddSeparators:
if (nofSeparators <= 0 || separatorLength == 0) return;
3. Technical Verification Details (Automated Audit Logs - Reviewers may skip this section)
Prior Critic Verdict Verbatim
* **Severity:** High (S1)
* **Brief Notes / Reasoning:**
The vulnerability report accurately identifies a layer-1 heap out-of-bounds (OOB) write in `Array.prototype.join` on 32-bit architectures (arm32/ia32). In `ArrayJoinImpl`, the `nofSeparators` counter is typed as an `intptr`. When joining a sparse or mostly empty array with a length approaching `kMaxArrayLength` (~4 billion elements), this signed 32-bit counter increments without overflow checks, eventually wrapping around to a negative value (e.g. -5). The `AddSeparators` and `AddStringLength` macros lack proper lower-bounds checking or negative guards for this counter, interpreting it as valid and eventually storing it as a negative Smi in the chunk list. In the C++ sink `WriteChunkListToFlat`, this negative Smi is interpreted as a `repeat_last` directive, which triggers an out-of-bounds `memcpy` write operation past the allocated bounds of the `SeqOneByteString`.
Since arm32 and ia32 architectures do not use pointer compression, the V8 sandbox and guard regions are absent (`V8_ENABLE_SANDBOX=0`). Therefore, this results in a direct renderer heap buffer overflow with attacker-controlled content and length, which is a powerful primitive for arbitrary code execution. The only constraint, ~4 billion iterations, is practically achievable in a few minutes due to the highly optimized, allocation-free `DictionaryElements` hole-path. The vulnerability maps to Class F (CSA/Torque builtin length arithmetic bug) and occurs on default flags on tier-1 architectures, fully justifying a High severity (S1).
Mitigations / Validators Checked (Environmental Assumptions)
- V8 Sandbox / Guard Regions / Pointer Compression: On 32-bit targets (arm32 and ia32),
V8_ENABLE_SANDBOXis0, and there is no pointer-compression cage. Hence, there are no sandbox constraints preventing direct corruption of adjacent pointers or objects. - Codebase Investigator Checks:
- Validated that
TryIntPtrAddinsrc/codegen/code-stub-assembler.cc:995-1002checks for signed overflow, meaning a large positivelenAand small negativelenBbypassesIfOverflowbecause the mathematical sum fits safely within a signed 32-bit integer range. - Confirmed the loop condition and structure in
ArrayJoinImplskips adding string fragments for holes viaif (str == kEmptyString) continue;(array-join.tq:484), allowingnofSeparatorsto continuously accumulate over billions of iterations allocation-free viaDictionaryElements. - Confirmed
WriteChunkListToFlatinsrc/objects/objects.cc:4396-4422usesrepeat_lastin awhileloop that callsmemcpy(sink, sink - copy_length, ...)restricted exclusively byDCHECK_LEbounds checks, which are omitted in release builds.
- Validated that
AddSeparatorsDivision Check (array-join.tq:211): The checksepsLen / separatorLength != nofSeparatorsIntperforms signed division. Because-5 / 1 == -5, the division check is bypassed and fails to identify the negative value as an anomaly.BufferJoinPositivity Verification (array-join.tq:325): The checkdcheck(IsValidPositiveSmi(...))is compiled out in release/non-debug builds.WriteChunkListToFlatBounds Checking (objects.cc:4312, 4380, 4408): All memory boundary verification utilizingsink_endis compiled out under#ifdef DEBUG, enabling unchecked sequential heap overwrite in release configurations.- 64-bit Systems: 64-bit systems are structurally unaffected because
intptris a 64-bit signed integer. Since the maximum loop length is capped atkMaxUInt32, the counternofSeparatorscannot overflow into a negative range on these systems.
Evaluated with Chrome root at commit: 71e7e1a98476f8faf0d126dc83935a84331b2194
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.
Note: This bug has been automatically redirected to the top-level Chromium component. Please move it to the actual component: https://b.corp.google.com/components/1456800 once PoCs have been generated.