CVE-2026-10964
Overview
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
LiteralBuffermethod 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
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.
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
- 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.
- Trigger the conversion
A trailing non-Latin-1 character forces
ConvertToTwoByte, whereposition_ * base::kUC16Sizeoverflows the signedintand wraps negative. - 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. - 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
(inferred). The advisory rates this high severity with no in-the-wild exploitation reported.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/parsing/literal-buffer.cc |
modified | |
forsrc/parsing/literal-buffer.cc |
modified | |
OverlongMockSourcetest/unittests/parser/scanner-streams-unittest.cc |
modified | |
TEST_Ftest/unittests/parser/scanner-streams-unittest.cc |
modified |
Files Changed
src/parsing/literal-buffer.ccsrc/parsing/literal-buffer.hsrc/parsing/scanner-character-streams.cctest/unittests/parser/scanner-streams-unittest.cc
Audit Directions
- Signed size arithmeticFlag any buffer-size or index computation done in signed
int(especiallycount * elementSize); such products can wrap negative and defeat>=capacity checks. - Unbounded streaming accumulationWhere 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 branchesAudit 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.
Patch
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();
+ },
+ "");
+}
Regression Test / PoC
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();
+ },
+ "");
+}
Original Bug Report
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.ccv8/src/parsing/literal-buffer.hv8/src/codegen/compiler.ccv8/src/parsing/scanner.ccv8/src/parsing/scanner-character-streams.ccthird_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];
}
// ...
}
- Integer Overflow:
position_is a signedinttracking the number of accumulated characters. Normally,String::kMaxLengthprevents strings from reaching 1 GiB. However, the backgroundScannerdoes not enforce this limit while accumulating literals. Whenposition_reaches exactly0x40000000(1 GiB),position_ * base::kUC16Size(wherekUC16Sizeis 2) overflows to0x80000000. As signed 32-bit integers are used and Chromium compiles with-fno-strict-overflow, this safely wraps to-2147483648(INT_MIN). - Comparison Failure: The check
new_content_size >= backing_store_.length()evaluatesINT_MIN >= 0x40000000. This returnsfalse. The function skips allocating a new 2 GiB buffer and aliasesnew_storeto the existing 1 GiBbacking_store_. - Out-of-Bounds Write: The loop expands characters in-place by sweeping backwards. On the first iteration (
i = 0x3FFFFFFF), the code reads safely fromsrc[i]but writes todst[i], which translates to byte offset0x7FFFFFFE. Since the buffer is only0x40000000bytes 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:
- Before the background thread reaches the overflow, the attacker’s main thread allocates a massive 1 GiB target object (e.g., a Blink
ArrayBuffer). - PartitionAlloc’s
AddressPoolManagerplaces DirectMap allocations sequentially bottom-up. The Target object will be placed adjacently above theLiteralBuffer. - When the out-of-bounds write starts at offset
0x7FFFFFFE, it lands squarely inside the Target object’s payload. - 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.
- Set up an HTTP server to deliver a large JavaScript file with
Content-Type: application/javascript; charset=utf-8andContent-Encoding: gzip(to bypass network fetch limits and optimize delivery). - The script body should contain a string literal starting with
0x40000000bytes (1 GiB) of ASCII characters (e.g.,'A'), followed by a non-ASCII two-byte character (e.g.,\u0100, encoded as0xC4 0x80in UTF-8), and ending with a quote. - Navigate Chrome to an HTML page that includes this script via
<script src="..."></script>. - 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
- Immediate Fix: Use
base::CheckedNumericorbase::CheckedMulfor thenew_content_sizecalculation inLiteralBuffer::ConvertToTwoByte()to safely catch the overflow and trigger a safe OOM crash. - Structural Fix: Enforce
v8::String::kMaxLengthlimits directly within theScannerorLiteralBufferduring 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.