Overview

Medium
Severity
8.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentPlatform>DevTools
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker502784366
Fix commiteed401bc6cc2 (v8/v8) +208/-75
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-15 (per vendor advisory)
Fix landed2026-05-04 (public git)
Disclosed2026-06-02
Patch gap29 days — fix public in source before the advisory

Background

`m_scripts`
the debugger agent’s map from script ID (String16) to std::shared_ptr<V8DebuggerScript>, owning the script objects for a session.
`V8DebuggerScript`
a heap-allocated Inspector object representing a parsed script; a raw V8DebuggerScript* becomes dangling if the owning shared_ptr in m_scripts is dropped.
Re-entrant GC / callbacks
calls such as matcher.matches, adjustBreakpointLocation, and setBreakpointImpl can trigger garbage collection or re-entrant Inspector activity that mutates m_scripts and frees scripts mid-iteration.
`DisallowGarbageCollectionScope`
a new guard (added in this patch) that forbids GC for its lifetime, used to make the “look up the script and grab a shared_ptr” step atomic with respect to collection.

Root Cause Analysis

Both the breakpoint-setting and breakpoint-removing loops iterated m_scripts directly and held raw V8DebuggerScript* pointers (or the map iterator) across calls that can trigger GC or re-enter the agent, such as matcher.matches, adjustBreakpointLocation, and setBreakpointImpl. If such a call caused a script to be removed from m_scripts (dropping its owning shared_ptr), the still-in-use raw pointer or iterator became dangling, and the subsequent dereference (*script.second, script->getLanguage(), or passing V8DebuggerScript* into removeBreakpointImpl) was a use-after-free. The invariant violated is that a V8DebuggerScript referenced across a potential GC/re-entrancy point must be kept alive by an owning reference or re-validated afterward.

The fix first snapshots the stable script IDs into a std::vector<String16> allScriptIds, then inside each iteration re-looks up the script under a DisallowGarbageCollectionScope and promotes it to a std::shared_ptr<V8DebuggerScript> (script = m_scripts.at(scriptId)), skipping it if it no longer exists. Because the loop now holds an owning shared_ptr and re-checks liveness (e.g. before building the hint) after each GC-capable call, the object cannot be freed while in use, and removeBreakpointImpl was changed to take std::vector<std::shared_ptr<V8DebuggerScript>> so ownership is retained through removal.

Key insight
The core mistake was holding raw V8DebuggerScript* pointers and a live m_scripts iterator across GC/re-entrant calls; the fix iterates over a snapshot of script IDs and re-fetches each script as an owning shared_ptr under a DisallowGarbageCollectionScope, so no raw pointer outlives the object it names.

Attack Path

  1. Register scripts and a matcher The attacker, driving the Debugger domain, has multiple scripts in m_scripts and issues a setBreakpointByUrl/removeBreakpoint-style request that iterates all scripts with a URL/regex matcher.
  2. Trigger GC or re-entrancy mid-loop During the loop, a GC-capable or re-entrant call (matcher.matches, adjustBreakpointLocation, or setBreakpointImpl) causes a V8DebuggerScript to be dropped from m_scripts, freeing it while the loop still holds the raw pointer or iterator.
  3. Use the freed script The loop dereferences the now-dangling V8DebuggerScript* (e.g. *script.second or script->getLanguage()) or passes it into removeBreakpointImpl, producing the use-after-free.
  4. Escalate via reclaimed memory Grooming the freed V8DebuggerScript allocation to reallocate attacker-controlled data could turn the dangling dereference into a controlled read/write primitive (inferred); the patch does not demonstrate this.

Impact Assessment

An attacker able to drive the Inspector/Debugger protocol (e.g. via a compromised or malicious page/DevTools-frontend context) gains a use-after-free of a V8DebuggerScript in the process hosting V8’s inspector. The advisory classifies this as a use-after-free in V8; the preconditions are an enabled Debugger domain and the ability to trigger script removal (GC or re-entrancy) during breakpoint set/remove iteration. Any escalation to controlled memory corruption or code execution is (inferred) beyond what the patch shows.

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

Audit Directions

  • Raw owner across GC/callback
    Flag loops that hold raw pointers or references obtained from a shared_ptr/owning container across any call that can trigger GC or re-enter script/agent state; require re-lookup or a retained owning reference.
  • Iterator invalidation in inspector maps
    Audit other iterations over m_scripts (and similar Inspector maps) that call matchers or protocol handlers inside the loop body, since those callbacks may mutate the container.
  • Pointer-parameter APIs into freeable objects
    Review helper functions that accept raw V8DebuggerScript* (or similar) vectors/args and outlive potential GC, converting them to shared_ptr or re-validation like removeBreakpointImpl.
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
Uploaded PoC files