CVE-2026-11185
Overview
Background
- `m_scripts`
- the debugger agent’s map from script ID (
String16) tostd::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 owningshared_ptrinm_scriptsis dropped. - Re-entrant GC / callbacks
- calls such as
matcher.matches,adjustBreakpointLocation, andsetBreakpointImplcan trigger garbage collection or re-entrant Inspector activity that mutatesm_scriptsand 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.
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
- Register scripts and a matcher
The attacker, driving the Debugger domain, has multiple scripts in
m_scriptsand issues asetBreakpointByUrl/removeBreakpoint-style request that iterates all scripts with a URL/regex matcher. - Trigger GC or re-entrancy mid-loop
During the loop, a GC-capable or re-entrant call (
matcher.matches,adjustBreakpointLocation, orsetBreakpointImpl) causes aV8DebuggerScriptto be dropped fromm_scripts, freeing it while the loop still holds the raw pointer or iterator. - Use the freed script
The loop dereferences the now-dangling
V8DebuggerScript*(e.g.*script.secondorscript->getLanguage()) or passes it intoremoveBreakpointImpl, producing the use-after-free. - Escalate via reclaimed memory
Grooming the freed
V8DebuggerScriptallocation 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
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
| Function | Change | Notes |
|---|---|---|
DisallowGarbageCollectionScopesrc/debug/debug-interface.cc |
modified | |
V8_NODISCARDsrc/debug/debug-interface.h |
modified | |
forsrc/inspector/v8-debugger-agent-impl.cc |
modified | |
ifsrc/inspector/v8-debugger-agent-impl.cc |
modified |
Files Changed
src/debug/debug-interface.ccsrc/debug/debug-interface.hsrc/inspector/v8-debugger-agent-impl.cc
Audit Directions
- Raw owner across GC/callbackFlag 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 mapsAudit 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 objectsReview helper functions that accept raw
V8DebuggerScript*(or similar) vectors/args and outlive potential GC, converting them toshared_ptror re-validation likeremoveBreakpointImpl.
Patch
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) {
Original Bug Report
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.ccv8/src/debug/debug.ccv8/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.