CVE-2026-14426
Overview
Files Changed
src/inspector/v8-inspector-impl.cctest/inspector/runtime/regress-517981277-expected.txttest/inspector/runtime/regress-517981277.js
Patch
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(); +})();
Regression Test / PoC
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();
+})();
Original Bug Report
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 -Rdisables ASLR so the target binary loads at the fixed PIE base0x555555554000+. A small r–p mapping at file offset0x07f03000(memory0x55555d45a000-0x55555d45b000) whose first 16 bytes are zero is used as the WRITE target.--sandbox-testingenables the V8 sandbox crash filter (src/sandbox/testing.cc).UBSAN_OPTIONS handle_segv=0prevents 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:
- Any user-attached DevTools session sending
Runtime.addBinding+Runtime.evaluate. - Any Chrome extension with the
debuggerpermission viachrome.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:
addBindings(ctx)ultimately callsaddBinding(ctx, name)which performsglobal->Set(localContext, v8Name, fn)at line 996. This is JS-reentrant: a setter installed onObject.prototype[name]fires here.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:
-
ASan-confirmed
heap-use-after-freeread on the freedInspectedContext(seepoc-asan.log). -
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. Seepoc-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 \