CVE-2026-10964
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
FileSourceStreamsrc/d8/d8.cc |
modified | |
FileSourceStreamsrc/d8/d8.cc |
modified | |
ifsrc/d8/d8.cc |
modified | |
StreamerThreadsrc/d8/d8.cc |
modified | |
forsrc/d8/d8.cc |
modified |
Files Changed
src/d8/d8.cc
Patch
From 2eed87c5fc04c46143129203f8d7ef6becc443d0 Mon Sep 17 00:00:00 2001
From: Leszek Swirski <leszeks@chromium.org>
Date: Tue, 12 May 2026 13:59:56 +0200
Subject: [PATCH] [d8] Refactor script execution to propagate Shell::Source wrapper
Introduce a unified Shell::Source wrapper class encapsulating either
inline strings or file paths to coordinate pipeline handling across
builtins and evaluation models. Refactor ExecuteString and
CompileString to accept this wrapper, evolving the underlying
streaming implementation to ingest byte chunks directly from native
file handles via FileSourceStream without full string buffering.
This brings us closer to how streaming is implemented in Chromium, where
we stream resource data in chunks directly from a pipe rather than
through an intermediate V8 string.
TAG=agy
CONV=ce6c0a42-1907-4d61-8bf9-5b621b040509
Bug: 511228272
Change-Id: I2683aaf917c090ff24aff6b4de210648bbc1bcb4
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7836622
Commit-Queue: Leszek Swirski <leszeks@chromium.org>
Reviewed-by: Patrick Thier <pthier@chromium.org>
Auto-Submit: Leszek Swirski <leszeks@chromium.org>
Cr-Commit-Position: refs/heads/main@{#107279}
---
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index dc7187e..44c069c 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -638,6 +638,43 @@
bool done_;
};
+class FileSourceStream : public v8::ScriptCompiler::ExternalSourceStream {
+ public:
+ FileSourceStream(Isolate* isolate, const char* filename) {
+ file_ = base::Fopen(filename, "rb");
+ if (file_ == nullptr) {
+ std::ostringstream oss;
+ oss << "Error loading file for streaming: " << filename;
+ ThrowError(isolate, oss.view());
+ }
+ }
+
+ ~FileSourceStream() override {
+ if (file_ != nullptr) {
+ base::Fclose(file_);
+ }
+ }
+
+ size_t GetMoreData(const uint8_t** src) override {
+ if (file_ == nullptr) return 0;
+
+ auto buffer = std::make_unique<uint8_t[]>(kChunkSize);
+ size_t bytes_read = fread(buffer.get(), 1, kChunkSize, file_);
+ if (bytes_read == 0) {
+ return 0;
+ }
+
+ *src = buffer.release();
+ return bytes_read;
+ }
+
+ bool IsValid() const { return file_ != nullptr; }
+
+ private:
+ static constexpr size_t kChunkSize = 4096;
+ FILE* file_ = nullptr;
+};
+
// Run a ScriptStreamingTask in a separate thread.
class StreamerThread : public v8::base::Thread {
public:
@@ -701,28 +738,68 @@
} // namespace
+MaybeLocal<String> Shell::Source::ConvertToString(Isolate* isolate) const {
+ if (type_ == Type::kString) {
+ return string_;
+ }
+ DCHECK_EQ(type_, Type::kFile);
+ return Shell::ReadFile(isolate, filename_);
+}
+
template <class T>
-MaybeLocal<T> Shell::CompileString(Isolate* isolate, Local<Context> context,
- Local<String> source,
+MaybeLocal<T> Shell::CompileSource(Isolate* isolate, Local<Context> context,
+ const Source& source,
const ScriptOrigin& origin) {
if (options.streaming_compile) {
- v8::ScriptCompiler::StreamedSource streamed_source(
- std::make_unique<DummySourceStream>(isolate, source),
- v8::ScriptCompiler::StreamedSource::TWO_BYTE);
+ std::unique_ptr<v8::ScriptCompiler::ExternalSourceStream> source_stream;
+ v8::ScriptCompiler::StreamedSource::Encoding encoding;
+ Local<String> source_string;
+
+ if (source.type() == Source::Type::kString) {
+ source_string = source.string();
+ source_stream =
+ std::make_unique<DummySourceStream>(isolate, source_string);
+ encoding = v8::ScriptCompiler::StreamedSource::TWO_BYTE;
+ } else {
+ DCHECK_EQ(source.type(), Source::Type::kFile);
+ auto file_stream =
+ std::make_unique<FileSourceStream>(isolate, source.filename());
+ if (!file_stream->IsValid()) {
+ return MaybeLocal<T>();
+ }
+ source_stream = std::move(file_stream);
+ encoding = v8::ScriptCompiler::StreamedSource::UTF8;
+ }
+
+ v8::ScriptCompiler::StreamedSource streamed_source(std::move(source_stream),
+ encoding);
std::unique_ptr<v8::ScriptCompiler::ScriptStreamingTask> streaming_task(
v8::ScriptCompiler::StartStreaming(isolate, &streamed_source,
std::is_same_v<T, Module>
? v8::ScriptType::kModule
: v8::ScriptType::kClassic));
StreamerThread::StartThreadForTaskAndJoin(streaming_task.get());
- return CompileStreamed<T>(context, &streamed_source, source, origin);
+
+ if (source_string.IsEmpty()) {
+ if (!source.ConvertToString(isolate).ToLocal(&source_string)) {
+ return MaybeLocal<T>();
+ }
+ }
+ update_script_size(source_string->Length());
+ return CompileStreamed<T>(context, &streamed_source, source_string, origin);
}
+ Local<String> source_string;
+ if (!source.ConvertToString(isolate).ToLocal(&source_string)) {
+ return MaybeLocal<T>();
+ }
+ update_script_size(source_string->Length());
+
ScriptCompiler::CachedData* cached_code = nullptr;
if (options.compile_options & ScriptCompiler::kConsumeCodeCache) {
- cached_code = LookupCodeCache(isolate, source);
+ cached_code = LookupCodeCache(isolate, source_string);
}
- ScriptCompiler::Source script_source(source, origin, cached_code);
+ ScriptCompiler::Source script_source(source_string, origin, cached_code);
MaybeLocal<T> result =
Compile<T>(context, &script_source,
cached_code ? ScriptCompiler::kConsumeCodeCache
@@ -949,16 +1026,28 @@
} // namespace
-// Executes a string within the current v8 context.
-bool Shell::ExecuteString(Isolate* isolate, Local<String> source,
+// Executes a source within the current v8 context.
+bool Shell::ExecuteSource(Isolate* isolate, const Source& source,
Local<String> name,
ReportExceptions report_exceptions,
Global<Value>* out_result) {
i::Isolate* i_isolate = reinterpret_cast<i::Isolate*>(isolate);
if (i_isolate->is_execution_terminating()) return true;
+
+ Local<String> source_str;
+ if (i::v8_flags.parse_only ||
+ options.code_cache_options ==
+ ShellOptions::CodeCacheOptions::kProduceCache ||
+ options.code_cache_options ==
+ ShellOptions::CodeCacheOptions::kProduceCacheAfterExecute) {
+ if (!source.ConvertToString(isolate).ToLocal(&source_str)) {
+ return false;
+ }
+ }
+
if (i::v8_flags.parse_only) {
i::VMState<PARSER> state(i_isolate);
- i::DirectHandle<i::String> str = Utils::OpenDirectHandle(*(source));
+ i::DirectHandle<i::String> str = Utils::OpenDirectHandle(*(source_str));
// Set up ParseInfo.
i::UnoptimizedCompileState compile_state;
@@ -1014,12 +1103,12 @@
for (int i = 1; i < options.repeat_compile; ++i) {
HandleScope handle_scope_for_compiling(isolate);
- if (CompileString<Script>(isolate, context, source, origin).IsEmpty()) {
+ if (CompileSource<Script>(isolate, context, source, origin).IsEmpty()) {
return false;
}
}
Local<Script> script;
- if (!CompileString<Script>(isolate, context, source, origin)
+ if (!CompileSource<Script>(isolate, context, source, origin)
.ToLocal(&script)) {
return false;
}
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.