High chrome Integer Overflow 📄 Reporter bug report 🔧 Commit mapped

Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in V8
DescriptionInteger overflow in V8
ComponentV8
Bug ClassInteger Overflow
Tracker522126182
Fix commit7893ac23bc1e (v8/v8) +75/-8
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
src/builtins/array-join.tq
modified
typeswitch
src/builtins/array-join.tq
modified

Files Changed

  • src/builtins/array-join.tq
  • test/mjsunit/regress/regress-522126182.js
  • test/mjsunit/regress/regress-crbug-897404.js
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] = {
Loading diff…

Regression Test / PoC

shipped with the fix
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] = {
Loading diff…

Original Bug Report

reported by [email protected]

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.tq
  • src/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

  1. Entry Point & Vector Initialization: An attacker invokes Array.prototype.join on a receiver with a length close to kMaxUInt32 (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 GenericElementsAccessor or dictionary elements accessor fallback paths. At src/builtins/array-join.tq:450, the length is converted to a uintptr:

    const len: uintptr = Convert<uintptr>(lengthNumber);   // len = 0xFFFFFFFC
    
  2. Signed Counter Overflow: At src/builtins/array-join.tq:452, nofSeparators is initialized as a signed intptr (which is a 32-bit signed integer on 32-bit targets):

    let nofSeparators: intptr = 0;
    

    As the loop iterates over the len empty entries/holes, it bypasses adding string fragments but repeatedly increments the separator counter at src/builtins/array-join.tq:469:

    nofSeparators = nofSeparators + 1;
    

    Once nofSeparators exceeds 0x7FFFFFFF (2,147,483,647), it wraps around to negative values. For 0xFFFFFFFC iterations with empty elements, nofSeparators wraps to 0xFFFFFFFB, which represents -5 as a signed 32-bit integer.

  3. Bypassing AddSeparators and AddStringLength Guards: At the end of the loop, buffer.AddSeparators(nofSeparators, separatorLength, true) is called with nofSeparators = -5. Inside the macro AddSeparators (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, bypasses
    

    The macro then calls AddStringLength with a negative length addition:

    this.totalStringLength = AddStringLength(this.totalStringLength, sepsLen); // totalStringLength decrements by 5
    

    AddStringLength (src/builtins/array-join.tq:142-150) uses TryIntPtrAdd and guards only against upper bounds exceeding kStringMaxLength, 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, AddSeparators appends the negative value as a Smi into the chunk array:

    this.AppendToChunk(Convert<Smi>(nofSeparatorsInt)); // Stores Smi(-5)
    
  4. Buffer Allocation: BufferJoin in src/builtins/array-join.tq allocates a SeqOneByteString of size totalStringLength (which is L - 5 where L is the length of the string at index 0). The positivity check at line 325 (dcheck(IsValidPositiveSmi)) is a DCHECK only and is disabled in release builds.

  5. C++ Out-of-Bounds Sink (memcpy): The C++ helper WriteChunkListToFlat in src/objects/objects.cc processes 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 now 5), 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 sink was allocated with length L - 5 but the logic copies 6L + 5 bytes, the loop writes 5L + 10 bytes 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_SANDBOX is 0, 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 TryIntPtrAdd in src/codegen/code-stub-assembler.cc:995-1002 checks for signed overflow, meaning a large positive lenA and small negative lenB bypasses IfOverflow because the mathematical sum fits safely within a signed 32-bit integer range.
    • Confirmed the loop condition and structure in ArrayJoinImpl skips adding string fragments for holes via if (str == kEmptyString) continue; (array-join.tq:484), allowing nofSeparators to continuously accumulate over billions of iterations allocation-free via DictionaryElements.
    • Confirmed WriteChunkListToFlat in src/objects/objects.cc:4396-4422 uses repeat_last in a while loop that calls memcpy(sink, sink - copy_length, ...) restricted exclusively by DCHECK_LE bounds checks, which are omitted in release builds.
  • AddSeparators Division Check (array-join.tq:211): The check sepsLen / separatorLength != nofSeparatorsInt performs signed division. Because -5 / 1 == -5, the division check is bypassed and fails to identify the negative value as an anomaly.
  • BufferJoin Positivity Verification (array-join.tq:325): The check dcheck(IsValidPositiveSmi(...)) is compiled out in release/non-debug builds.
  • WriteChunkListToFlat Bounds Checking (objects.cc:4312, 4380, 4408): All memory boundary verification utilizing sink_end is compiled out under #ifdef DEBUG, enabling unchecked sequential heap overwrite in release configurations.
  • 64-bit Systems: 64-bit systems are structurally unaffected because intptr is a 64-bit signed integer. Since the maximum loop length is capped at kMaxUInt32, the counter nofSeparators cannot 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.

View on issue tracker