Overview

High
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInteger overflow in V8
DescriptionInteger overflow in V8
ComponentV8
Sub-componentD8
Bug ClassInteger Overflow
Action tierpatch-now — corruption primitive, web-reachable in default config (confirmed) — factors set by independent review by AI
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.
Independent review by AIConfirmed
Reviewer notes: none false; quoted pre-fix/fix lines match literal-buffer.cc/.h and scanner-character-streams.cc. Minor: quoted CHECK_LE(position,...) is ChunkedStream only; Utf8 path checks current_.pos.bytes. Mapped 2eed87c is d8 test refactor; 9180d91 reland is the fix.
A second, independent AI reviewer (not a human) re-derived this analysis from the fixing commit and the vendor advisory, looking for errors.
Tracker511228272
Fix commit9180d91eb6e2 (v8/v8) +58/-16
Related commits2eed87c5fc04 previously mapped (not the fix) · v8/v8
4095047c3468 candidate · v8/v8
CWECWE-472
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-05-08 (per vendor advisory)
Fix landed2026-05-13 (public git)
Disclosed2026-06-02
Patch gap20 days — fix public in source before the advisory

Background

`LiteralBuffer`
V8’s scanner-side growable byte buffer that accumulates the characters of one string/identifier literal, in one-byte (Latin-1) or two-byte (UC16) form.
`ConvertToTwoByte`
the LiteralBuffer method that re-encodes an accumulated one-byte literal into two-byte storage when a non-Latin-1 character appears.
`position_`
the field tracking how many bytes have been accumulated in the current literal.
Streaming compilation
V8’s background path that feeds script bytes to the scanner in chunks via an ExternalSourceStream, outside the main thread.

Root Cause Analysis

Re-mapped on 2026-10-03: our pipeline had picked the earliest commit citing this bug (2eed87c5fc04), which an audit found was a preparatory/helper change, not the fix. This page now points to the commit whose diff removes the bug.

In the pre-fix LiteralBuffer, the accumulation index and capacity were signed 32-bit int: the declaration - int position_ = 0; and the helpers - int NewCapacity(int min_capacity);. ConvertToTwoByte computed its needed size as - int new_content_size = position_ * base::kUC16Size; and gated reallocation on - if (new_content_size >= backing_store_.length()) {. When a literal grew to ~1 GiB (position_ == 0x40000000), position_ * 2 overflowed the signed int and wrapped negative, so the >= comparison was false and the code aliased new_store to the existing undersized backing_store_ instead of allocating a larger buffer. The subsequent expansion loop - for (int i = position_ - 1; i >= 0; i--) { dst[i] = src[i]; } then wrote two-byte units far past the buffer, an out-of-bounds write. The streaming scanner is what let position_ reach that size, because the background path did not cap accumulated input against v8::String::kMaxLength.

The fix (9180d91e, a reland of the reverted 4095047c) makes position_, NewCapacity, and the capacity constants size_t, so + size_t new_content_size = position_ * base::kUC16Size; cannot wrap negative and the >= comparison stays meaningful; it adds + CHECK_LE(position_, new_store.size()); immediately before the copy; it rewrites the loop as + for (size_t i = position_; i > 0; i--) { dst[i - 1] = src[i - 1]; }; and it bounds streamed input with + CHECK_LE(position, static_cast<size_t>(v8::String::kMaxLength)); in the chunk-fetch paths so oversized streams abort before reaching the buffer.

Key insight
A signed-int size computation (position_ * kUC16Size) could wrap negative at ~1 GiB and defeat the grow-check; widening the index/capacity types to size_t and adding a CHECK_LE bound plus stream-length caps removes the overflow and the resulting aliased out-of-bounds copy.

Attack Path

  1. Stream a giant literal The attacker serves a script whose string literal is ~1 GiB of one-byte characters, delivered through streaming compilation so the background scanner accumulates it without the normal length limit.
  2. Trigger the conversion A trailing non-Latin-1 character forces ConvertToTwoByte, where position_ * base::kUC16Size overflows the signed int and wraps negative.
  3. Skip the reallocation The wrapped value fails new_content_size >= backing_store_.length(), so the code reuses the undersized one-byte buffer as the two-byte destination.
  4. Out-of-bounds write The backward expansion loop writes two-byte units past the buffer end, corrupting adjacent heap memory. (inferred: heap grooming / sandbox-escape escalation is from the report, not shown in the code)

Impact Assessment

A remote page serving a crafted ~1 GiB streamed script could drive an out-of-bounds heap write during background parsing in the renderer process. The reporter claims this allows corruption of an adjacent allocation and a sandbox escape, but the material only shows the OOB write itself; escalation is (inferred). The advisory rates this high severity with no in-the-wild exploitation reported.

Changed Functions

FunctionChangeNotes
if
src/parsing/literal-buffer.cc
modified
for
src/parsing/literal-buffer.cc
modified
OverlongMockSource
test/unittests/parser/scanner-streams-unittest.cc
modified
TEST_F
test/unittests/parser/scanner-streams-unittest.cc
modified

Files Changed

  • src/parsing/literal-buffer.cc
  • src/parsing/literal-buffer.h
  • src/parsing/scanner-character-streams.cc
  • test/unittests/parser/scanner-streams-unittest.cc

Audit Directions

  • Signed size arithmetic
    Flag any buffer-size or index computation done in signed int (especially count * elementSize); such products can wrap negative and defeat >= capacity checks.
  • Unbounded streaming accumulation
    Where a background/streaming path accumulates data, verify it enforces the same hard limits (String::kMaxLength) as the synchronous path before growth math runs.
  • Grow-or-alias branches
    Audit code that either reallocates or reuses an existing buffer based on a size comparison; a mis-evaluated comparison that takes the reuse branch yields an undersized destination.
diff --git a/src/parsing/literal-buffer.cc b/src/parsing/literal-buffer.cc
index c7e5560..4ff2071 100644
--- a/src/parsing/literal-buffer.cc
+++ b/src/parsing/literal-buffer.cc
@@ -26,14 +26,14 @@
 template DirectHandle<String> LiteralBuffer::Internalize(
     LocalIsolate* isolate) const;
 
-int LiteralBuffer::NewCapacity(int min_capacity) {
+size_t LiteralBuffer::NewCapacity(size_t min_capacity) {
   return min_capacity < (kMaxGrowth / (kGrowthFactor - 1))
              ? min_capacity * kGrowthFactor
              : min_capacity + kMaxGrowth;
 }
 
 void LiteralBuffer::ExpandBuffer() {
-  int min_capacity = std::max({kInitialCapacity, backing_store_.length()});
+  size_t min_capacity = std::max(kInitialCapacity, backing_store_.size());
   base::Vector<uint8_t> new_store =
       base::Vector<uint8_t>::New(NewCapacity(min_capacity));
   if (position_ > 0) {
@@ -46,18 +46,19 @@
 void LiteralBuffer::ConvertToTwoByte() {
   DCHECK(is_one_byte());
   base::Vector<uint8_t> new_store;
-  int new_content_size = position_ * base::kUC16Size;
-  if (new_content_size >= backing_store_.length()) {
+  size_t new_content_size = position_ * base::kUC16Size;
+  if (new_content_size >= backing_store_.size()) {
     // Ensure room for all currently read code units as UC16 as well
     // as the code unit about to be stored.
     new_store = base::Vector<uint8_t>::New(NewCapacity(new_content_size));
   } else {
     new_store = backing_store_;
   }
+  CHECK_LE(position_, new_store.size());
   uint8_t* src = backing_store_.begin();
   uint16_t* dst = reinterpret_cast<uint16_t*>(new_store.begin());
-  for (int i = position_ - 1; i >= 0; i--) {
-    dst[i] = src[i];
+  for (size_t i = position_; i > 0; i--) {
+    dst[i - 1] = src[i - 1];
   }
   if (new_store.begin() != backing_store_.begin()) {
     backing_store_.Dispose();
@@ -69,7 +70,7 @@
 
 void LiteralBuffer::AddTwoByteChar(base::uc32 code_unit) {
   DCHECK(!is_one_byte());
-  if (position_ >= backing_store_.length()) ExpandBuffer();
+  if (position_ >= backing_store_.size()) ExpandBuffer();
   if (code_unit <=
       static_cast<base::uc32>(unibrow::Utf16::kMaxNonSurrogateCharCode)) {
     *reinterpret_cast<uint16_t*>(&backing_store_[position_]) = code_unit;
@@ -78,7 +79,7 @@
     *reinterpret_cast<uint16_t*>(&backing_store_[position_]) =
         unibrow::Utf16::LeadSurrogate(code_unit);
     position_ += base::kUC16Size;
-    if (position_ >= backing_store_.length()) ExpandBuffer();
+    if (position_ >= backing_store_.size()) ExpandBuffer();
     *reinterpret_cast<uint16_t*>(&backing_store_[position_]) =
         unibrow::Utf16::TrailSurrogate(code_unit);
     position_ += base::kUC16Size;
diff --git a/src/parsing/literal-buffer.h b/src/parsing/literal-buffer.h
index 92ed94b..d11fdbd 100644
--- a/src/parsing/literal-buffer.h
+++ b/src/parsing/literal-buffer.h
@@ -41,7 +41,7 @@
   bool is_one_byte() const { return is_one_byte_; }
 
   bool Equals(base::Vector<const char> keyword) const {
-    return is_one_byte() && keyword.length() == position_ &&
+    return is_one_byte() && keyword.size() == position_ &&
            (memcmp(keyword.begin(), backing_store_.begin(), position_) == 0);
   }
 
@@ -62,7 +62,9 @@
         position_ >> (sizeof(Char) - 1));
   }
 
-  int length() const { return is_one_byte() ? position_ : (position_ >> 1); }
+  int length() const {
+    return static_cast<int>(is_one_byte() ? position_ : (position_ >> 1));
+  }
 
   void Start() {
     position_ = 0;
@@ -73,9 +75,9 @@
   DirectHandle<String> Internalize(IsolateT* isolate) const;
 
  private:
-  static constexpr int kInitialCapacity = 256;
-  static constexpr int kGrowthFactor = 4;
-  static constexpr int kMaxGrowth = 1 * MB;
+  static constexpr size_t kInitialCapacity = 256;
+  static constexpr size_t kGrowthFactor = 4;
+  static constexpr size_t kMaxGrowth = 1 * MB;
 
   inline bool IsValidAscii(char code_unit) {
     // Control characters and printable characters span the range of
@@ -87,18 +89,18 @@
 
   V8_INLINE void AddOneByteChar(uint8_t one_byte_char) {
     DCHECK(is_one_byte());
-    if (position_ >= backing_store_.length()) ExpandBuffer();
+    if (position_ >= backing_store_.size()) ExpandBuffer();
     backing_store_[position_] = one_byte_char;
     position_ += kOneByteSize;
   }
 
   void AddTwoByteChar(base::uc32 code_unit);
-  int NewCapacity(int min_capacity);
+  size_t NewCapacity(size_t min_capacity);
   V8_NOINLINE V8_PRESERVE_MOST void ExpandBuffer();
   void ConvertToTwoByte();
 
   base::Vector<uint8_t> backing_store_;
-  int position_ = 0;
+  size_t position_ = 0;
   bool is_one_byte_ = true;
 };
 
diff --git a/src/parsing/scanner-character-streams.cc b/src/parsing/scanner-character-streams.cc
index 3c0a6d1..1dce68e 100644
--- a/src/parsing/scanner-character-streams.cc
+++ b/src/parsing/scanner-character-streams.cc
@@ -213,6 +213,7 @@
     // Cloned ChunkedStreams have a null source, and therefore can't fetch any
     // new data.
     DCHECK_NOT_NULL(source_);
+    CHECK_LE(position, static_cast<size_t>(v8::String::kMaxLength));
 
     const uint8_t* data = nullptr;
     size_t length;
@@ -220,6 +221,9 @@
       RCS_SCOPE(stats, RuntimeCallCounterId::kGetMoreDataCallback);
       length = source_->GetMoreData(&data);
     }
+    // We can't handle more data than the max string length.
+    CHECK_LE(length / sizeof(Char),
+             static_cast<size_t>(v8::String::kMaxLength) - position);
     ProcessChunk(data, position, length);
   }
 
@@ -746,9 +750,13 @@
   // Utf8ExternalStreamingStreams that have been cloned are not allowed to fetch
   // any more.
   DCHECK_EQ(chunks_.use_count(), 1);
+  CHECK_LE(current_.pos.bytes, static_cast<size_t>(v8::String::kMaxLength));
 
   const uint8_t* chunk = nullptr;
   size_t length = source_stream_->GetMoreData(&chunk);
+  // We can't handle more data than the max string length.
+  CHECK_LE(length,
+           static_cast<size_t>(v8::String::kMaxLength) - current_.pos.bytes);
   chunks_->emplace_back(chunk, length, current_.pos);
   return length > 0;
 }
diff --git a/test/unittests/parser/scanner-streams-unittest.cc b/test/unittests/parser/scanner-streams-unittest.cc
index d6399c8..4806766 100644
--- a/test/unittests/parser/scanner-streams-unittest.cc
+++ b/test/unittests/parser/scanner-streams-unittest.cc
@@ -990,3 +990,34 @@
     TestCloneCharacterStream("12345678", two_byte_streaming_stream.get(), 8);
   }
 }
+
+class OverlongMockSource : public v8::ScriptCompiler::ExternalSourceStream {
+ public:
+  OverlongMockSource() : returned_(false) {}
+  size_t GetMoreData(const uint8_t** src) override {
+    if (returned_) return 0;
+    size_t size = static_cast<size_t>(v8::String::kMaxLength) + 100;
+    uint8_t* buffer = new uint8_t[size];
+    memset(buffer, 'a', size);
+    buffer[size - 2] = 0xC4;
+    buffer[size - 1] = 0x80;
+    *src = buffer;
+    returned_ = true;
+    return size;
+  }
+
+ private:
+  bool returned_;
+};
+
+TEST_F(ScannerStreamsTest, StreamSizeExceedsMaxBounds) {
+  OverlongMockSource source;
+  ASSERT_DEATH_IF_SUPPORTED(
+      {
+        std::unique_ptr<v8::internal::Utf16CharacterStream> stream(
+            v8::internal::ScannerStream::For(
+                &source, v8::ScriptCompiler::StreamedSource::UTF8));
+        stream->Advance();
+      },
+      "");
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/unittests/parser/scanner-streams-unittest.cc b/test/unittests/parser/scanner-streams-unittest.cc
index d6399c8..4806766 100644
--- a/test/unittests/parser/scanner-streams-unittest.cc
+++ b/test/unittests/parser/scanner-streams-unittest.cc
@@ -990,3 +990,34 @@
     TestCloneCharacterStream("12345678", two_byte_streaming_stream.get(), 8);
   }
 }
+
+class OverlongMockSource : public v8::ScriptCompiler::ExternalSourceStream {
+ public:
+  OverlongMockSource() : returned_(false) {}
+  size_t GetMoreData(const uint8_t** src) override {
+    if (returned_) return 0;
+    size_t size = static_cast<size_t>(v8::String::kMaxLength) + 100;
+    uint8_t* buffer = new uint8_t[size];
+    memset(buffer, 'a', size);
+    buffer[size - 2] = 0xC4;
+    buffer[size - 1] = 0x80;
+    *src = buffer;
+    returned_ = true;
+    return size;
+  }
+
+ private:
+  bool returned_;
+};
+
+TEST_F(ScannerStreamsTest, StreamSizeExceedsMaxBounds) {
+  OverlongMockSource source;
+  ASSERT_DEATH_IF_SUPPORTED(
+      {
+        std::unique_ptr<v8::internal::Utf16CharacterStream> stream(
+            v8::internal::ScannerStream::For(
+                &source, v8::ScriptCompiler::StreamedSource::UTF8));
+        stream->Advance();
+      },
+      "");
+}
Loading diff…

Original Bug Report

reported by vm...@google.com

Integer overflow in LiteralBuffer::ConvertToTwoByte leads to 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 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: An integer overflow exists in LiteralBuffer::ConvertToTwoByte during the background parsing of JavaScript strings. By providing a 1 GiB string literal via streaming compilation, an attacker can overflow a signed 32-bit size calculation, causing a massive out-of-bounds write outside the V8 sandbox. A concurrent heap grooming strategy can exploit the resulting race window to achieve remote code execution in the renderer.

Affected files:

  • v8/src/parsing/literal-buffer.cc
  • v8/src/parsing/literal-buffer.h
  • v8/src/codegen/compiler.cc
  • v8/src/parsing/scanner.cc
  • v8/src/parsing/scanner-character-streams.cc
  • third_party/blink/renderer/bindings/core/v8/script_streamer.cc

Estimated timestamp from git blame: 2023-06-13

Summary

A potential integer overflow vulnerability exists in V8’s LiteralBuffer::ConvertToTwoByte, which is triggered when parsing a massive (1 GiB) string literal. Because the streaming compilation path (BackgroundCompileTask) does not enforce standard V8 string length limits during parsing, an attacker can force a buffer to grow to 1 GiB. When a non-ASCII character is subsequently encountered, a signed integer overflow occurs during buffer expansion calculation. This results in the reuse of an undersized buffer and a massive out-of-bounds backward sweep across the renderer heap, completely bypassing the V8 sandbox.

Vulnerability Details

In v8/src/parsing/literal-buffer.cc, the ConvertToTwoByte() method converts a string literal buffer from one-byte (Latin-1) to two-byte (UC16) when a non-Latin-1 character is found.

void LiteralBuffer::ConvertToTwoByte() {
  DCHECK(is_one_byte());
  base::Vector<uint8_t> new_store;
  int new_content_size = position_ * base::kUC16Size; // [1] Integer Overflow
  if (new_content_size >= backing_store_.length()) { // [2] Comparison Failure
    new_store = base::Vector<uint8_t>::New(NewCapacity(new_content_size));
  } else {
    new_store = backing_store_;
  }
  uint8_t* src = backing_store_.begin();
  uint16_t* dst = reinterpret_cast<uint16_t*>(new_store.begin());
  for (int i = position_ - 1; i >= 0; i--) { // [3] OOB Write Loop
    dst[i] = src[i];
  }
  // ...
}
  1. Integer Overflow: position_ is a signed int tracking the number of accumulated characters. Normally, String::kMaxLength prevents strings from reaching 1 GiB. However, the background Scanner does not enforce this limit while accumulating literals. When position_ reaches exactly 0x40000000 (1 GiB), position_ * base::kUC16Size (where kUC16Size is 2) overflows to 0x80000000. As signed 32-bit integers are used and Chromium compiles with -fno-strict-overflow, this safely wraps to -2147483648 (INT_MIN).
  2. Comparison Failure: The check new_content_size >= backing_store_.length() evaluates INT_MIN >= 0x40000000. This returns false. The function skips allocating a new 2 GiB buffer and aliases new_store to the existing 1 GiB backing_store_.
  3. Out-of-Bounds Write: The loop expands characters in-place by sweeping backwards. On the first iteration (i = 0x3FFFFFFF), the code reads safely from src[i] but writes to dst[i], which translates to byte offset 0x7FFFFFFE. Since the buffer is only 0x40000000 bytes long, this write lands almost 1 GiB out-of-bounds.

Exploitability and Sandbox Bypass

The LiteralBuffer allocates memory using base::Vector::New, which routes to PartitionAlloc’s default Malloc partition. Crucially, this partition is outside the V8 Sandbox.

While the backward write loop sweeps from 0x7FFFFFFE down towards 0x40000000, it will eventually hit the uncommitted PartitionAlloc guard pages that precede the 1 GiB LiteralBuffer allocation, causing a deterministic SIGSEGV crash.

However, this sweeping operation over ~1 GiB of memory takes tens of milliseconds, creating a substantial race window. An attacker can exploit this via concurrent heap grooming on the main thread:

  1. Before the background thread reaches the overflow, the attacker’s main thread allocates a massive 1 GiB target object (e.g., a Blink ArrayBuffer).
  2. PartitionAlloc’s AddressPoolManager places DirectMap allocations sequentially bottom-up. The Target object will be placed adjacently above the LiteralBuffer.
  3. When the out-of-bounds write starts at offset 0x7FFFFFFE, it lands squarely inside the Target object’s payload.
  4. While the background thread sweeps downward (corrupting the Target object), the attacker’s main thread can observe the corruption and use it to escape the sandbox before the background thread hits the guard page and crashes.

Suggested Reproduction Steps

Note: These are potential steps as our tooling cannot currently execute code to verify.

  1. Set up an HTTP server to deliver a large JavaScript file with Content-Type: application/javascript; charset=utf-8 and Content-Encoding: gzip (to bypass network fetch limits and optimize delivery).
  2. The script body should contain a string literal starting with 0x40000000 bytes (1 GiB) of ASCII characters (e.g., 'A'), followed by a non-ASCII two-byte character (e.g., \u0100, encoded as 0xC4 0x80 in UTF-8), and ending with a quote.
  3. Navigate Chrome to an HTML page that includes this script via <script src="..."></script>.
  4. The background parser will accumulate 1 GiB of characters. Upon encountering the two-byte character, it triggers the overflow in ConvertToTwoByte, resulting in an OOB write and eventual crash.

Suggested Fix

  1. Immediate Fix: Use base::CheckedNumeric or base::CheckedMul for the new_content_size calculation in LiteralBuffer::ConvertToTwoByte() to safely catch the overflow and trigger a safe OOM crash.
  2. Structural Fix: Enforce v8::String::kMaxLength limits directly within the Scanner or LiteralBuffer during the streaming compilation phase to prevent the buffer from growing beyond safe sizes in the first place.

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.

View on issue tracker