CVE-2026-17896
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/inspector/inspector_emulation_agent.cc |
modified | |
InspectorEmulationAgentTestthird_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc |
modified | |
MutatingBodyLoaderthird_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc |
modified | |
ifthird_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc |
modified | |
forthird_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/inspector/inspector_emulation_agent.ccthird_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc
Patch
From 50302ef33bc0c9f9e7313821c936a9dd8ce8aa15 Mon Sep 17 00:00:00 2001 From: Peter Kvitek <[email protected]> Date: Fri, 19 Jun 2026 06:17:35 -0700 Subject: [PATCH] [DevTools] Improve Emulation.setVirtualTimePolicy robustness Bug: 525331547 Change-Id: I15ce4a9dd7fea2ad4fce5cc36a7dcc863cc185d1 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7964078 Auto-Submit: Peter Kvitek <[email protected]> Commit-Queue: Dmitry Gozman <[email protected]> Reviewed-by: Dmitry Gozman <[email protected]> Cr-Commit-Position: refs/heads/main@{#1649637} --- diff --git a/third_party/blink/renderer/core/inspector/inspector_emulation_agent.cc b/third_party/blink/renderer/core/inspector/inspector_emulation_agent.cc index 4163430..c6d6e4b 100644 --- a/third_party/blink/renderer/core/inspector/inspector_emulation_agent.cc +++ b/third_party/blink/renderer/core/inspector/inspector_emulation_agent.cc @@ -600,9 +600,16 @@ budget_amount, BindOnce(&InspectorEmulationAgent::VirtualTimeBudgetExpired, WrapWeakPersistent(this))); - for (DocumentLoader* loader : pending_document_loaders_) + // SetDefersLoading() can synchronously run author script (virtual time + // forces kForceSynchronousParsing) which may spin a nested message loop + // and re-enter WillCommitLoad(), mutating |pending_document_loaders_|. + // Move to a local snapshot first so the live iterators cannot be + // invalidated. + HeapVector<Member<DocumentLoader>> loaders = + std::move(pending_document_loaders_); + for (DocumentLoader* loader : loaders) { loader->SetDefersLoading(LoaderFreezeMode::kNone); - pending_document_loaders_.clear(); + } } if (max_virtual_time_task_starvation_count.value_or(0)) { diff --git a/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc b/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc index 0d2d3e60..8d190fe 100644 --- a/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc +++ b/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc @@ -14,9 +14,12 @@ #include "third_party/blink/renderer/core/frame/web_local_frame_impl.h" #include "third_party/blink/renderer/core/inspector/inspector_session_state.h" #include "third_party/blink/renderer/core/inspector/protocol/protocol.h" +#include "third_party/blink/renderer/core/loader/document_loader.h" #include "third_party/blink/renderer/platform/scheduler/public/page_scheduler.h" #include "third_party/blink/renderer/platform/scheduler/public/thread_cpu_throttler.h" #include "third_party/blink/renderer/platform/testing/task_environment.h" +#include "third_party/blink/renderer/platform/testing/url_loader_test_delegate.h" +#include "third_party/blink/renderer/platform/testing/url_test_helpers.h" #include "third_party/blink/renderer/platform/wtf/text/string_builder.h" #include "third_party/inspector_protocol/crdtp/span.h" @@ -53,8 +56,6 @@ void FlushProtocolNotifications() override {} }; -} // namespace - class InspectorEmulationAgentTest : public testing::Test {}; TEST_F(InspectorEmulationAgentTest, ModifiesAcceptHeader) { @@ -152,4 +153,106 @@ } #endif +class MutatingBodyLoader : public WebNavigationBodyLoader { + public: + MutatingBodyLoader(InspectorEmulationAgent* agent, + LocalFrame* frame, + Persistent<DocumentLoader>* loader2) + : agent_(agent), frame_(frame), loader2_(loader2) {} + + void SetTestReady(bool ready) { test_ready_ = ready; } + + void SetDefersLoading(WebLoaderFreezeMode mode) override { + if (test_ready_ && mode == WebLoaderFreezeMode::kNone && !mutated_) { + mutated_ = true; + double base_ms = 0; + agent_->setVirtualTimePolicy( + protocol::Emulation::VirtualTimePolicyEnum::Pause, std::nullopt, + std::nullopt, std::nullopt, &base_ms); + + auto params2 = std::make_unique<WebNavigationParams>(); + params2->url = WebURL(KURL("https://example.com/test2.html")); + *loader2_ = MakeGarbageCollected<DocumentLoader>( + frame_, kWebNavigationTypeOther, std::move(params2), nullptr, + nullptr); + + for (int i = 0; i < 50; ++i) { + agent_->WillCommitLoad(frame_, *loader2_); + } + } + } + + void StartLoadingBody(Client*) override {} + BodyLoaderType GetType() const override { return BodyLoaderType::kStatic; } + + private: + Persistent<InspectorEmulationAgent> agent_; + Persistent<LocalFrame> frame_; + Persistent<DocumentLoader>* loader2_; + bool mutated_ = false; + bool test_ready_ = false; +}; + +TEST_F(InspectorEmulationAgentTest, VirtualTimePolicyIteratorInvalidation) { + test::TaskEnvironment task_environment; + frame_test_helpers::WebViewHelper helper; + WebViewImpl* web_view = helper.Initialize(); + WebLocalFrameImpl* web_frame = web_view->MainFrameImpl(); + LocalFrame* frame = web_frame->GetFrame(); + auto* virtual_time_controller = + web_view->Scheduler()->GetVirtualTimeController(); + + DummyFrontendChannel channel; + protocol::UberDispatcher dispatcher(&channel); + auto reattach_state = mojom::blink::DevToolsSessionState::New(); + InspectorSessionState session_state(std::move(reattach_state)); + + auto* agent = MakeGarbageCollected<InspectorEmulationAgent>( + web_frame, *virtual_time_controller); + agent->Init(frame->GetProbeSink(), &dispatcher, &session_state); + + double base_ms = 0; + agent->setVirtualTimePolicy(protocol::Emulation::VirtualTimePolicyEnum::Pause, + std::nullopt, std::nullopt, std::nullopt, + &base_ms); + + Persistent<DocumentLoader> loader2; + auto body_loader = + std::make_unique<MutatingBodyLoader>(agent, frame, &loader2); + MutatingBodyLoader* body_loader_ptr = body_loader.get(); + + auto params1 = std::make_unique<WebNavigationParams>(); + params1->url = WebURL(KURL("https://example.com/test1.html")); + params1->body_loader = std::move(body_loader); + + auto* loader1 = MakeGarbageCollected<DocumentLoader>( + frame, kWebNavigationTypeOther, std::move(params1), nullptr, nullptr); + loader1->StartLoading(); + + agent->WillCommitLoad(frame, loader1); + + body_loader_ptr->SetTestReady(true); + + agent->setVirtualTimePolicy( + protocol::Emulation::VirtualTimePolicyEnum::Advance, 100.0, std::nullopt, + std::nullopt, &base_ms); + + loader1->SetSentDidFinishLoad(); + loader1->StopLoading(); + loader1->DetachFromFrame(false); + + if (loader2) { + loader2->SetSentDidFinishLoad(); + loader2->StopLoading(); + loader2->DetachFromFrame(false); + } + + virtual_time_controller->DisableVirtualTimeForTesting(); + agent->disable(); + agent->Dispose(); + helper.Reset(); +} + +} // namespace + } // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc b/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc
index 0d2d3e60..8d190fe 100644
--- a/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc
+++ b/third_party/blink/renderer/core/inspector/inspector_emulation_agent_test.cc
@@ -14,9 +14,12 @@
#include "third_party/blink/renderer/core/frame/web_local_frame_impl.h"
#include "third_party/blink/renderer/core/inspector/inspector_session_state.h"
#include "third_party/blink/renderer/core/inspector/protocol/protocol.h"
+#include "third_party/blink/renderer/core/loader/document_loader.h"
#include "third_party/blink/renderer/platform/scheduler/public/page_scheduler.h"
#include "third_party/blink/renderer/platform/scheduler/public/thread_cpu_throttler.h"
#include "third_party/blink/renderer/platform/testing/task_environment.h"
+#include "third_party/blink/renderer/platform/testing/url_loader_test_delegate.h"
+#include "third_party/blink/renderer/platform/testing/url_test_helpers.h"
#include "third_party/blink/renderer/platform/wtf/text/string_builder.h"
#include "third_party/inspector_protocol/crdtp/span.h"
@@ -53,8 +56,6 @@
void FlushProtocolNotifications() override {}
};
-} // namespace
-
class InspectorEmulationAgentTest : public testing::Test {};
TEST_F(InspectorEmulationAgentTest, ModifiesAcceptHeader) {
@@ -152,4 +153,106 @@
}
#endif
+class MutatingBodyLoader : public WebNavigationBodyLoader {
+ public:
+ MutatingBodyLoader(InspectorEmulationAgent* agent,
+ LocalFrame* frame,
+ Persistent<DocumentLoader>* loader2)
+ : agent_(agent), frame_(frame), loader2_(loader2) {}
+
+ void SetTestReady(bool ready) { test_ready_ = ready; }
+
+ void SetDefersLoading(WebLoaderFreezeMode mode) override {
+ if (test_ready_ && mode == WebLoaderFreezeMode::kNone && !mutated_) {
+ mutated_ = true;
+ double base_ms = 0;
+ agent_->setVirtualTimePolicy(
+ protocol::Emulation::VirtualTimePolicyEnum::Pause, std::nullopt,
+ std::nullopt, std::nullopt, &base_ms);
+
+ auto params2 = std::make_unique<WebNavigationParams>();
+ params2->url = WebURL(KURL("https://example.com/test2.html"));
+ *loader2_ = MakeGarbageCollected<DocumentLoader>(
+ frame_, kWebNavigationTypeOther, std::move(params2), nullptr,
+ nullptr);
+
+ for (int i = 0; i < 50; ++i) {
+ agent_->WillCommitLoad(frame_, *loader2_);
+ }
+ }
+ }
+
+ void StartLoadingBody(Client*) override {}
+ BodyLoaderType GetType() const override { return BodyLoaderType::kStatic; }
+
+ private:
+ Persistent<InspectorEmulationAgent> agent_;
+ Persistent<LocalFrame> frame_;
+ Persistent<DocumentLoader>* loader2_;
+ bool mutated_ = false;
+ bool test_ready_ = false;
+};
+
+TEST_F(InspectorEmulationAgentTest, VirtualTimePolicyIteratorInvalidation) {
+ test::TaskEnvironment task_environment;
+ frame_test_helpers::WebViewHelper helper;
+ WebViewImpl* web_view = helper.Initialize();
+ WebLocalFrameImpl* web_frame = web_view->MainFrameImpl();
+ LocalFrame* frame = web_frame->GetFrame();
+ auto* virtual_time_controller =
+ web_view->Scheduler()->GetVirtualTimeController();
+
+ DummyFrontendChannel channel;
+ protocol::UberDispatcher dispatcher(&channel);
+ auto reattach_state = mojom::blink::DevToolsSessionState::New();
+ InspectorSessionState session_state(std::move(reattach_state));
+
+ auto* agent = MakeGarbageCollected<InspectorEmulationAgent>(
+ web_frame, *virtual_time_controller);
+ agent->Init(frame->GetProbeSink(), &dispatcher, &session_state);
+
+ double base_ms = 0;
+ agent->setVirtualTimePolicy(protocol::Emulation::VirtualTimePolicyEnum::Pause,
+ std::nullopt, std::nullopt, std::nullopt,
+ &base_ms);
+
+ Persistent<DocumentLoader> loader2;
+ auto body_loader =
+ std::make_unique<MutatingBodyLoader>(agent, frame, &loader2);
+ MutatingBodyLoader* body_loader_ptr = body_loader.get();
+
+ auto params1 = std::make_unique<WebNavigationParams>();
+ params1->url = WebURL(KURL("https://example.com/test1.html"));
+ params1->body_loader = std::move(body_loader);
+
+ auto* loader1 = MakeGarbageCollected<DocumentLoader>(
+ frame, kWebNavigationTypeOther, std::move(params1), nullptr, nullptr);
+ loader1->StartLoading();
+
+ agent->WillCommitLoad(frame, loader1);
+
+ body_loader_ptr->SetTestReady(true);
+
+ agent->setVirtualTimePolicy(
+ protocol::Emulation::VirtualTimePolicyEnum::Advance, 100.0, std::nullopt,
+ std::nullopt, &base_ms);
+
+ loader1->SetSentDidFinishLoad();
+ loader1->StopLoading();
+ loader1->DetachFromFrame(false);
+
+ if (loader2) {
+ loader2->SetSentDidFinishLoad();
+ loader2->StopLoading();
+ loader2->DetachFromFrame(false);
+ }
+
+ virtual_time_controller->DisableVirtualTimeForTesting();
+ agent->disable();
+ agent->Dispose();
+ helper.Reset();
+}
+
+} // namespace
+
} // namespace blink
Original Bug Report
UAF in InspectorEmulationAgent::setVirtualTimePolicy due to reentrant vector mutation
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: During execution of InspectorEmulationAgent::setVirtualTimePolicy, unfreezing pending loaders can synchronously execute author scripts and spin a nested message loop via a scripted print request. If the virtual time budget expires during this nested loop, the policy reverts to Pause, allowing other concurrent navigation commits to mutate pending_document_loaders_. This results in vector reallocation and iterator invalidation, potentially leading to a Use-After-Free (UAF) in the renderer process.
Affected files:
third_party/blink/renderer/core/inspector/inspector_emulation_agent.ccthird_party/blink/renderer/core/inspector/inspector_emulation_agent.h
Estimated timestamp from git blame: 2020-11-11
Potential Use-After-Free (UAF) in InspectorEmulationAgent::setVirtualTimePolicy
Summary
There is a potential Use-After-Free (UAF) vulnerability in InspectorEmulationAgent::setVirtualTimePolicy due to synchronous script reentrancy and reentrant mutation of the pending_document_loaders_ vector during iteration.
Root Cause Analysis
In InspectorEmulationAgent::setVirtualTimePolicy (third_party/blink/renderer/core/inspector/inspector_emulation_agent.cc:603-605), the agent iterates over pending_document_loaders_ (a HeapVector<Member<DocumentLoader>>) directly with a range-based for loop:
for (DocumentLoader* loader : pending_document_loaders_)
loader->SetDefersLoading(LoaderFreezeMode::kNone);
pending_document_loaders_.clear();
When loader->SetDefersLoading(LoaderFreezeMode::kNone) is called on a loader, it propagates down to NavigationBodyLoader::SetDefersLoading which synchronously invokes OnReadable, reads from the Mojo data pipe, and triggers synchronous HTML parsing (kForceSynchronousParsing is enabled when virtual time is active).
This allows inline script blocks inside the parsing document to execute synchronously on the same stack. If this script spins a nested message loop, asynchronous tasks can run. If the virtual time budget expires inside the nested loop, VirtualTimeBudgetExpired is called and reverts virtual_time_policy_ to Pause.
When any concurrent navigation commit message is processed during the nested loop, InspectorEmulationAgent::WillCommitLoad is invoked. Because the policy was reverted to Pause, the loader is frozen and appended to pending_document_loaders_ via push_back:
void InspectorEmulationAgent::WillCommitLoad(LocalFrame*, DocumentLoader* loader) {
if (virtual_time_policy_.Get() != protocol::Emulation::VirtualTimePolicyEnum::Pause) return;
loader->SetDefersLoading(LoaderFreezeMode::kStrict);
pending_document_loaders_.push_back(loader);
}
This push_back mutates the active pending_document_loaders_ vector, potentially reallocating its backing store. When the nested loop exits and control returns to the range-based for loop, the loop’s iterators are invalidated, resulting in a Use-After-Free of the Member<DocumentLoader> backing storage.
Note: Since our tooling agent currently lacks the ability to execute code, these are potential steps and findings derived from code flow analysis.
Suggested Potential Exploitation Steps
An attacker might attempt to exploit this potential vulnerability using the following path:
- Target page runs under a CDP automation session where virtual time is managed (e.g. headless tests).
- The CDP client pauses virtual time (
policy: "pause"), causing multiple frame navigations to freeze and append theirDocumentLoaders topending_document_loaders_. - The CDP client resumes virtual time (
policy: "advance") with a small budget. - During the iteration, the first unpaused loader synchronously parses an inline script containing
parent.print(). This script targets a fully loaded parent frame to bypassLocalDOMWindow::print’s loading check, triggeringPrintRenderFrameHelper::RequestPrintPreview. - The scripted print request spins a nested
base::RunLoopwith nestable tasks allowed. - Inside the nested loop, the virtual time budget expires, reverting the policy to
Pause. - Also during the nested loop, a queued navigation commit dispatches, calling
WillCommitLoadand invokingpending_document_loaders_.push_back(), causing vector reallocation. - The print loop is terminated/dismissed, and the range-for resumes with invalidated iterators, leading to UAF.
Recommended Fix
To resolve this issue, adopt the pattern used to fix other reentrancy bugs in loader vectors (such as document_loader.cc:1946-1960) by snapshotting the collection before iterating. Move the elements out of pending_document_loaders_ using std::move or std::exchange before the loop, preventing any reentrant mutations from invalidating active iterators.
HeapVector<Member<DocumentLoader>> pending_loaders = std::move(pending_document_loaders_);
for (DocumentLoader* loader : pending_loaders)
loader->SetDefersLoading(LoaderFreezeMode::kNone);
Evaluated with Chrome root at commit: 75203b87cbf6681eb7c7dda8e1d0bf781538c76a
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.