Overview

Medium
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in V8
DescriptionUse after free in V8
ComponentV8
Sub-componentInspector
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.
Tracker493631402
Fix commit606a11949ee0 (v8/v8) +58/-13
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
Creditedsakana
Reported2026-03-17 (per vendor advisory)
Fix landed2026-03-23 (public git)
Disclosed2026-05-05
Patch gap43 days — fix public in source before the advisory

Background

V8 Inspector `V8DebuggerAgentImpl`
the C++ agent that services CDP Debugger.* commands (such as setBreakpointByUrl) and emits events like scriptFailedToParse.
`V8DebuggerScript`
the inspector-side wrapper for a parsed script; the fix adds an m_hadCompileError flag so the wrapper records whether the underlying compilation failed.
Lazy recompilation on `setBreakpoint`
as the removed commit message notes, setting a breakpoint forces V8 to (re)compile a script that was never compiled or was evicted by GC, and a failed top-level compilation re-enters the inspector.
`ScriptsMap` (`m_scripts`)
the agent’s map from script id to owning std::unique_ptr<V8DebuggerScript>; entries can be replaced or destroyed while V8 is re-entered.

Root Cause Analysis

The breakpoint-setting helper in v8-debugger-agent-impl.cc (the enclosing function name is not shown in the hunk) looked up m_scripts.find(scriptId) and cached the raw pointer V8DebuggerScript* script = scriptIterator->second.get() before calling script->setBreakpoint(...). On a script that had not yet compiled or had been GC-evicted, setBreakpoint triggers a recompilation that, on failure, re-enters the inspector and drives another didParseSource/scriptFailedToParse cycle (and, per the “Drive-by” note, V8 could also be re-entered via the regex used for URL matching). That re-entrancy can mutate or destroy the owning unique_ptr entry in m_scripts, leaving the previously cached raw script pointer dangling and used afterward — a use-after-free.

The fix adds an early if (script->hadCompileError()) return nullptr; so breakpoints are never set on broken scripts (which “can’t be run in any case”), and wraps the setBreakpoint call in a v8::TryCatch to absorb any re-entrant exception; a plumbed m_hadCompileError bit lets both the guard and didParseSource decide behavior from the script itself rather than a passed success boolean.

Key insight
The agent set breakpoints on scripts that had failed compilation, and doing so re-entered V8/the inspector and could free the very V8DebuggerScript whose raw pointer was still held; the fix refuses breakpoints on compile-error scripts up front and contains any re-entrant exception with a TryCatch.

Attack Path

  1. Register a broken script an attacker with debugger access supplies a script with a top-level compile error (or one that will be GC-evicted before use), which the inspector tracks in m_scripts.
  2. Request a breakpoint on it issuing Debugger.setBreakpointByUrl/setBreakpoint for that script drives the helper to cache the raw V8DebuggerScript* and call setBreakpoint, forcing recompilation.
  3. Trigger re-entrancy on compile failure the failed recompilation (or a regex re-entry) fires another scriptFailedToParse/didParseSource, mutating or destroying the owning m_scripts entry.
  4. Use the freed script control returns to the helper, which continues to operate on the now-dangling script pointer, yielding the use-after-free (subsequent read/write primitive is (inferred) — the diff does not show the sink).

Impact Assessment

An attacker who can drive the DevTools/CDP debugger protocol against the target can free and then reuse a V8DebuggerScript in the process hosting the inspector, a use-after-free in V8. Turning this into a read/write primitive or further escalation is (inferred); the advisory classifies the issue as a use-after-free in V8 and does not state exploited-in-the-wild status here. Preconditions are an enabled debugger agent and the ability to set a breakpoint on a script that fails top-level (re)compilation.

Changed Functions

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

Files Changed

  • src/inspector/v8-debugger-agent-impl.cc
  • src/inspector/v8-debugger-agent-impl.h
  • src/inspector/v8-debugger-script.cc
  • src/inspector/v8-debugger-script.h
  • src/inspector/v8-debugger.cc
  • test/inspector/debugger/set-breakpoint-by-url-in-broken-script-expected.txt

Audit Directions

  • Raw pointers held across V8 re-entry
    audit inspector agents that cache X* = map.find(...)->second.get() and then call into V8 (compile, evaluate, regex); any re-entrant callback that can erase/replace the map entry invalidates the pointer.
  • State passed as a boolean instead of read from the object
    look for other didParseSource-style APIs that thread a success/status flag separately from the object it describes, where the flag and the object can disagree after re-entrancy.
  • Operations that force recompilation
    review paths (breakpoints, blackboxing, source fetch) that can recompile a script whose compilation may fail, and confirm they short-circuit on scripts already known to have compile errors.
From 606a11949ee0fecbb3cae68b0191814b6efb5cb1 Mon Sep 17 00:00:00 2001
From: Simon Zünd <szuend@chromium.org>
Date: Mon, 23 Mar 2026 11:43:12 +0000
Subject: [PATCH] [inspector] Don't emit scriptFailedToParse on setBreakpoint

Setting breakpoints triggers re-compilation if V8 hasn't compiled the
script yet or the GC evicted it.

If the top-level compilation fails, the inspector receives another
'scriptFailedToParse' event.

The "real" fix would be to forward a "CLEAR_EXCEPTION" bit into
'CompileTopLevel' in V8.

For now, we prevent setting breakpoints on broken scripts, they can't be
run in any case.

Drive-by: Catch exceptions when setting breakpoints in case there are
other ways where we re-enter V8 (e.g. via the regex).

R=bmeurer@chromium.org

Bug: 493631402
Change-Id: I18ec510a8288fd2a4bc44921ba538613ee8e1201
Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/7690996
Reviewed-by: Benedikt Meurer <bmeurer@chromium.org>
Commit-Queue: Simon Zünd <szuend@chromium.org>
Cr-Commit-Position: refs/heads/main@{#105967}
---

diff --git a/src/inspector/v8-debugger-agent-impl.cc b/src/inspector/v8-debugger-agent-impl.cc
index 2e78b58..b39dfea 100644
--- a/src/inspector/v8-debugger-agent-impl.cc
+++ b/src/inspector/v8-debugger-agent-impl.cc
@@ -446,7 +446,7 @@
   std::vector<std::unique_ptr<V8DebuggerScript>> compiledScripts =
       m_debugger->getCompiledScripts(m_session->contextGroupId(), this);
   for (auto& script : compiledScripts) {
-    didParseSource(std::move(script), true);
+    didParseSource(std::move(script));
   }
 
   m_breakpointsActive = m_state->booleanProperty(
@@ -1073,6 +1073,7 @@
   ScriptsMap::iterator scriptIterator = m_scripts.find(scriptId);
   if (scriptIterator == m_scripts.end()) return nullptr;
   V8DebuggerScript* script = scriptIterator->second.get();
+  if (script->hadCompileError()) return nullptr;
 
   v8::debug::BreakpointId debuggerBreakpointId;
   v8::debug::Location location(lineNumber, columnNumber);
@@ -1083,6 +1084,7 @@
 
   {
     v8::Context::Scope contextScope(inspected->context());
+    v8::TryCatch tryCatch(m_isolate);
     if (!script->setBreakpoint(condition, &location, &debuggerBreakpointId)) {
       return nullptr;
     }
@@ -1956,9 +1958,9 @@
 }  // namespace
 
 void V8DebuggerAgentImpl::didParseSource(
-    std::unique_ptr<V8DebuggerScript> script, bool success) {
+    std::unique_ptr<V8DebuggerScript> script) {
   v8::HandleScope handles(m_isolate);
-  if (!success) {
+  if (script->hadCompileError()) {
     String16 scriptSource = script->source(0);
     script->setSourceURL(findSourceURL(scriptSource, false));
     script->setSourceMappingURL(findSourceMapURL(scriptSource, false));
@@ -2025,7 +2027,7 @@
           ? stack->buildInspectorObjectImpl(m_debugger, 0)
           : nullptr;
 
-  if (!success) {
+  if (scriptRef->hadCompileError()) {
     m_frontend.scriptFailedToParse(
         scriptId, scriptURL, scriptRef->startLine(), scriptRef->startColumn(),
         scriptRef->endLine(), scriptRef->endColumn(), contextId,
diff --git a/src/inspector/v8-debugger-agent-impl.h b/src/inspector/v8-debugger-agent-impl.h
index 7c81ced..c71e9ab 100644
--- a/src/inspector/v8-debugger-agent-impl.h
+++ b/src/inspector/v8-debugger-agent-impl.h
@@ -182,7 +182,7 @@
                 v8::debug::ExceptionType exceptionType, bool isUncaught,
                 v8::debug::BreakReasons breakReasons);
   void didContinue();
-  void didParseSource(std::unique_ptr<V8DebuggerScript>, bool success);
+  void didParseSource(std::unique_ptr<V8DebuggerScript>);
 
   bool isFunctionBlackboxed(const String16& scriptId,
                             const v8::debug::Location& start,
diff --git a/src/inspector/v8-debugger-script.cc b/src/inspector/v8-debugger-script.cc
index 5d65d7a..05f8107 100644
--- a/src/inspector/v8-debugger-script.cc
+++ b/src/inspector/v8-debugger-script.cc
@@ -42,13 +42,15 @@
 
 V8DebuggerScript::V8DebuggerScript(v8::Isolate* isolate,
                                    v8::Local<v8::debug::Script> script,
-                                   bool isLiveEdit, V8DebuggerAgentImpl* agent,
+                                   bool hadCompileError, bool isLiveEdit,
+                                   V8DebuggerAgentImpl* agent,
                                    V8InspectorClient* client)
     : m_id(String16::fromInteger(script->Id())),
       m_url(GetScriptURL(isolate, script, client)),
       m_isolate(isolate),
       m_embedderName(GetScriptName(isolate, script, client)),
       m_agent(agent),
+      m_hadCompileError(hadCompileError),
       m_isLiveEdit(isLiveEdit) {
   Initialize(script);
 }
diff --git a/src/inspector/v8-debugger-script.h b/src/inspector/v8-debugger-script.h
index d8f5e1d..107b870 100644
--- a/src/inspector/v8-debugger-script.h
+++ b/src/inspector/v8-debugger-script.h
@@ -53,8 +53,8 @@
   enum class Language { JavaScript, WebAssembly };
 
   V8DebuggerScript(v8::Isolate* isolate, v8::Local<v8::debug::Script> script,
-                   bool isLiveEdit, V8DebuggerAgentImpl* agent,
-                   V8InspectorClient* client);
+                   bool hadCompileError, bool isLiveEdit,
+                   V8DebuggerAgentImpl* agent, V8InspectorClient* client);
   ~V8DebuggerScript() = default;
   V8DebuggerScript(const V8DebuggerScript&) = delete;
   V8DebuggerScript& operator=(const V8DebuggerScript&) = delete;
@@ -76,6 +76,7 @@
   int endColumn() const { return m_endColumn; }
   int codeOffset() const;
   int executionContextId() const { return m_executionContextId; }
+  bool hadCompileError() const { return m_hadCompileError; }
   bool isLiveEdit() const { return m_isLiveEdit; }
   bool isModule() const { return m_isModule; }
   int length() const;
@@ -132,6 +133,7 @@
   String16 m_sourceMappingURL;
   mutable String16 m_buildId;
   Language m_language;
+  bool m_hadCompileError = false;
   bool m_isLiveEdit = false;
   bool m_isModule = false;
   mutable String16 m_hash;
diff --git a/src/inspector/v8-debugger.cc b/src/inspector/v8-debugger.cc
index 72e5f49..faa2463 100644
--- a/src/inspector/v8-debugger.cc
+++ b/src/inspector/v8-debugger.cc
@@ -169,7 +169,7 @@
       if (m_inspector->contextGroupId(contextId) != contextGroupId) continue;
     }
     result.push_back(std::make_unique<V8DebuggerScript>(
-        m_isolate, script, false, agent, m_inspector->client()));
+        m_isolate, script, false, false, agent, m_inspector->client()));
   }
   return result;
 }
@@ -604,10 +604,8 @@
        client](V8InspectorSessionImpl* session) {
         auto agent = session->debuggerAgent();
         if (!agent->enabled()) return;
-        agent->didParseSource(
-            std::make_unique<V8DebuggerScript>(isolate, script, is_live_edited,
-                                               agent, client),
-            !has_compile_error);
+        agent->didParseSource(std::make_unique<V8DebuggerScript>(
+            isolate, script, has_compile_error, is_live_edited, agent, client));
       });
 }
 
diff --git a/test/inspector/debugger/set-breakpoint-by-url-in-broken-script-expected.txt b/test/inspector/debugger/set-breakpoint-by-url-in-broken-script-expected.txt
new file mode 100644
index 0000000..e0f9392
--- /dev/null
+++ b/test/inspector/debugger/set-breakpoint-by-url-in-broken-script-expected.txt
@@ -0,0 +1,21 @@
+Check that setBreakpointByUrl in a broken script doesn't trigger scriptFailedToParse
+{
+    method : Debugger.scriptFailedToParse
+    params : {
+        buildId : 
+        embedderName : foo.js
+        endColumn : 1
+        endLine : 0
+        executionContextId : <executionContextId>
+        hasSourceURL : false
+        hash : 021fb596db81e6d02bf3d2586ee3981fe519f275c0ac9ca76bbcf2ebb4097d96
+        isModule : false
+        length : 1
+        scriptId : <scriptId>
+        scriptLanguage : JavaScript
+        sourceMapURL : 
+        startColumn : 0
+        startLine : 0
+        url : foo.js
+    }
+}
diff --git a/test/inspector/debugger/set-breakpoint-by-url-in-broken-script.js b/test/inspector/debugger/set-breakpoint-by-url-in-broken-script.js
new file mode 100644
Loading diff…

Original Bug Report

reported by sa...@gmail.com

heap-use-after-free in v8_inspector::V8DebuggerAgentImpl::setBreakpointByUrl

VULNERABILITY DETAILS UAF in v8_inspector::V8DebuggerAgentImpl::setBreakpointByUrl

VERSION Chrome Version: 148.0.7728.0(Developer Build) Operating System: Ubuntu

REPRODUCTION CASE

  1. put manifest.json/background.js into the extension_path
  2. run the command: ./chrome –user-data-dir=./noexist –no-sandbox –load-extension=“extension_path”

FOR CRASHES, PLEASE INCLUDE THE FOLLOWING ADDITIONAL INFORMATION Type of crash: tab Crash State: see asan.log file

Please note that this bug is very similar to https://issues.chromium.org/u/2/issues/40063469.

CREDIT INFORMATION Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited? Reporter credit: sakana

View on issue tracker