Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Headless
DescriptionUse after free in Headless
ComponentHeadless
Bug ClassUAF
Tracker513822378
Fix commitb0e9bf87c426 (chromium/src) +99/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
for
components/headless/test/data/protocol/shared/hidden-target-sync-close.js
modified
HeadlessProtocolBrowserTestWithAllowedCrashes
headless/test/headless_protocol_browsertest.cc
modified

Files Changed

  • components/headless/test/data/protocol/shared/hidden-target-sync-close-expected.txt
  • components/headless/test/data/protocol/shared/hidden-target-sync-close.js
  • headless/lib/browser/headless_browser_context_impl.cc
  • headless/lib/browser/headless_devtools_manager_delegate.cc
  • headless/test/headless_protocol_browsertest.cc
  • headless/test/headless_test_launcher.cc
From b0e9bf87c426b7c57dfa95a7ba88b4d20f07cf79 Mon Sep 17 00:00:00 2001
From: Andrey Kosyakov <caseq@chromium.org>
Date: Thu, 21 May 2026 11:35:29 -0700
Subject: [PATCH] Reland "[headless] properly handle other WCs closing synchronously while closing a WC"

This reverts commit 5bdfda652bc310de5d2761e6b04ece31b7c1864c.

Reason for revert: re-land with a fix (suppress core dumps to avoid hitting test timeouts while dumping cores of crashed renderers)

Original change's description:
> Revert "[headless] properly handle other WCs closing synchronously while closing a WC"
>
> This reverts commit be3315a21fe26fb27e196265202f4c68794a34cb.
>
> Reason for revert: failure in https://ci.chromium.org/ui/p/chromium/builders/ci/Linux%20Tests%20(dbg)(1)/131757/overview
>
> Original change's description:
> > [headless] properly handle other WCs closing synchronously while closing a WC
> >
> > Fixed: 513822378
> > Change-Id: Id94649fe35a0d24238bc991d0d203ccb84965b6b
> > Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7858555
> > Commit-Queue: Andrey Kosyakov <caseq@chromium.org>
> > Reviewed-by: Peter Kvitek <kvitekp@chromium.org>
> > Cr-Commit-Position: refs/heads/main@{#1632931}
>
> Bug: 513822378
> No-Presubmit: true
> No-Tree-Checks: true
> No-Try: true
> Change-Id: I5f7615d7d185155ab383fe36d7cb6f97777117ce
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7861667
> Commit-Queue: rubber-stamper@appspot.gserviceaccount.com <rubber-stamper@appspot.gserviceaccount.com>
> Owners-Override: S Ganesh <ganesh@chromium.org>
> Auto-Submit: S Ganesh <ganesh@chromium.org>
> Bot-Commit: rubber-stamper@appspot.gserviceaccount.com <rubber-stamper@appspot.gserviceaccount.com>
> Cr-Commit-Position: refs/heads/main@{#1633198}

Bug: 513822378
Change-Id: Ia167db11c7a2b88fee5e2bef2aed37de47c34b80
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7865317
Commit-Queue: Andrey Kosyakov <caseq@chromium.org>
Reviewed-by: Peter Kvitek <kvitekp@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1634425}
---

diff --git a/components/headless/test/data/protocol/shared/hidden-target-sync-close-expected.txt b/components/headless/test/data/protocol/shared/hidden-target-sync-close-expected.txt
new file mode 100644
index 0000000..57661b8
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/hidden-target-sync-close-expected.txt
@@ -0,0 +1,3 @@
+Test hidden targets synchronous close
+Crashed 10 targets
+PASSED
\ No newline at end of file
diff --git a/components/headless/test/data/protocol/shared/hidden-target-sync-close.js b/components/headless/test/data/protocol/shared/hidden-target-sync-close.js
new file mode 100644
index 0000000..80c63f96b
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/hidden-target-sync-close.js
@@ -0,0 +1,55 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+  const {session} =
+      await testRunner.startBlank(`Test hidden targets synchronous close`);
+
+  const {result: {browserContextId}} =
+      await testRunner.browserP().Target.createBrowserContext();
+
+  const {result: mainTarget} = await testRunner.browserP().Target.createTarget({
+    url: testRunner.url('/resources/blank.html'),
+    browserContextId,
+  });
+
+  const attachedToMainTarget =
+      await testRunner.browserP().Target.attachToTarget({
+        targetId: mainTarget.targetId,
+        flatten: true,
+      });
+  const mainSession =
+      session.createChild(attachedToMainTarget.result.sessionId);
+
+  const hiddenTargetIds = [];
+  for (let i = 0; i < 100; ++i) {
+    const {result: {targetId: hiddenId}} =
+        await mainSession.protocol.Target.createTarget({
+          url: `about:blank`,
+          hidden: true,
+          browserContextId,
+        });
+    hiddenTargetIds.push(hiddenId);
+  }
+
+  const crashPromises = [];
+  for (let i = 0; i < 10; ++i) {
+    const targetId = hiddenTargetIds[i];
+    const attachedToHiddenTarget =
+        await testRunner.browserP().Target.attachToTarget({
+          targetId,
+          flatten: true,
+        });
+    const hiddenSession =
+        session.createChild(attachedToHiddenTarget.result.sessionId);
+    hiddenSession.protocol.Page.crash();
+    crashPromises.push(hiddenSession.protocol.Inspector.onceTargetCrashed());
+  }
+
+  await Promise.all(crashPromises);
+  testRunner.log(`Crashed ${crashPromises.length} targets`);
+  await testRunner.browserP().Target.disposeBrowserContext({browserContextId});
+  testRunner.log('PASSED');
+  testRunner.completeTest();
+});
diff --git a/headless/lib/browser/headless_browser_context_impl.cc b/headless/lib/browser/headless_browser_context_impl.cc
index afec498..c93e62b 100644
--- a/headless/lib/browser/headless_browser_context_impl.cc
+++ b/headless/lib/browser/headless_browser_context_impl.cc
@@ -143,6 +143,10 @@
 }
 
 void HeadlessBrowserContextImpl::Close() {
+  while (!web_contents_map_.empty()) {
+    auto it = web_contents_map_.begin();
+    it->second->Close();
+  }
   DCHECK_CURRENTLY_ON(content::BrowserThread::UI);
   browser_->DestroyBrowserContext(this);
 }
diff --git a/headless/lib/browser/headless_devtools_manager_delegate.cc b/headless/lib/browser/headless_devtools_manager_delegate.cc
index 045c115..abf3365f 100644
--- a/headless/lib/browser/headless_devtools_manager_delegate.cc
+++ b/headless/lib/browser/headless_devtools_manager_delegate.cc
@@ -102,15 +102,6 @@
     DisposeCallback callback) {
   HeadlessBrowserContextImpl* context =
       HeadlessBrowserContextImpl::From(browser_context);
-  std::vector<HeadlessWebContents*> web_contents = context->GetAllWebContents();
-  while (!web_contents.empty()) {
-    for (auto* wc : web_contents)
-      wc->Close();
-    // Since HeadlessWebContents::Close spawns a nested run loop to await
-    // closing, new web_contents could be opened. We need to re-query pages and
-    // close them too.
-    web_contents = context->GetAllWebContents();
-  }
   context->Close();
   std::move(callback).Run(true, "");
 }
diff --git a/headless/test/headless_protocol_browsertest.cc b/headless/test/headless_protocol_browsertest.cc
index 3f81fd5..1503229 100644
--- a/headless/test/headless_protocol_browsertest.cc
+++ b/headless/test/headless_protocol_browsertest.cc
@@ -21,6 +21,7 @@
 #include "build/config/linux/dbus/buildflags.h"
 #include "components/headless/test/shared_test_util.h"
 #include "content/public/common/content_switches.h"
+#include "content/public/test/no_renderer_crashes_assertion.h"
 #include "headless/lib/browser/headless_web_contents_impl.h"
 #include "headless/public/switches.h"
 #include "headless/test/headless_browser_test_utils.h"
@@ -360,6 +361,30 @@
 
 HEADLESS_PROTOCOL_TEST(HiddenTargetCreate, "shared/hidden-target-create.js")
 HEADLESS_PROTOCOL_TEST(HiddenTargetClose, "shared/hidden-target-close.js")
+class HeadlessProtocolBrowserTestWithAllowedCrashes
+    : public HeadlessProtocolBrowserTest {
+ public:
+  HeadlessProtocolBrowserTestWithAllowedCrashes() = default;
+
+ protected:
+  void SetUpOnMainThread() override {
+    allow_renderer_crashes_ =
+        std::make_unique<content::ScopedAllowRendererCrashes>();
+    HeadlessProtocolBrowserTest::SetUpOnMainThread();
+  }
+
+  void TearDownOnMainThread() override {
+    allow_renderer_crashes_.reset();
+    HeadlessProtocolBrowserTest::TearDownOnMainThread();
+  }
+
+ private:
+  std::unique_ptr<content::ScopedAllowRendererCrashes> allow_renderer_crashes_;
+};
+
+HEADLESS_PROTOCOL_TEST_F(HeadlessProtocolBrowserTestWithAllowedCrashes,
+                         HiddenTargetSyncClose,
+                         "shared/hidden-target-sync-close.js")
 HEADLESS_PROTOCOL_TEST(HiddenTargetCreateInvalidParams,
                        "shared/hidden-target-create-invalid-params.js")
 HEADLESS_PROTOCOL_TEST(HiddenTargetPageEnable,
diff --git a/headless/test/headless_test_launcher.cc b/headless/test/headless_test_launcher.cc
index fc35eb6..2c44ffa 100644
--- a/headless/test/headless_test_launcher.cc
+++ b/headless/test/headless_test_launcher.cc
@@ -20,6 +20,9 @@
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/headless/test/data/protocol/shared/hidden-target-sync-close-expected.txt b/components/headless/test/data/protocol/shared/hidden-target-sync-close-expected.txt
new file mode 100644
index 0000000..57661b8
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/hidden-target-sync-close-expected.txt
@@ -0,0 +1,3 @@
+Test hidden targets synchronous close
+Crashed 10 targets
+PASSED
\ No newline at end of file
diff --git a/components/headless/test/data/protocol/shared/hidden-target-sync-close.js b/components/headless/test/data/protocol/shared/hidden-target-sync-close.js
new file mode 100644
index 0000000..80c63f96b
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/hidden-target-sync-close.js
@@ -0,0 +1,55 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+  const {session} =
+      await testRunner.startBlank(`Test hidden targets synchronous close`);
+
+  const {result: {browserContextId}} =
+      await testRunner.browserP().Target.createBrowserContext();
+
+  const {result: mainTarget} = await testRunner.browserP().Target.createTarget({
+    url: testRunner.url('/resources/blank.html'),
+    browserContextId,
+  });
+
+  const attachedToMainTarget =
+      await testRunner.browserP().Target.attachToTarget({
+        targetId: mainTarget.targetId,
+        flatten: true,
+      });
+  const mainSession =
+      session.createChild(attachedToMainTarget.result.sessionId);
+
+  const hiddenTargetIds = [];
+  for (let i = 0; i < 100; ++i) {
+    const {result: {targetId: hiddenId}} =
+        await mainSession.protocol.Target.createTarget({
+          url: `about:blank`,
+          hidden: true,
+          browserContextId,
+        });
+    hiddenTargetIds.push(hiddenId);
+  }
+
+  const crashPromises = [];
+  for (let i = 0; i < 10; ++i) {
+    const targetId = hiddenTargetIds[i];
+    const attachedToHiddenTarget =
+        await testRunner.browserP().Target.attachToTarget({
+          targetId,
+          flatten: true,
+        });
+    const hiddenSession =
+        session.createChild(attachedToHiddenTarget.result.sessionId);
+    hiddenSession.protocol.Page.crash();
+    crashPromises.push(hiddenSession.protocol.Inspector.onceTargetCrashed());
+  }
+
+  await Promise.all(crashPromises);
+  testRunner.log(`Crashed ${crashPromises.length} targets`);
+  await testRunner.browserP().Target.disposeBrowserContext({browserContextId});
+  testRunner.log('PASSED');
+  testRunner.completeTest();
+});
diff --git a/headless/test/headless_protocol_browsertest.cc b/headless/test/headless_protocol_browsertest.cc
index 3f81fd5..1503229 100644
--- a/headless/test/headless_protocol_browsertest.cc
+++ b/headless/test/headless_protocol_browsertest.cc
@@ -21,6 +21,7 @@
 #include "build/config/linux/dbus/buildflags.h"
 #include "components/headless/test/shared_test_util.h"
 #include "content/public/common/content_switches.h"
+#include "content/public/test/no_renderer_crashes_assertion.h"
 #include "headless/lib/browser/headless_web_contents_impl.h"
 #include "headless/public/switches.h"
 #include "headless/test/headless_browser_test_utils.h"
@@ -360,6 +361,30 @@
 
 HEADLESS_PROTOCOL_TEST(HiddenTargetCreate, "shared/hidden-target-create.js")
 HEADLESS_PROTOCOL_TEST(HiddenTargetClose, "shared/hidden-target-close.js")
+class HeadlessProtocolBrowserTestWithAllowedCrashes
+    : public HeadlessProtocolBrowserTest {
+ public:
+  HeadlessProtocolBrowserTestWithAllowedCrashes() = default;
+
+ protected:
+  void SetUpOnMainThread() override {
+    allow_renderer_crashes_ =
+        std::make_unique<content::ScopedAllowRendererCrashes>();
+    HeadlessProtocolBrowserTest::SetUpOnMainThread();
+  }
+
+  void TearDownOnMainThread() override {
+    allow_renderer_crashes_.reset();
+    HeadlessProtocolBrowserTest::TearDownOnMainThread();
+  }
+
+ private:
+  std::unique_ptr<content::ScopedAllowRendererCrashes> allow_renderer_crashes_;
+};
+
+HEADLESS_PROTOCOL_TEST_F(HeadlessProtocolBrowserTestWithAllowedCrashes,
+                         HiddenTargetSyncClose,
+                         "shared/hidden-target-sync-close.js")
 HEADLESS_PROTOCOL_TEST(HiddenTargetCreateInvalidParams,
                        "shared/hidden-target-create-invalid-params.js")
 HEADLESS_PROTOCOL_TEST(HiddenTargetPageEnable,
diff --git a/headless/test/headless_test_launcher.cc b/headless/test/headless_test_launcher.cc
index fc35eb6..2c44ffa 100644
--- a/headless/test/headless_test_launcher.cc
+++ b/headless/test/headless_test_launcher.cc
@@ -20,6 +20,9 @@
 #if BUILDFLAG(IS_WIN)
 #include "base/win/win_util.h"
 #endif  // BUILDFLAG(IS_WIN)
+#if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_CHROMEOS)
+#include <sys/resource.h>
+#endif
 
 namespace headless {
 namespace {
@@ -60,6 +63,15 @@
     return 1;
   }
 
+#if BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_CHROMEOS)
+  // Set core dump limit to 0 for Linux/ChromeOS.
+  // Some tests intentionally cause renderer crashes, which may cause a core
+  // dump on unix systems, which is slow and usually causes a test to time-out.
+  struct rlimit limit = {0, 0};
+  if (setrlimit(RLIMIT_CORE, &limit) != 0) {
+    PLOG(WARNING) << "Failed to set core dump limit";
+  }
+#endif  // BUILDFLAG(IS_LINUX) || BUILDFLAG(IS_CHROMEOS)
 #if BUILDFLAG(IS_WIN)
   // Load and pin user32.dll to avoid having to load it once tests start while
   // on the main thread loop where blocking calls are disallowed.
Loading diff…

Original Bug Report

reported by vm...@google.com

Browser Process UAF in HeadlessDevToolsManagerDelegate::DisposeBrowserContext

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A Use-After-Free vulnerability in headless_shell can be triggered when a browser context is disposed of while containing specifically configured DevTools sessions. The issue arises from iterating over a snapshot of raw pointers to WebContents that can be synchronously destroyed during the iteration process.

Affected files:

  • headless/lib/browser/headless_devtools_manager_delegate.cc
  • headless/lib/browser/headless_web_contents_impl.cc
  • headless/lib/browser/headless_browser_context_impl.cc
  • headless/lib/browser/protocol/target_handler.cc
  • headless/lib/browser/protocol/headless_devtools_session.cc

Estimated timestamp from git blame: 2025-04-02

Summary

A Use-After-Free (UAF) vulnerability exists in the HeadlessDevToolsManagerDelegate::DisposeBrowserContext function. The vulnerability is triggered when closing one HeadlessWebContents synchronously causes the destruction of another HeadlessWebContents that is also present in a local snapshot vector of raw pointers.

Root Cause Analysis

In headless/lib/browser/headless_devtools_manager_delegate.cc, the DisposeBrowserContext method snapshots the current WebContents into a std::vector<HeadlessWebContents*>:

void HeadlessDevToolsManagerDelegate::DisposeBrowserContext(
    content::BrowserContext* browser_context, DisposeCallback callback) {
  // ...
  std::vector<HeadlessWebContents*> web_contents = context->GetAllWebContents();
  while (!web_contents.empty()) {
    for (auto* wc : web_contents)
      wc->Close();                 // Potential UAF here
    web_contents = context->GetAllWebContents();
  }
  // ...
}

The wc->Close() call eventually leads to the synchronous destruction of the HeadlessWebContentsImpl object. If the destruction of one object (A) synchronously triggers the destruction of another object (B) that is also in the web_contents vector, the iteration will reach a dangling pointer when it attempts to call Close() on B.

Synchronous Destruction Chain

This condition can be reached using the Chrome DevTools Protocol (CDP). A DevTools session (Session A) can “own” other targets if they are created as “hidden” targets through that session. When Target A is closed:

  1. Target A is destroyed, which synchronously destroys its associated DevTools sessions (including Session A).
  2. The destructor of HeadlessDevToolsSession calls TargetHandler::Disable().
  3. TargetHandler::Disable() iterates over its hidden_web_contents_ and calls Close() on them (e.g., Target B).
  4. If Target B’s renderer is crashed (not live), the Content layer (RenderFrameHostImpl::ClosePage) executes the closure synchronously, immediately destroying Target B.

When control returns to the loop in DisposeBrowserContext, the pointer to Target B is dangling, leading to a UAF upon the next virtual call to Close().

Potential Reproduction Steps

Note: These steps are suggested based on code analysis; our environment does not support functional execution of proof-of-concept code.

  1. Launch chrome-headless-shell with DevTools enabled (e.g., --remote-debugging-pipe).
  2. Create a new BrowserContext (Context X) via CDP.
  3. Create a target (Target A) in Context X and attach to it to obtain a session.
  4. Using the session for Target A, create a hidden target (Target B) in Context X using Target.createTarget({hidden: true}).
  5. Crash the renderer process for Target B (e.g., by navigating it to chrome://crash).
  6. Call Target.disposeBrowserContext for Context X.
  7. The browser process should encounter a UAF when the disposal loop reaches the destroyed Target B.

Suggested Fix

The snapshot should not store raw pointers to objects with managed lifetimes. Instead, use a vector of base::WeakPtr<HeadlessWebContentsImpl> and verify that the pointer is still valid before calling Close() in each iteration. Alternatively, iterate over a list of Target IDs and re-resolve them in each step.

// Suggested remediation using WeakPtr
std::vector<base::WeakPtr<HeadlessWebContentsImpl>> web_contents_weak;
for (auto* wc : context->GetAllWebContents()) {
  web_contents_weak.push_back(HeadlessWebContentsImpl::From(wc)->GetWeakPtr());
}

for (auto& wc_weak : web_contents_weak) {
  if (wc_weak) {
    wc_weak->Close();
  }
}

Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker