CVE-2026-13832
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcomponents/headless/test/data/protocol/shared/hidden-target-sync-close.js |
modified | |
HeadlessProtocolBrowserTestWithAllowedCrashesheadless/test/headless_protocol_browsertest.cc |
modified |
Files Changed
components/headless/test/data/protocol/shared/hidden-target-sync-close-expected.txtcomponents/headless/test/data/protocol/shared/hidden-target-sync-close.jsheadless/lib/browser/headless_browser_context_impl.ccheadless/lib/browser/headless_devtools_manager_delegate.ccheadless/test/headless_protocol_browsertest.ccheadless/test/headless_test_launcher.cc
Patch
From b0e9bf87c426b7c57dfa95a7ba88b4d20f07cf79 Mon Sep 17 00:00:00 2001 From: Andrey Kosyakov <[email protected]> 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 <[email protected]> > > Reviewed-by: Peter Kvitek <[email protected]> > > 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: [email protected] <[email protected]> > Owners-Override: S Ganesh <[email protected]> > Auto-Submit: S Ganesh <[email protected]> > Bot-Commit: [email protected] <[email protected]> > 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 <[email protected]> Reviewed-by: Peter Kvitek <[email protected]> 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 @@
Regression Test / PoC
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.
Original Bug Report
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.ccheadless/lib/browser/headless_web_contents_impl.ccheadless/lib/browser/headless_browser_context_impl.ccheadless/lib/browser/protocol/target_handler.ccheadless/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:
- Target A is destroyed, which synchronously destroys its associated DevTools sessions (including Session A).
- The destructor of
HeadlessDevToolsSessioncallsTargetHandler::Disable(). TargetHandler::Disable()iterates over itshidden_web_contents_and callsClose()on them (e.g., Target B).- 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.
- Launch
chrome-headless-shellwith DevTools enabled (e.g.,--remote-debugging-pipe). - Create a new
BrowserContext(Context X) via CDP. - Create a target (Target A) in Context X and attach to it to obtain a session.
- Using the session for Target A, create a hidden target (Target B) in Context X using
Target.createTarget({hidden: true}). - Crash the renderer process for Target B (e.g., by navigating it to
chrome://crash). - Call
Target.disposeBrowserContextfor Context X. - 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.