Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Bug ClassUAF
Tracker502784366
Fix commiteed401bc6cc2 (v8/v8) +208/-75
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
DisallowGarbageCollectionScope
src/debug/debug-interface.cc
modified
V8_NODISCARD
src/debug/debug-interface.h
modified
for
src/inspector/v8-debugger-agent-impl.cc
modified
if
src/inspector/v8-debugger-agent-impl.cc
modified

Files Changed

  • src/debug/debug-interface.cc
  • src/debug/debug-interface.h
  • src/inspector/v8-debugger-agent-impl.cc
From eed401bc6cc222111dc95b112f1edbb6f10a1847 Mon Sep 17 00:00:00 2001
From: Danil Somsikov <[email protected]>
Date: Thu, 30 Apr 2026 04:26:21 -0700
Subject: [PATCH] Avoid holding raw script pointers across potential GC events.

The changes modify how breakpoints are set and removed by:
-   Collecting script IDs instead of raw V8DebuggerScript pointers before iterating.
-   Re-looking up scripts in the `m_scripts` map within loops, especially after calls that might trigger garbage collection and invalidate pointers or iterators.
-   Updating `removeBreakpointImpl` to accept a vector of script IDs instead of script pointers.

Bug: 502784366
Change-Id: I940b27de9b45b176b39d56bbe8abcd6d4e8a35f6
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7785931
Auto-Submit: Danil Somsikov <[email protected]>
Commit-Queue: Danil Somsikov <[email protected]>
Reviewed-by: Yang Guo <[email protected]>
Reviewed-by: Simon Zünd <[email protected]>
Cr-Commit-Position: refs/heads/main@{#106998}
---

diff --git a/src/debug/debug-interface.cc b/src/debug/debug-interface.cc
index 4233053..b69dad0 100644
--- a/src/debug/debug-interface.cc
+++ b/src/debug/debug-interface.cc
@@ -1350,6 +1350,16 @@
 
 PostponeInterruptsScope::~PostponeInterruptsScope() = default;
 
+DisallowGarbageCollectionScope::DisallowGarbageCollectionScope() {
+  new (internal_) i::DisallowGarbageCollectionInRelease();
+}
+
+DisallowGarbageCollectionScope::~DisallowGarbageCollectionScope() {
+  using i::DisallowGarbageCollectionInRelease;
+  reinterpret_cast<DisallowGarbageCollectionInRelease*>(internal_)
+      ->~DisallowGarbageCollectionInRelease();
+}
+
 DisableBreakScope::DisableBreakScope(v8::Isolate* isolate)
     : scope_(std::make_unique<i::DisableBreak>(
           reinterpret_cast<i::Isolate*>(isolate)->debug())) {}
diff --git a/src/debug/debug-interface.h b/src/debug/debug-interface.h
index 0677ea0..7891d49 100644
--- a/src/debug/debug-interface.h
+++ b/src/debug/debug-interface.h
@@ -596,6 +596,16 @@
   std::unique_ptr<i::PostponeInterruptsScope> scope_;
 };
 
+class V8_NODISCARD DisallowGarbageCollectionScope {
+ public:
+  DisallowGarbageCollectionScope();
+  ~DisallowGarbageCollectionScope();
+
+ private:
+  alignas(internal::Internals::kDisallowGarbageCollectionAlign) char internal_
+      [internal::Internals::kDisallowGarbageCollectionSize];
+};
+
 class V8_NODISCARD DisableBreakScope {
  public:
   explicit DisableBreakScope(v8::Isolate* isolate);
diff --git a/src/inspector/v8-debugger-agent-impl.cc b/src/inspector/v8-debugger-agent-impl.cc
index f7c9783..50e59b5 100644
--- a/src/inspector/v8-debugger-agent-impl.cc
+++ b/src/inspector/v8-debugger-agent-impl.cc
@@ -688,29 +688,59 @@
         "Breakpoint at specified location already exists.");
   }
 
+  std::vector<String16> allScriptIds;
+  allScriptIds.reserve(m_scripts.size());
+  for (const auto& scriptIter : m_scripts) {
+    allScriptIds.push_back(scriptIter.first);
+  }
+
   std::unique_ptr<protocol::DictionaryValue> hint;
-  for (const auto& script : m_scripts) {
-    if (!matcher.matches(*script.second)) continue;
-    // Make sure the session was not disabled by some re-entrant call
-    // in the script matcher.
-    DCHECK(enabled());
-    int adjustedLineNumber = lineNumber;
-    int adjustedColumnNumber = columnNumber;
-    if (hint) {
-      adjustBreakpointLocation(*script.second, hint.get(), &adjustedLineNumber,
-                               &adjustedColumnNumber);
+  for (const auto& scriptId : allScriptIds) {
+    std::shared_ptr<V8DebuggerScript> script;
+    {
+      v8::debug::DisallowGarbageCollectionScope no_gc;
+      if (getScriptById(scriptId, no_gc)) {
+        script = m_scripts.at(scriptId);
+      }
     }
-    std::unique_ptr<protocol::Debugger::Location> location =
-        setBreakpointImpl(breakpointId, script.first, condition,
-                          adjustedLineNumber, adjustedColumnNumber);
+    if (!script) continue;
+
+    bool isMatch = matcher.matches(*script);
+
+    if (!isMatch) continue;  // Check Match First
+
     if (!enabled()) {
       return Response::ServerError(
           "Debugger domain disabled during setBreakpoint");
     }
+
+    int adjustedLineNumber = lineNumber;
+    int adjustedColumnNumber = columnNumber;
+    if (hint) {
+      adjustBreakpointLocation(*script, hint.get(), &adjustedLineNumber,
+                               &adjustedColumnNumber);
+    }
+    std::unique_ptr<protocol::Debugger::Location> location =
+        setBreakpointImpl(breakpointId, scriptId, condition, adjustedLineNumber,
+                          adjustedColumnNumber);
+    if (!enabled()) {
+      return Response::ServerError(
+          "Debugger domain disabled during setBreakpoint");
+    }
+
+    // We need to look up the script again because setBreakpointImpl might have
+    // triggered GC
     if (location && type != BreakpointType::kByUrlRegex) {
-      hint = breakpointHint(*script.second, lineNumber, columnNumber,
-                            location->getLineNumber(),
-                            location->getColumnNumber(adjustedColumnNumber));
+      bool is_active = false;
+      {
+        v8::debug::DisallowGarbageCollectionScope no_gc;
+        is_active = getScriptById(scriptId, no_gc) != nullptr;
+      }
+      if (is_active) {
+        hint = breakpointHint(*script, lineNumber, columnNumber,
+                              location->getLineNumber(),
+                              location->getColumnNumber(adjustedColumnNumber));
+      }
     }
     if (location) (*locations)->emplace_back(std::move(location));
   }
@@ -832,16 +862,34 @@
   // Get a list of scripts to remove breakpoints.
   // TODO(duongn): we can do better here if from breakpoint id we can tell it is
   // not Wasm breakpoint.
-  std::vector<V8DebuggerScript*> scripts;
+  std::vector<String16> allScriptIds;
+  allScriptIds.reserve(m_scripts.size());
   for (const auto& scriptIter : m_scripts) {
-    const bool scriptSelectorMatch = matcher.matches(*scriptIter.second);
-    // Make sure the session was not disabled by some re-entrant call
-    // in the script matcher.
-    DCHECK(enabled());
-    const bool isInstrumentation =
-        type == BreakpointType::kInstrumentationBreakpoint;
-    if (!scriptSelectorMatch && !isInstrumentation) continue;
-    V8DebuggerScript* script = scriptIter.second.get();
+    allScriptIds.push_back(scriptIter.first);
+  }
+
+  const bool isInstrumentation =
+      type == BreakpointType::kInstrumentationBreakpoint;
+  std::vector<std::shared_ptr<V8DebuggerScript>> scripts;
+  for (const auto& scriptId : allScriptIds) {
+    std::shared_ptr<V8DebuggerScript> script;
+    {
+      v8::debug::DisallowGarbageCollectionScope no_gc;
+      if (getScriptById(scriptId, no_gc)) {
+        script = m_scripts.at(scriptId);
+      }
+    }
+    if (!script) continue;
+
+    bool isMatch = matcher.matches(*script);
+
+    if (!isMatch && !isInstrumentation) continue;  // Check Match First
+
+    if (!enabled()) {
+      return Response::ServerError(
+          "Debugger domain disabled during removeBreakpoint");
+    }
+
     if (script->getLanguage() == V8DebuggerScript::Language::WebAssembly) {
       scripts.push_back(script);
     }
@@ -853,7 +901,7 @@
 
 void V8DebuggerAgentImpl::removeBreakpointImpl(
     const String16& breakpointId,
-    const std::vector<V8DebuggerScript*>& scripts) {
+    const std::vector<std::shared_ptr<V8DebuggerScript>>& scripts) {
   DCHECK(enabled());
   BreakpointIdToDebuggerBreakpointIdsMap::iterator
       debuggerBreakpointIdsIterator =
@@ -864,7 +912,7 @@
   }
   for (const auto& id : debuggerBreakpointIdsIterator->second) {
 #if V8_ENABLE_WEBASSEMBLY
-    for (auto& script : scripts) {
Loading diff…

Original Bug Report

reported by [email protected]

Use-After-Free of V8DebuggerScript in V8 Inspector breakpoint removal

Project Fortify has identified a security issue and generated a PoC.

d8 variant: ‘Default’

flags: –enable-inspector –expose-gc –gc-interval=1000 –omit-quit –fuzzing

Return code: 134

<details>

<summary>stdout</summary>

[*] Compiling victims...
[*] Setting instrumentation breakpoint...
[*] Compiling triggers...
[*] Dropping victims...
[*] Removing breakpoint...

</details>

<details>

<summary>stderr</summary>



#
# Fatal error in ../../v8/src/api/api-inl.h, line 147
# Debug check failed: allow_empty_handle || !i::ValueHelper::IsEmpty(that).
#
#
#
#FailureMessage Object: 0x7ffe7d57a700
==== C stack trace ===============================

    /compressed/bin/Default/libv8_libbase.so(v8::base::debug::StackTrace::StackTrace()+0x1e) [0x77c69054289e]
    /compressed/bin/Default/libv8_libplatform.so(+0x163cd) [0x77c6904f23cd]
    /compressed/bin/Default/libv8_libbase.so(V8_Fatal(char const*, int, char const*, ...)+0x194) [0x77c690524b14]
    /compressed/bin/Default/libv8_libbase.so(+0x293c5) [0x77c6905243c5]
    /compressed/bin/Default/libv8.so(v8::debug::Script::RemoveWasmBreakpoint(int)+0xd8) [0x77c68c9c46f8]
    /compressed/bin/Default/libv8.so(+0x3f8c7a0) [0x77c68e58c7a0]
    /compressed/bin/Default/libv8.so(+0x3f79bcc) [0x77c68e579bcc]
    /compressed/bin/Default/libv8.so(+0x3f79679) [0x77c68e579679]
    /compressed/bin/Default/libv8.so(+0x3f3451f) [0x77c68e53451f]
    /compressed/bin/Default/libv8.so(+0x3fd5a1f) [0x77c68e5d5a1f]
    /compressed/bin/Default/libv8.so(+0x3faaef4) [0x77c68e5aaef4]
    bin/Default/d8(+0x9e611) [0x61bc38c11611]
    /compressed/bin/Default/libv8.so(+0xf94930) [0x77c68b594930]
Received signal 6
Aborted (core dumped)

</details>

Overview: A Use-After-Free vulnerability exists in the V8 Inspector when removing breakpoints across multiple WebAssembly scripts. Raw pointers to V8DebuggerScript objects are held in a local vector across a potential synchronous garbage collection point. If the scripts are collected, these pointers become dangling, leading to a UAF that can be exploited for renderer remote code execution.

Affected files:

  • v8/src/inspector/v8-debugger-agent-impl.cc
  • v8/src/debug/debug.cc
  • v8/src/inspector/v8-debugger-script.cc

Estimated timestamp from git blame: 2023-02-16

Root Cause

In v8/src/inspector/v8-debugger-agent-impl.cc, V8DebuggerAgentImpl::removeBreakpoint gathers a list of WebAssembly scripts and stores their raw pointers in a local std::vector<V8DebuggerScript*> scripts. This vector is passed to removeBreakpointImpl.

For instrumentation breakpoints (e.g., beforeScriptExecution), multiple WebAssembly scripts can be associated with a single protocol breakpoint ID. Each script appends a v8::debug::BreakpointId (which is -1 for instrumentation) to m_breakpointIdToDebuggerBreakpointIds. Consequently, the outer loop in removeBreakpointImpl executes multiple times.

Inside this loop, the code calls script->removeWasmBreakpoint(id) for each script in the vector, followed by m_debugger->removeBreakpoint(id). The vulnerability arises because m_debugger->removeBreakpoint eventually calls v8::internal::Debug::RemoveBreakpoint, which allocates a new BreakPoint object via isolate_->factory()->NewBreakPoint(...).

This heap allocation can trigger a synchronous Garbage Collection (GC) under heap pressure. If GC occurs and the underlying v8::Script objects are no longer reachable, their weak callbacks are executed. The weak callback invokes V8DebuggerAgentImpl::ScriptCollected, which erases the script from m_scripts, freeing the V8DebuggerScript C++ object. Because the local scripts vector still holds raw pointers to these freed objects, the next iteration of the outer loop will dereference a dangling pointer when calling script->removeWasmBreakpoint(id).

Impact

This Use-After-Free allows an attacker with DevTools Protocol (CDP) access (e.g., via a malicious extension) to execute arbitrary code in the context of the renderer process. The freed memory is subsequently used to instantiate a v8::HandleScope, providing a highly controllable primitive for memory corruption.

Suggested Fix

Avoid holding raw pointers to V8DebuggerScript across operations that can trigger garbage collection. Instead of populating the scripts vector with raw pointers, store their String16 script IDs.

Inside removeBreakpointImpl, dynamically look up each script ID in the agent’s m_scripts map before use:

// In removeBreakpointImpl:
for (const String16& scriptId : scriptIds) {
  auto it = m_scripts.find(scriptId);
  if (it != m_scripts.end()) {
    it->second->removeWasmBreakpoint(id);
  }
}

This ensures that if a script is collected and deleted during an earlier iteration’s GC phase, it is safely skipped rather than accessed.

Evaluated with Chrome root at commit: c0eb5541aebfa4ea08806eaf6e94bcc69f87ab2f


The description of the vuln is LLM-generated and can contain mistakes. Your feedback is appreciated, and will help us make improvement over time. The PoC was run in a VM and it seemed to be legit - if not, let us know and we can strengthen our checker.

View on issue tracker