Overview

High
Severity
7.5
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.
Tracker517981277
Fix commit3c3aab471d2b (v8/v8) +50/-5
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedywatanabee
Reported2026-05-30 · bounty $1000 (per vendor advisory)
Fix landed2026-06-08 (public git)
Disclosed2026-06-30
Patch gap22 days — fix public in source before the advisory

Background

`V8InspectorImpl::contextCreated`
the inspector entry point that registers a newly created execution context and, per CDP semantics, re-adds persisted bindings to it across every inspector session.
`InspectedContext`
the heap object that wraps a JS execution context for the inspector; it is stored in the contextById map and can be torn down by contextDestroyed.
`addBindings` / prototype hijack
installing a CDP binding assigns a property on the global object, which can invoke a JS set accessor placed on Object.prototype, letting attacker JS run synchronously during binding installation.
`forEachSession`
iterates over all V8InspectorSessionImpl objects in a context group, invoking a callback per session.

Root Cause Analysis

In contextCreated, after inserting the new InspectedContext into contextById, the pre-patch code called forEachSession with a lambda capturing the raw context pointer and, for every session, called session->runtimeAgent()->addBindings(context) followed by session->runtimeAgent()->reportExecutionContextCreated(context). Installing a binding sets a property on the global object, so an attacker who has hijacked Object.prototype with a set accessor can synchronously call inspector.fireContextDestroyed() during addBindings, which runs contextDestroyed and frees the InspectedContext while it is still referenced. Control then returned to the loop, which continued using the freed context pointer for the remaining sessions and for reportExecutionContextCreated, a use-after-free.

The fix captures a std::shared_ptr<InspectedContext> contextRef = (*contextById)[contextId] to keep the object alive across the iteration and re-checks if (!getContext(contextGroupId, contextId)) return; both before addBindings(contextRef.get()) and before reportExecutionContextCreated(contextRef.get()), so once the context is destroyed the callback bails out instead of operating on it. The shared_ptr prevents the raw dereference from touching freed memory, and the two getContext guards prevent operating on a logically destroyed context both within a session and when the loop advances to other sessions.

Key insight
The core mistake was holding and reusing a raw pointer to the InspectedContext across addBindings, a call that can re-enter attacker JS and synchronously destroy that very context; the fix keeps the object alive with a shared_ptr and re-validates its existence via getContext before each subsequent operation.

Attack Path

  1. Attach inspector and register bindings A client with an inspector session enables Runtime and calls Runtime.addBinding for names such as x and y, so bindings will be re-added whenever a context is created.
  2. Plant a prototype setter Attacker JS does delete globalThis.x and Object.defineProperty(Object.prototype, 'x', { set: function(v){ inspector.fireContextDestroyed(); } }) so that writing the binding property invokes attacker code.
  3. Trigger context creation Attacker JS calls inspector.fireContextCreated(), driving contextCreated into forEachSession and then addBindings, whose property write hits the hijacked setter.
  4. Free during binding The setter calls fireContextDestroyed, running contextDestroyed and freeing the just-registered InspectedContext while the enclosing loop still holds the raw context pointer.
  5. Use after free Control returns to the loop, which continues to reportExecutionContextCreated(context) and to the next session using the freed pointer, the UAF the patch closes. Any escalation beyond the freed-object access is (inferred).

Impact Assessment

An attacker able to run script in a context that is being inspected can free an InspectedContext mid-iteration and cause the inspector to dereference the freed object, a use-after-free in the process hosting V8/the inspector (renderer or embedder with an active inspector session). The precondition is an attached inspector session with a registered binding plus attacker-controlled JS that hijacks Object.prototype, as encoded in the shipped regression test regress-517981277.js. The advisory classifies this as UAF / “Use after free in V8”; it states no exploited-in-the-wild status, and exploitation beyond the freed-pointer access demonstrated by the patch is (inferred).

Files Changed

  • src/inspector/v8-inspector-impl.cc
  • test/inspector/runtime/regress-517981277-expected.txt
  • test/inspector/runtime/regress-517981277.js

Audit Directions

  • Re-entrant callbacks during binding/property installation
    Audit inspector code that writes properties on the global object (bindings, injected scripts) while holding raw pointers to InspectedContext or agent state, since property writes can invoke attacker JS synchronously.
  • Raw pointers across `forEachSession` loops
    Review callbacks passed to forEachSession/similar iterators that capture raw context or session pointers and operate on them after a call that can re-enter script; prefer shared_ptr plus a liveness re-check via getContext.
  • Object lifetime vs. `contextById` mutation
    Look for other paths that read (*contextById)[contextId] once and reuse it after calls that may run contextDestroyed, and ensure they revalidate the map entry.
From 3c3aab471d2b291626328169e988e4846127e751 Mon Sep 17 00:00:00 2001
From: Yulun Zeng <[email protected]>
Date: Thu, 04 Jun 2026 22:35:27 +0000
Subject: [PATCH] Handle context destruction when adding binding within V8InspectorImpl::contextCreated.

CDP specifies that the bindings must persist across reloads, and thus in
V8InspectorImpl::contextCreated(), bindings are added again when new
contexts are created. The issue arises when context is destroyed when
adding binding (as the regress test shows), but the context is still
being processed down the code path.

This change skips operations on the context if it has been destroyed
during adding binding.

There is the same check before addBindings() because operations on the
context have to skipped too when it loops to other inspector sessions.

Also, `contextRef` is a `std::shared_ptr` to keep it alive when looping
through inspector sessions. This allows getting the context ID on the
object for other addBinding() calls within addBindings().

Bug: 517981277
Change-Id: Ic1ee273c316040acc611f6c53349b734076f7f14
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7904365
Reviewed-by: Simon Zünd <[email protected]>
Reviewed-by: Philip Pfaffe <[email protected]>
Commit-Queue: Yulun Zeng <[email protected]>
Cr-Commit-Position: refs/heads/main@{#107830}
---

diff --git a/src/inspector/v8-inspector-impl.cc b/src/inspector/v8-inspector-impl.cc
index d4ff6dc..ade7809 100644
--- a/src/inspector/v8-inspector-impl.cc
+++ b/src/inspector/v8-inspector-impl.cc
@@ -308,11 +308,15 @@
 
   DCHECK(contextById->find(contextId) == contextById->cend());
   (*contextById)[contextId].reset(context);
-  forEachSession(
-      info.contextGroupId, [&context](V8InspectorSessionImpl* session) {
-        session->runtimeAgent()->addBindings(context);
-        session->runtimeAgent()->reportExecutionContextCreated(context);
-      });
+  int contextGroupId = info.contextGroupId;
+  std::shared_ptr<InspectedContext> contextRef = (*contextById)[contextId];
+  forEachSession(contextGroupId, [this, contextGroupId, contextId,
+                                  contextRef](V8InspectorSessionImpl* session) {
+    if (!getContext(contextGroupId, contextId)) return;
+    session->runtimeAgent()->addBindings(contextRef.get());
+    if (!getContext(contextGroupId, contextId)) return;
+    session->runtimeAgent()->reportExecutionContextCreated(contextRef.get());
+  });
 }
 
 void V8InspectorImpl::contextDestroyed(v8::Local<v8::Context> context) {
diff --git a/test/inspector/runtime/regress-517981277-expected.txt b/test/inspector/runtime/regress-517981277-expected.txt
new file mode 100644
index 0000000..d3928e7
--- /dev/null
+++ b/test/inspector/runtime/regress-517981277-expected.txt
@@ -0,0 +1,3 @@
+Test that destroying context during addBinding does not cause UAF (regress-517981277).
+Triggering evaluate with prototype hijack and context creation...
+Finished evaluate call.
diff --git a/test/inspector/runtime/regress-517981277.js b/test/inspector/runtime/regress-517981277.js
new file mode 100644
index 0000000..a384384
--- /dev/null
+++ b/test/inspector/runtime/regress-517981277.js
@@ -0,0 +1,38 @@
+// 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-517981277).');
+
+(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;
+      Object.defineProperty(Object.prototype, 'x', {
+        configurable: true,
+        set: function(v) {
+          // Synchronously destroy the just-registered InspectedContext.
+          inspector.fireContextDestroyed();
+        }
+      });
+      // === TRIGGER ===
+      inspector.fireContextCreated();
+    `,
+  });
+
+  InspectorTest.log('Finished evaluate call.');
+  InspectorTest.completeTest();
+})();
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/test/inspector/runtime/regress-517981277-expected.txt b/test/inspector/runtime/regress-517981277-expected.txt
new file mode 100644
index 0000000..d3928e7
--- /dev/null
+++ b/test/inspector/runtime/regress-517981277-expected.txt
@@ -0,0 +1,3 @@
+Test that destroying context during addBinding does not cause UAF (regress-517981277).
+Triggering evaluate with prototype hijack and context creation...
+Finished evaluate call.
diff --git a/test/inspector/runtime/regress-517981277.js b/test/inspector/runtime/regress-517981277.js
new file mode 100644
index 0000000..a384384
--- /dev/null
+++ b/test/inspector/runtime/regress-517981277.js
@@ -0,0 +1,38 @@
+// 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-517981277).');
+
+(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;
+      Object.defineProperty(Object.prototype, 'x', {
+        configurable: true,
+        set: function(v) {
+          // Synchronously destroy the just-registered InspectedContext.
+          inspector.fireContextDestroyed();
+        }
+      });
+      // === TRIGGER ===
+      inspector.fireContextCreated();
+    `,
+  });
+
+  InspectorTest.log('Finished evaluate call.');
+  InspectorTest.completeTest();
+})();
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Free in V8 Inspector InspectedContext yields Arbitrary Write Outside V8 Sandbox Cage

Steps to reproduce the problem

Environment

  • V8 source: 15.0.0 (Chromium-tracked)
  • Tested commit: 6ac60d22f45 (HEAD as of 2026-05-30)
  • Platform: Linux x86_64

Required builds

Build A (ASan + sandbox + memory-corruption-api): out_asan/inspector-test

args.gn:

is_debug = false
dcheck_always_on = true
v8_enable_sandbox = true
v8_enable_memory_corruption_api = true
v8_enable_test_features = true
v8_enable_verify_heap = true
is_asan = true
target_cpu = "x64"

Build B (Non-ASan, sandbox + memory-corruption-api): out_sandbox/inspector-test

Same args.gn but is_asan = false.

Build commands:

gn gen out_asan
gn gen out_sandbox
autoninja -C out_asan inspector-test
autoninja -C out_sandbox inspector-test

Attached PoCs

  • poc-asan.js (ASan UAF reproduction)
  • poc-sandbox-violation.js (sandbox bypass with WRITE classification)

Step 1: ASan UAF reproduction

Command (from V8 source root):

ASAN_OPTIONS='abort_on_error=0:halt_on_error=1:detect_leaks=0' \
  ./out_asan/inspector-test \
  test/inspector/protocol-test.js \
  poc-asan.js

Expected stderr (key sections):

==XXXXX==ERROR: AddressSanitizer: heap-use-after-free
  on address 0xXXXX...
READ of size 8 at 0xXXXX thread T7 (Task Runner)
  #0 in std::__Cr::__hash_table<int, ...>::__emplace_unique<...>
     lambda at __hash_table:531:67
  #1 in v8_inspector::InspectedContext::setReported
     try_key_extraction.h:44:10
  #2 in v8_inspector::V8RuntimeAgentImpl::reportExecutionContextCreated
     src/inspector/v8-runtime-agent-impl.cc:1190:12
  #3 in v8_inspector::V8InspectorImpl::forEachSession
  #4 in v8_inspector::V8InspectorImpl::contextCreated
     src/inspector/v8-inspector-impl.cc:308:3
  ...

0xXXXX is located 144 bytes inside of 232-byte region [0xXXXX, 0xXXXX)
freed by thread T7:
  #0 operator delete(void*, unsigned long)
  #1 V8InspectorImpl::contextCollected
  ...
previously allocated by thread T7:
  #0 operator new(unsigned long)
  #1 V8InspectorImpl::contextCreated
     src/inspector/v8-inspector-impl.cc:288
  ...

Verification: ASan reports heap-use-after-free on a 232-byte region; the freed object is the InspectedContext allocated at v8-inspector-impl.cc:288; the read is at offset +144 reached from setReported → reportExecutionContextCreated.

Step 2: WRITE-classification sandbox violation

Command:

UBSAN_OPTIONS='print_stacktrace=0:halt_on_error=0:abort_on_error=0:handle_segv=0:handle_sigbus=0:handle_sigill=0' \
  setarch -R \
  ./out_sandbox/inspector-test \
  --sandbox-testing \
  test/inspector/protocol-test.js \
  poc-sandbox-violation.js

Flag notes:

  • setarch -R disables ASLR so the target binary loads at the fixed PIE base 0x555555554000+. A small r–p mapping at file offset 0x07f03000 (memory 0x55555d45a000-0x55555d45b000) whose first 16 bytes are zero is used as the WRITE target.
  • --sandbox-testing enables the V8 sandbox crash filter (src/sandbox/testing.cc).
  • UBSAN_OPTIONS handle_segv=0 prevents UBSan’s signal handler from preempting the sandbox crash filter so the filter can classify the access type.

Expected stderr:

Sandbox testing mode is enabled. Only sandbox violations will be
  reported, all other crashes will be ignored.
Sandbox bounds: [0xXXXXXXXXXXXX, 0xXXXXXXXXXXXX)
...

## V8 sandbox violation detected!

(Process exits with signal 11 / SIGSEGV)

Verification: The crash filter prints ## V8 sandbox violation detected! with NO “read access” caveat following. Per src/sandbox/testing.cc:1209-1213, that caveat is added only for read access; its absence confirms WRITE access classification via the SIGSEGV error code’s kWriteAccessBit checked at testing.cc:898-907 GetAccessType. This satisfies docs/security/triaging.md’s requirement: “A successful bypass must show write access outside of the sandbox.”

Step 3: Determinism check

Command (bash):

for i in 1 2 3 4 5; do
  echo "--- Attempt $i ---"
  UBSAN_OPTIONS='print_stacktrace=0:halt_on_error=0:abort_on_error=0:handle_segv=0:handle_sigbus=0:handle_sigill=0' \
    timeout 15 setarch -R \
    ./out_sandbox/inspector-test --sandbox-testing \
    test/inspector/protocol-test.js \
    poc-sandbox-violation.js 2>&1 \
    | grep -A2 "V8 sandbox" | head -5
done

Problem Description

Summary

Use-after-free on InspectedContext (sizeof=232) allocated on the libc C++ heap OUTSIDE the V8 sandbox cage. The freed object is dereferenced inside the contextCreated lambda’s call to reportExecutionContextCreated. The attached PoCs demonstrate the bug and show that successful exploitation yields an Arbitrary Address Write (AAW) primitive outside the V8 sandbox cage.

Affected code

  • src/inspector/v8-inspector-impl.cc:308-312 (contextCreated lambda)
  • src/inspector/v8-runtime-agent-impl.cc:1187-1207 (reportExecutionContextCreated)
  • src/inspector/inspected-context.cc:128-134 (setReported)

Attacker model

web-content. Reachable from:

  1. Any user-attached DevTools session sending Runtime.addBinding + Runtime.evaluate.
  2. Any Chrome extension with the debugger permission via chrome.debugger.attach() + Runtime.addBinding({name:"x"}) + Runtime.evaluate().

No pre-existing memory primitive is assumed.

Fix-sibling

Commit 5a7de4a07b0128358a40117a51c5bfacf7f76c99 (“Fix Use-After-Free in Runtime.addBinding()”) added an internal re-validation inside addBinding after global->Set(), but did NOT protect the immediate-next call site reportExecutionContextCreated inside the contextCreated lambda. This is the classic fix-sibling pattern: the leaf function is patched but the caller scope still ferries the dangling pointer.

Root cause

v8-inspector-impl.cc:308-312:

forEachSession(info.contextGroupId,
    [&context](V8InspectorSessionImpl* s) {
      s->runtimeAgent()->addBindings(context);
      s->runtimeAgent()->reportExecutionContextCreated(context);
    });

The lambda captures raw InspectedContext* context and uses it twice:

  1. addBindings(ctx) ultimately calls addBinding(ctx, name) which performs global->Set(localContext, v8Name, fn) at line 996. This is JS-reentrant: a setter installed on Object.prototype[name] fires here.
  2. reportExecutionContextCreated(ctx) immediately after.

Inside the Set() trap, attacker JS calls inspector.fireContextDestroyed() (mimics the production frame-detach path). This drives:

V8Inspector::contextDestroyed
  -> V8InspectorImpl::contextCollected
  -> discardInspectedContext

which erases the only shared_ptr<InspectedContext> from m_contexts. The local shared_ptr in contextCollected goes out of scope at function exit; ~InspectedContext runs inline, freeing the 232-byte slot via operator delete (libc malloc allocator — this is OUTSIDE the V8 sandbox cage).

Control returns to addBindings. The 5a7de4a07b0 inner-fix check at line 1001 returns early. addBindings’s loop exits cleanly. Then the OUTER LAMBDA continues to reportExecutionContextCreated(freed_ctx) — UAF.

Impact

The attached PoCs achieve the following:

  1. ASan-confirmed heap-use-after-free read on the freed InspectedContext (see poc-asan.log).

  2. Under --sandbox-testing, the V8 sandbox crash filter reports ## V8 sandbox violation detected! classified as WRITE access (no “read access” caveat). This empirically demonstrates an Arbitrary Address Write (AAW) primitive: attacker-controlled bytes sprayed into the freed slot dictate the target address of a WRITE instruction executed at a canonical address outside the V8 sandbox cage. See poc-sandbox-violation.log.

Evidence

Two attached PoCs:

(1) poc-asan.js + poc-asan.log — reproduces the UAF under AddressSanitizer.

Signature: heap-use-after-free READ of size 8 at +144 bytes of freed InspectedContext inside libc++ __hash_table, called from InspectedContext::setReported reached via V8RuntimeAgentImpl::reportExecutionContextCreated at v8-runtime-agent-impl.cc:1190. The free site is V8InspectorImpl::contextCollected; the allocation site is V8InspectorImpl::contextCreated at v8-inspector-impl.cc:288.

(2) poc-sandbox-violation.js + poc-sandbox-violation.log — demonstrates the AAW primitive under --sandbox-testing.

Output: ## V8 sandbox violation detected! with NO “read access” caveat. The absence of that caveat (added at src/sandbox/testing.cc:1209-1213 only for read access) confirms WRITE access classification via the SIGSEGV error code’s kWriteAccessBit (testing.cc:898-907 GetAccessType).

ASLR / production notes

The sandbox-violation PoC uses setarch -R to fix the target address at a known mapped read-only location so the WRITE classification is deterministically observable. The AAW primitive itself (attacker-controlled write target via heap spray) is independent of ASLR; production attacks would defeat ASLR through standard exploitation techniques.

Summary

Use-After-Free in V8 Inspector InspectedContext yields Arbitrary Write Outside V8 Sandbox Cage

Custom Questions

Type of crash:

tab

Reporter credit:

ywatanabee

Additional Data

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

View on issue tracker