Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
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.
Tracker511255112
Fix commit1dba9760bf96 (v8/v8) +33/-10
CWECWE-416 (Use-after-free)
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-20 (public git)
Disclosed2026-06-30
Patch gap41 days — fix public in source before the advisory

Background

`V8ConsoleMessageStorage`
Per-context-group container that owns the queue of V8ConsoleMessage objects buffered for a debugging session.
`V8InspectorImpl`
The central inspector object that maps each contextGroupId to its V8ConsoleMessageStorage via m_consoleStorageMap.
Nested message loop
A reentrant execution pump triggered when JS hits a debugger statement, 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.

Key insight
The core mistake was treating “storage still exists” as equivalent to “storage is still the same object,” so an existence check (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

  1. Open an inspector session An attacker with a debugging channel (or a compromised renderer driving the inspector) triggers console message reporting through V8ConsoleAgentImpl::enable or V8RuntimeAgentImpl::reportMessages.
  2. Queue a message with a JS getter A console message whose arguments require formatting causes reportMessage/wrapArguments to invoke attacker-controlled JS during iteration over the cached storage.
  3. Spin a nested message loop The JS executes a debugger statement, entering a nested message loop where inspector teardown/re-init runs.
  4. Free and recreate the storage The context group’s V8ConsoleMessageStorage is destroyed and a fresh one allocated, leaving the outer loop’s storage pointer dangling.
  5. 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

An attacker gains a use-after-free on the freed 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

FunctionChangeNotes
for
src/inspector/v8-console-agent-impl.cc
modified
if
src/inspector/v8-console-message.cc
modified
for
src/inspector/v8-runtime-agent-impl.cc
modified

Files Changed

  • src/inspector/v8-console-agent-impl.cc
  • src/inspector/v8-console-message.cc
  • src/inspector/v8-inspector-impl.cc
  • src/inspector/v8-inspector-impl.h
  • src/inspector/v8-runtime-agent-impl.cc

Audit Directions

  • Cached raw pointers across reentrancy
    Audit any inspector or V8 code that caches a raw pointer or reference to a map-owned object (like m_consoleStorageMap entries) and then invokes JS, callbacks, or nested message loops before reusing it.
  • Existence vs identity checks
    Look 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 loops
    Trace every path reachable from reportMessage/wrapArguments and other formatting hooks that can run getters, and verify state fetched before the call is re-validated after any PostponeInterruptsScope or nested pump completes.
From 1dba9760bf9601b35fe0f1ab07c9f228ef554cff Mon Sep 17 00:00:00 2001
From: Kim-Anh Tran <[email protected]>
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 <[email protected]>
Commit-Queue: Benedikt Meurer <[email protected]>
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;
     }
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • v8/src/inspector/v8-runtime-agent-impl.cc
  • v8/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

  1. V8ConsoleMessageStorage::addMessage logs a console message and calls m_inspector->forEachSession. This triggers V8RuntimeAgentImpl::reportMessage.
  2. reportMessage calls ValueMirror::create (via wrapArguments or wrapException) to wrap the object. For an Error object, ValueMirror::create attempts to access the properties name, message, and stack via getErrorProperty.
  3. While getErrorProperty avoids direct user-defined getters, it falls back to object->Get(...) if the property is not an ‘own’ property. This allows an attacker to execute custom JavaScript synchronously by placing a Proxy with a get trap on the object’s prototype chain.
  4. The attacker places a debugger; statement inside this get trap. This forces a synchronous pause, spinning up a nested message loop in V8.
  5. 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 the V8ConsoleMessageStorage object (this) from m_consoleStorageMap, freeing it.
  6. 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 into m_consoleStorageMap using the same contextGroupId.
  7. The attacker resumes execution, unwinding the stack back to V8ConsoleMessageStorage::addMessage.
  8. addMessage executes 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 returns true.
  9. addMessage proceeds to call m_messages.push_back(...) on the freed this pointer, resulting in a UAF write.

Vector 2: UAF Read in V8RuntimeAgentImpl::enable

  1. V8RuntimeAgentImpl::enable caches a pointer to the storage and a reference to its message deque: const auto& messages = storage->messages();.
  2. It iterates over this deque, calling reportMessage.
  3. If reportMessage triggers the same debugger; nested loop sequence described above, the original storage and its deque are freed, and a new storage is created.
  4. The hasConsoleMessageStorage guard at the end of reportMessage falsely returns true.
  5. The loop continues to the next iteration, accessing messages.size() and messages[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.

  1. For V8ConsoleMessageStorage::addMessage: Instead of relying on hasConsoleMessageStorage(contextGroupId), capture a base::WeakPtr (or equivalent weak reference mechanism available in V8) to this before the forEachSession call. Check if the weak pointer is still valid after the call returns.
  2. 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.

View on issue tracker