CVE-2026-14393
Overview
Background
- `V8ConsoleMessageStorage`
- Per-context-group container that owns the queue of
V8ConsoleMessageobjects buffered for a debugging session. - `V8InspectorImpl`
- The central inspector object that maps each
contextGroupIdto itsV8ConsoleMessageStorageviam_consoleStorageMap. - Nested message loop
- A reentrant execution pump triggered when JS hits a
debuggerstatement, allowing arbitrary JS (and inspector teardown/recreation) to run in the middle of another inspector operation. - UAF (use-after-free)
- Accessing memory through a pointer after the underlying object has been freed, here a freed
V8ConsoleMessageStorage.
Root Cause Analysis
When an inspector agent reported buffered console messages, code such as V8ConsoleAgentImpl::enable and V8RuntimeAgentImpl::reportMessages fetched the V8ConsoleMessageStorage once via ensureConsoleMessageStorage and then iterated its messages() list, holding the raw storage pointer across calls to reportMessage. Reporting a message can format arguments that invoke JS getters, and JS can execute a debugger statement that spins a nested message loop in which the context group is torn down and re-created, freeing the original V8ConsoleMessageStorage and allocating a new one. The old code only guarded against the message queue being cleared (hasConsoleMessageStorage merely checked existence), so it re-entered the loop and dereferenced the stale storage pointer and its messages(), a use-after-free.
The fix adds V8InspectorImpl::consoleMessageStorage, which returns the current storage pointer (or nullptr), and re-checks on every iteration that the live storage still equals the cached storage, breaking out if identity changed. This closes the window because a destroyed-and-recreated storage yields a different pointer, so the loop stops before touching freed memory.
hasConsoleMessageStorage) could not detect a destroy-and-recreate across a nested message loop; the fix replaces that with a pointer-identity check against the cached storage.Attack Path
- Open an inspector session
An attacker with a debugging channel (or a compromised renderer driving the inspector) triggers console message reporting through
V8ConsoleAgentImpl::enableorV8RuntimeAgentImpl::reportMessages. - Queue a message with a JS getter
A console message whose arguments require formatting causes
reportMessage/wrapArgumentsto invoke attacker-controlled JS during iteration over the cachedstorage. - Spin a nested message loop
The JS executes a
debuggerstatement, entering a nested message loop where inspector teardown/re-init runs. - Free and recreate the storage
The context group’s
V8ConsoleMessageStorageis destroyed and a fresh one allocated, leaving the outer loop’sstoragepointer dangling. - Dereference freed storage
Control returns to the loop, which reads
storage->messages()on the freed object, yielding a use-after-free that can be groomed for control-flow hijack.
Impact Assessment
V8ConsoleMessageStorage inside the renderer/V8 process, which can be leveraged for memory corruption and potentially remote code execution in the sandboxed process. The precondition is an active inspector/console reporting path plus the ability to run JS that hits a debugger statement (e.g., DevTools attached or an equivalent inspector consumer), so the nested message loop can destroy and recreate the storage mid-iteration.Changed Functions
| Function | Change | Notes |
|---|---|---|
forsrc/inspector/v8-console-agent-impl.cc |
modified | |
ifsrc/inspector/v8-console-message.cc |
modified | |
forsrc/inspector/v8-runtime-agent-impl.cc |
modified |
Files Changed
src/inspector/v8-console-agent-impl.ccsrc/inspector/v8-console-message.ccsrc/inspector/v8-inspector-impl.ccsrc/inspector/v8-inspector-impl.hsrc/inspector/v8-runtime-agent-impl.cc
Audit Directions
- Cached raw pointers across reentrancyAudit any inspector or V8 code that caches a raw pointer or reference to a map-owned object (like
m_consoleStorageMapentries) and then invokes JS, callbacks, or nested message loops before reusing it. - Existence vs identity checksLook for guards that only test whether an object still exists (e.g.,
has*/contains) after reentrant JS, where the object may have been freed and recreated, and require an identity or generation check instead. - `debugger`-driven nested loopsTrace every path reachable from
reportMessage/wrapArgumentsand other formatting hooks that can run getters, and verify state fetched before the call is re-validated after anyPostponeInterruptsScopeor nested pump completes.
Patch
From 1dba9760bf9601b35fe0f1ab07c9f228ef554cff Mon Sep 17 00:00:00 2001
From: Kim-Anh Tran <kimanh@chromium.org>
Date: Mon, 18 May 2026 01:41:17 -0700
Subject: [PATCH] Fix potential UAF when accessing V8ConsoleMessageStorage
During console message handling a V8ConsoleMessageStorage may be removed
and recreated in a nested message loop (kicked off by a 'debugger' call
in JS code), leading to a UAF if we don't check the identity of the
storage object itself.
This adds the identity checks for storage whenever JS may have changed
the storage object.
Bug: 511255112
Change-Id: I121eebd536f5e36f2df19467e0cb996a0f8962ac
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7853603
Reviewed-by: Benedikt Meurer <bmeurer@chromium.org>
Commit-Queue: Benedikt Meurer <bmeurer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#107470}
---
diff --git a/src/inspector/v8-console-agent-impl.cc b/src/inspector/v8-console-agent-impl.cc
index 8a79d9c..1fc082c 100644
--- a/src/inspector/v8-console-agent-impl.cc
+++ b/src/inspector/v8-console-agent-impl.cc
@@ -62,11 +62,16 @@
m_session->contextGroupId());
// The message queue can be cleared by a getter during message formatting.
// Make a copy of the message to avoid a UAF.
- const auto& messages = storage->messages();
- const size_t size = messages.size();
+ // Also, the storage itself can be destroyed and recreated, so re-fetch the
+ // storage on each iteration.
+ size_t size = storage->messages().size();
for (size_t i = 0; i < size; ++i) {
- if (i >= messages.size()) break;
- V8ConsoleMessage message = *messages[i];
+ if (m_session->inspector()->consoleMessageStorage(
+ m_session->contextGroupId()) != storage) {
+ break;
+ }
+ if (i >= storage->messages().size()) break;
+ V8ConsoleMessage message = *storage->messages()[i];
if (!reportMessage(&message, false)) {
break;
}
diff --git a/src/inspector/v8-console-message.cc b/src/inspector/v8-console-message.cc
index 66d12ad..f6993b0 100644
--- a/src/inspector/v8-console-message.cc
+++ b/src/inspector/v8-console-message.cc
@@ -321,8 +321,11 @@
// Protect against reentrant debugger calls via interrupts.
v8::debug::PostponeInterruptsScope no_interrupts(inspector->isolate());
+ V8ConsoleMessageStorage* storage =
+ inspector->consoleMessageStorage(contextGroupId);
+ if (!storage) return;
+
if (m_origin == V8MessageOrigin::kException) {
- if (!inspector->hasConsoleMessageStorage(contextGroupId)) return;
v8::HandleScope scope(inspector->isolate());
auto maybeScriptOrigin =
v8::debug::GetScriptOrigin(inspector->isolate(), m_scriptId);
@@ -367,7 +370,7 @@
if (m_origin == V8MessageOrigin::kConsole) {
std::unique_ptr<protocol::Array<protocol::Runtime::RemoteObject>>
arguments = wrapArguments(session, generatePreview);
- if (!inspector->hasConsoleMessageStorage(contextGroupId)) return;
+ if (inspector->consoleMessageStorage(contextGroupId) != storage) return;
if (!arguments) {
arguments =
std::make_unique<protocol::Array<protocol::Runtime::RemoteObject>>();
diff --git a/src/inspector/v8-inspector-impl.cc b/src/inspector/v8-inspector-impl.cc
index 34be36d..77bd811 100644
--- a/src/inspector/v8-inspector-impl.cc
+++ b/src/inspector/v8-inspector-impl.cc
@@ -138,6 +138,15 @@
return storageIt->second.get();
}
+V8ConsoleMessageStorage* V8InspectorImpl::consoleMessageStorage(
+ int contextGroupId) {
+ auto storageIt = m_consoleStorageMap.find(contextGroupId);
+ if (storageIt == m_consoleStorageMap.end()) {
+ return nullptr;
+ }
+ return storageIt->second.get();
+}
+
bool V8InspectorImpl::hasConsoleMessageStorage(int contextGroupId) {
auto storageIt = m_consoleStorageMap.find(contextGroupId);
return storageIt != m_consoleStorageMap.end();
diff --git a/src/inspector/v8-inspector-impl.h b/src/inspector/v8-inspector-impl.h
index cf4e63c..f0e9ab4 100644
--- a/src/inspector/v8-inspector-impl.h
+++ b/src/inspector/v8-inspector-impl.h
@@ -133,6 +133,7 @@
void muteExceptions(int contextGroupId);
void unmuteExceptions(int contextGroupId);
V8ConsoleMessageStorage* ensureConsoleMessageStorage(int contextGroupId);
+ V8ConsoleMessageStorage* consoleMessageStorage(int contextGroupId);
bool hasConsoleMessageStorage(int contextGroupId);
void discardInspectedContext(int contextGroupId, int contextId);
void disconnect(V8InspectorSessionImpl*);
diff --git a/src/inspector/v8-runtime-agent-impl.cc b/src/inspector/v8-runtime-agent-impl.cc
index 226a6c7..e7a003b 100644
--- a/src/inspector/v8-runtime-agent-impl.cc
+++ b/src/inspector/v8-runtime-agent-impl.cc
@@ -1125,11 +1125,16 @@
m_inspector->ensureConsoleMessageStorage(m_session->contextGroupId());
// The message queue can be cleared by a getter during message formatting.
// Make a copy of the message to avoid a UAF.
- const auto& messages = storage->messages();
- const size_t size = messages.size();
+ // Also, the storage itself can be destroyed and recreated, so re-fetch the
+ // storage on each iteration.
+ size_t size = storage->messages().size();
for (size_t i = 0; i < size; ++i) {
- if (i >= messages.size()) break;
- V8ConsoleMessage message = *messages[i];
+ if (m_inspector->consoleMessageStorage(m_session->contextGroupId()) !=
+ storage) {
+ break;
+ }
+ if (i >= storage->messages().size()) break;
+ V8ConsoleMessage message = *storage->messages()[i];
if (!reportMessage(&message, false)) {
break;
}
Original Bug Report
UAF in V8 Inspector via identity-blind hasConsoleMessageStorage bypass
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: A potential Use-After-Free exists in V8 Inspector’s console message handling when DevTools is open. An attacker could bypass the hasConsoleMessageStorage liveness guard by using a debugger; statement in a custom object getter to trigger a nested message loop. During this pause, an attacker-initiated navigation can destroy the original V8ConsoleMessageStorage and recreate a new one, leading to UAF read/write operations when the getter returns.
Affected files:
v8/src/inspector/v8-console-message.ccv8/src/inspector/v8-runtime-agent-impl.ccv8/src/inspector/v8-inspector-impl.cc
Estimated timestamp from git blame: 2026-02-24
Summary
A potential Use-After-Free (UAF) vulnerability has been identified in the V8 Inspector component. The issue stems from the hasConsoleMessageStorage guard, which is used to verify the liveness of a V8ConsoleMessageStorage instance after a JavaScript callout. This guard only checks for the presence of a context group ID in a map, failing to verify the identity of the specific storage object itself. If the storage is destroyed and then re-created for the same context group ID during a callout, the guard will return a false positive, leading to subsequent operations on a freed object.
Technical Details
There are two identified vectors for this vulnerability: a UAF write in V8ConsoleMessageStorage::addMessage and a UAF read in V8RuntimeAgentImpl::enable.
Vector 1: UAF Write in V8ConsoleMessageStorage::addMessage
V8ConsoleMessageStorage::addMessagelogs a console message and callsm_inspector->forEachSession. This triggersV8RuntimeAgentImpl::reportMessage.reportMessagecallsValueMirror::create(viawrapArgumentsorwrapException) to wrap the object. For anErrorobject,ValueMirror::createattempts to access the propertiesname,message, andstackviagetErrorProperty.- While
getErrorPropertyavoids direct user-defined getters, it falls back toobject->Get(...)if the property is not an ‘own’ property. This allows an attacker to execute custom JavaScript synchronously by placing aProxywith agettrap on the object’s prototype chain. - The attacker places a
debugger;statement inside thisgettrap. This forces a synchronous pause, spinning up a nested message loop in V8. - During this nested loop, the renderer continues to process high-priority IPCs. The attacker arranges for a navigation or frame detachment IPC to arrive at this moment. This triggers
V8InspectorImpl::resetContextGroup, which erases theV8ConsoleMessageStorageobject (this) fromm_consoleStorageMap, freeing it. - Still within the nested loop (e.g., via DevTools evaluation), the attacker logs a new message to the identical context group. This invokes
V8InspectorImpl::ensureConsoleMessageStorage, which allocates a new storage object and inserts it intom_consoleStorageMapusing the samecontextGroupId. - The attacker resumes execution, unwinding the stack back to
V8ConsoleMessageStorage::addMessage. addMessageexecutes its liveness guard:if (!inspector->hasConsoleMessageStorage(contextGroupId)) return;. Because a new object was created in Step 6, the ID exists in the map, and the guard incorrectly returnstrue.addMessageproceeds to callm_messages.push_back(...)on the freedthispointer, resulting in a UAF write.
Vector 2: UAF Read in V8RuntimeAgentImpl::enable
V8RuntimeAgentImpl::enablecaches a pointer to the storage and a reference to its message deque:const auto& messages = storage->messages();.- It iterates over this deque, calling
reportMessage. - If
reportMessagetriggers the samedebugger;nested loop sequence described above, the original storage and its deque are freed, and a new storage is created. - The
hasConsoleMessageStorageguard at the end ofreportMessagefalsely returnstrue. - The loop continues to the next iteration, accessing
messages.size()andmessages[i]using the dangling reference, leading to a UAF read.
Impact
This vulnerability could lead to memory corruption within the renderer process. An attacker could leverage the UAF write to corrupt the heap, potentially achieving Remote Code Execution (RCE) within the renderer sandbox. Exploitation requires DevTools to be open and the user to visit a malicious site.
(Note: These are suggested steps based on static analysis; our tooling agent cannot execute code to verify the exploit.)
Suggested Fix
To prevent this vulnerability, the liveness checks must verify the identity of the storage object, not just the existence of the group ID.
- For
V8ConsoleMessageStorage::addMessage: Instead of relying onhasConsoleMessageStorage(contextGroupId), capture abase::WeakPtr(or equivalent weak reference mechanism available in V8) tothisbefore theforEachSessioncall. Check if the weak pointer is still valid after the call returns. - For
V8RuntimeAgentImpl::enable: Do not cache raw references or pointers across calls that can execute JavaScript. Instead, maintain an index and fetch the storage and message at that index dynamically on each iteration, verifying the storage object hasn’t been unexpectedly swapped out or deleted.
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.