CVE-2026-106406
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifios/chrome/browser/print/coordinator/print_coordinator_impl.mm |
modified | |
TEST_Fios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm |
modified |
Files Changed
ios/chrome/browser/print/coordinator/print_coordinator_impl.mmios/chrome/browser/print/coordinator/print_coordinator_impl.swiftios/chrome/browser/web/model/print/print_java_script_feature_unittest.mmios/chrome/browser/web/model/print/BUILD.gnios/chrome/browser/web/model/print/print_java_script_feature.mm
Patch
From 52fc98ec96e0a537026ea452640075b1080adc58 Mon Sep 17 00:00:00 2001
From: Daniel White <danieltwhite@google.com>
Date: Fri, 11 Sep 2026 10:22:47 -0700
Subject: [PATCH] [iOS] Ignore window.print() from background tabs
UIPrintInteractionController is a process-wide singleton, so a
window.print() call from a background tab would overwrite the delegate,
renderer and parent view controller of any print sheet already on screen
for the foreground tab.
Drop window.print() messages when the originating WebState is not
visible, and have PrintCoordinator ignore new requests while the shared
controller already has a delegate. Clear the controller's delegate,
renderer and item on completion and on -stop so the WKWebView-backed
renderer is released and subsequent requests see a clean state.
Bug: 505186109
Change-Id: I887e2fab0ac182fe587f6888427337e7c5afb9dd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8384938
Reviewed-by: Sylvain Defresne <sdefresne@chromium.org>
Commit-Queue: Daniel White <danieltwhite@google.com>
Cr-Commit-Position: refs/heads/main@{#1696394}
---
diff --git a/ios/chrome/browser/print/coordinator/print_coordinator_impl.mm b/ios/chrome/browser/print/coordinator/print_coordinator_impl.mm
index 7dbac8a..988b253b 100644
--- a/ios/chrome/browser/print/coordinator/print_coordinator_impl.mm
+++ b/ios/chrome/browser/print/coordinator/print_coordinator_impl.mm
@@ -29,6 +29,14 @@
#pragma mark - ChromeCoordinator
- (void)stop {
+ UIPrintInteractionController* printInteractionController =
+ [UIPrintInteractionController sharedPrintController];
+ if (printInteractionController.delegate == self) {
+ printInteractionController.delegate = nil;
+ printInteractionController.printPageRenderer = nil;
+ printInteractionController.printingItem = nil;
+ printInteractionController.printInfo = nil;
+ }
self.defaultBaseViewController = nil;
}
@@ -82,10 +90,16 @@
CHECK_EQ((renderer ? 1 : 0) + (item ? 1 : 0), 1);
CHECK(title);
CHECK(baseViewController);
- self.defaultBaseViewController = baseViewController;
- base::RecordAction(base::UserMetricsAction("MobilePrintMenuAirPrint"));
UIPrintInteractionController* printInteractionController =
[UIPrintInteractionController sharedPrintController];
+ if (printInteractionController.delegate) {
+ // The shared print controller is already presenting on behalf of this or
+ // another coordinator. Ignore this request rather than mutating the state
+ // of the in-progress session.
+ return;
+ }
+ self.defaultBaseViewController = baseViewController;
+ base::RecordAction(base::UserMetricsAction("MobilePrintMenuAirPrint"));
printInteractionController.delegate = self;
UIPrintInfo* printInfo = [UIPrintInfo printInfo];
@@ -104,6 +118,10 @@
DLOG(ERROR) << "Air printing error: "
<< base::SysNSStringToUTF8(error.description);
}
+ controller.delegate = nil;
+ controller.printPageRenderer = nil;
+ controller.printingItem = nil;
+ controller.printInfo = nil;
}];
}
diff --git a/ios/chrome/browser/print/coordinator/print_coordinator_impl.swift b/ios/chrome/browser/print/coordinator/print_coordinator_impl.swift
index 65fbfebe..fddff3f 100644
--- a/ios/chrome/browser/print/coordinator/print_coordinator_impl.swift
+++ b/ios/chrome/browser/print/coordinator/print_coordinator_impl.swift
@@ -27,6 +27,13 @@
@objc override func stop() {
MainActor.assumeIsolated {
+ let printInteractionController = UIPrintInteractionController.shared
+ if printInteractionController.delegate === self {
+ printInteractionController.delegate = nil
+ printInteractionController.printPageRenderer = nil
+ printInteractionController.printingItem = nil
+ printInteractionController.printInfo = nil
+ }
defaultBaseViewController = nil
}
}
@@ -71,9 +78,15 @@
) {
// Only one item must be passed.
assert((renderer != nil) != (item != nil))
+ let printInteractionController = UIPrintInteractionController.shared
+ if printInteractionController.delegate != nil {
+ // The shared print controller is already presenting on behalf of this or
+ // another coordinator. Ignore this request rather than mutating the state
+ // of the in-progress session.
+ return
+ }
self.defaultBaseViewController = baseViewController
base.swift.RecordUserMetricsAction("MobilePrintMenuAirPrint")
- let printInteractionController = UIPrintInteractionController.shared
printInteractionController.delegate = self
let printInfo = UIPrintInfo.printInfo()
@@ -90,6 +103,10 @@
if let error = error {
NSLog("Air printing error: %@", error.localizedDescription)
}
+ controller.delegate = nil
+ controller.printPageRenderer = nil
+ controller.printingItem = nil
+ controller.printInfo = nil
})
}
}
diff --git a/ios/chrome/browser/web/model/print/print_java_script_feature.mm b/ios/chrome/browser/web/model/print/print_java_script_feature.mm
index 8bda5bb6..f460ba6 100644
--- a/ios/chrome/browser/web/model/print/print_java_script_feature.mm
+++ b/ios/chrome/browser/web/model/print/print_java_script_feature.mm
@@ -36,6 +36,15 @@
const web::ScriptMessage& message) {
DCHECK(web_state);
+ // TODO(crbug.com/560132282): Extend this condition to cover pre-rendered tabs
+ // as well.
+ if (!web_state->IsVisible()) {
+ // Ignore window.print() calls from background tabs. The system print
+ // controller is process-wide and showing it for a hidden WebState would
+ // present over (or replace) the foreground tab's UI.
+ return;
+ }
+
if (!message.is_main_frame() && !message.is_user_interacting()) {
// Ignore non user-initiated window.print() calls from iframes, to prevent
// abusive behavior from web sites.
diff --git a/ios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm b/ios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm
index 26a63219..5cce02c 100644
--- a/ios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm
+++ b/ios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm
@@ -8,6 +8,7 @@
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/web/model/print/print_handler.h"
#import "ios/chrome/browser/web/model/print/print_tab_helper.h"
+#import "ios/web/public/js_messaging/script_message.h"
#import "ios/web/public/test/fakes/fake_web_client.h"
#import "ios/web/public/test/scoped_testing_web_client.h"
#import "ios/web/public/test/web_state_test_util.h"
@@ -15,6 +16,7 @@
#import "ios/web/public/test/web_view_interaction_test_util.h"
#import "ios/web/public/web_state.h"
#import "testing/platform_test.h"
+#import "url/origin.h"
namespace {
const char kButtonPageHtml[] =
@@ -72,6 +74,15 @@
}
web::WebState* web_state() { return web_state_.get(); }
+ // Delivers a script message directly to the feature, simulating a
+ // window.print() call from a frame with the given properties.
+ void DeliverPrintMessage(bool is_user_interacting, bool is_main_frame) {
+ web::ScriptMessage message(std::make_unique<base::Value>(),
+ is_user_interacting, is_main_frame,
+ /*request_url=*/std::nullopt, url::Origin());
+ feature_.ScriptMessageReceived(web_state(), message);
+ }
+
web::ScopedTestingWebClient web_client_;
web::WebTaskEnvironment task_environment_;
std::unique_ptr<TestProfileIOS> profile_;
@@ -83,8 +94,39 @@
TEST_F(PrintJavaScriptFeatureTest, PrintInvoked) {
ASSERT_FALSE(printer_.printInvoked);
+ web_state()->WasShown();
web::test::LoadHtml(base::SysUTF8ToNSString(kButtonPageHtml), web_state());
ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), "button"));
EXPECT_TRUE(printer_.printInvoked);
}
+
+// Tests that window.print() from the main frame of a visible WebState is
+// forwarded to the printer.
+TEST_F(PrintJavaScriptFeatureTest, PrintFromVisibleMainFrame) {
+ ASSERT_FALSE(printer_.printInvoked);
+
+ web_state()->WasShown();
+ DeliverPrintMessage(/*is_user_interacting=*/false, /*is_main_frame=*/true);
+ EXPECT_TRUE(printer_.printInvoked);
+}
+
+// Tests that window.print() from the main frame of a hidden WebState is
+// ignored.
+TEST_F(PrintJavaScriptFeatureTest, PrintIgnoredFromHiddenMainFrame) {
+ ASSERT_FALSE(printer_.printInvoked);
+
+ web_state()->WasHidden();
+ DeliverPrintMessage(/*is_user_interacting=*/false, /*is_main_frame=*/true);
+ EXPECT_FALSE(printer_.printInvoked);
+}
+
+// Tests that window.print() from a subframe of a hidden WebState is ignored
+// even when the user is interacting.
+TEST_F(PrintJavaScriptFeatureTest, PrintIgnoredFromHiddenSubframe) {
+ ASSERT_FALSE(printer_.printInvoked);
+
+ web_state()->WasHidden();
+ DeliverPrintMessage(/*is_user_interacting=*/true, /*is_main_frame=*/false);
+ EXPECT_FALSE(printer_.printInvoked);
+}
diff --git a/ios/chrome/browser/web/model/print/BUILD.gn b/ios/chrome/browser/web/model/print/BUILD.gn
index c189526e..0e7767bb 100644
--- a/ios/chrome/browser/web/model/print/BUILD.gn
+++ b/ios/chrome/browser/web/model/print/BUILD.gn
@@ -41,8 +41,10 @@
"//ios/chrome/browser/shared/model/prefs:pref_names",
"//ios/chrome/browser/shared/model/profile/test",
"//ios/chrome/test/fakes",
+ "//ios/web/public/js_messaging",
"//ios/web/public/test",
"//testing/gtest",
+ "//url",
]
}
Regression Test / PoC
diff --git a/ios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm b/ios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm
index 26a63219..5cce02c 100644
--- a/ios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm
+++ b/ios/chrome/browser/web/model/print/print_java_script_feature_unittest.mm
@@ -8,6 +8,7 @@
#import "ios/chrome/browser/shared/model/profile/test/test_profile_ios.h"
#import "ios/chrome/browser/web/model/print/print_handler.h"
#import "ios/chrome/browser/web/model/print/print_tab_helper.h"
+#import "ios/web/public/js_messaging/script_message.h"
#import "ios/web/public/test/fakes/fake_web_client.h"
#import "ios/web/public/test/scoped_testing_web_client.h"
#import "ios/web/public/test/web_state_test_util.h"
@@ -15,6 +16,7 @@
#import "ios/web/public/test/web_view_interaction_test_util.h"
#import "ios/web/public/web_state.h"
#import "testing/platform_test.h"
+#import "url/origin.h"
namespace {
const char kButtonPageHtml[] =
@@ -72,6 +74,15 @@
}
web::WebState* web_state() { return web_state_.get(); }
+ // Delivers a script message directly to the feature, simulating a
+ // window.print() call from a frame with the given properties.
+ void DeliverPrintMessage(bool is_user_interacting, bool is_main_frame) {
+ web::ScriptMessage message(std::make_unique<base::Value>(),
+ is_user_interacting, is_main_frame,
+ /*request_url=*/std::nullopt, url::Origin());
+ feature_.ScriptMessageReceived(web_state(), message);
+ }
+
web::ScopedTestingWebClient web_client_;
web::WebTaskEnvironment task_environment_;
std::unique_ptr<TestProfileIOS> profile_;
@@ -83,8 +94,39 @@
TEST_F(PrintJavaScriptFeatureTest, PrintInvoked) {
ASSERT_FALSE(printer_.printInvoked);
+ web_state()->WasShown();
web::test::LoadHtml(base::SysUTF8ToNSString(kButtonPageHtml), web_state());
ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), "button"));
EXPECT_TRUE(printer_.printInvoked);
}
+
+// Tests that window.print() from the main frame of a visible WebState is
+// forwarded to the printer.
+TEST_F(PrintJavaScriptFeatureTest, PrintFromVisibleMainFrame) {
+ ASSERT_FALSE(printer_.printInvoked);
+
+ web_state()->WasShown();
+ DeliverPrintMessage(/*is_user_interacting=*/false, /*is_main_frame=*/true);
+ EXPECT_TRUE(printer_.printInvoked);
+}
+
+// Tests that window.print() from the main frame of a hidden WebState is
+// ignored.
+TEST_F(PrintJavaScriptFeatureTest, PrintIgnoredFromHiddenMainFrame) {
+ ASSERT_FALSE(printer_.printInvoked);
+
+ web_state()->WasHidden();
+ DeliverPrintMessage(/*is_user_interacting=*/false, /*is_main_frame=*/true);
+ EXPECT_FALSE(printer_.printInvoked);
+}
+
+// Tests that window.print() from a subframe of a hidden WebState is ignored
+// even when the user is interacting.
+TEST_F(PrintJavaScriptFeatureTest, PrintIgnoredFromHiddenSubframe) {
+ ASSERT_FALSE(printer_.printInvoked);
+
+ web_state()->WasHidden();
+ DeliverPrintMessage(/*is_user_interacting=*/true, /*is_main_frame=*/false);
+ EXPECT_FALSE(printer_.printInvoked);
+}
Original Bug Report
State Corruption in UIPrintInteractionController leads to Spoofing and DoS
Flapjack, 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 without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: The PrintCoordinatorImpl unconditionally overwrites the shared UIPrintInteractionController singleton properties without verifying if a print session is active. A background tab can trigger a print request via window.print(), hijacking an ongoing print dialog in a foreground tab. This leads to potential print content spoofing, a browser crash (DoS) due to a window hierarchy mismatch, and a persistent memory leak.
Affected files:
ios/chrome/browser/print/coordinator/print_coordinator_impl.mm
Estimated timestamp from git blame: 2026-01-16
Vulnerability Mechanism
In iOS Chrome, printing is managed using the UIPrintInteractionController system singleton ([UIPrintInteractionController sharedPrintController]).
When a print job is initiated, PrintCoordinatorImpl::printRenderer:orItem:withTitle:baseViewController: immediately and unconditionally modifies the singleton’s properties (delegate, printInfo, printPageRenderer, and printingItem) before calling presentAnimated:.
Simultaneously, PrintJavaScriptFeature::ScriptMessageReceived handles window.print() calls from web content. It checks !message.is_main_frame() && !message.is_user_interacting() to block background iframes. However, a background main frame satisfies message.is_main_frame() and is allowed to proceed without user interaction.
Because the UIPrintInteractionController state is shared across the entire application (even across multiple windows in an iPad split-view scenario), a background tab can overwrite the properties of an active, user-initiated print dialog currently on screen.
Potential Security Impacts
-
Print Content Spoofing: If a user is reviewing a legitimate document in the system print dialog, and a background tab fires
window.print(), theprintPageRendereron the singleton is replaced. UIKit does not snapshot the renderer at presentation time; it reads from it when the spooler processes the job. If the user taps “Print”, they will unknowingly print the attacker’s fraudulent content instead of the expected document. -
Denial of Service (Browser Crash): When the background tab initiates printing, its
PrintCoordinatorImplsets itself as the singleton’sdelegate. If the background tab belongs to a different window (e.g., in iPad split-view), the delegate’sprintInteractionControllerParentViewController:method returns abaseViewControllerfrom the background window hierarchy. UIKit attempts to present the print interaction controller (or reconcile an existing presentation) using a view controller from a different, inactive window hierarchy, triggering anNSInternalInconsistencyExceptionthat crashes the Chrome process. -
Memory Leak: The completion handler passed to
presentAnimated:completionHandler:does not clear the singleton’s properties after the dialog is dismissed. TheUIPrintInteractionControllersingleton indefinitely retains the last usedUIPrintPageRenderer, which retains theWKWebView. This causes a substantial memory leak of the web view after every print job.
Potential Steps to Trigger
(Note: These are suggested steps based on static analysis, as our tooling cannot execute a live proof of concept yet)
- An attacker lures a user to a malicious site, and instructs them to open a legitimate, sensitive site (e.g., a bank statement) in a second window (split-view) or a foreground tab.
- The user initiates a print action on the legitimate site.
- The system print dialog is displayed, showing the legitimate document.
- The malicious site, using a timer or visibility API listener, executes
window.print()in its main frame. - The background print request bypasses interaction checks and updates the
UIPrintInteractionControllersingleton with the malicious site’s content and delegate. - Either the browser crashes immediately due to the delegate/hierarchy mismatch, or the user proceeds to print, generating a spoofed physical/PDF document containing the attacker’s content.
Recommendation
- State Checking: Before overwriting properties on
[UIPrintInteractionController sharedPrintController], verify if a print session is already active (e.g., checkprintInteractionController.delegate != nil). If a session is active, ignore or queue the background request. - Restrict Background Printing: Update
PrintJavaScriptFeature::ScriptMessageReceivedto checkweb_state->IsVisible()to ensure only the active, visible tab can trigger printing. - Cleanup: In the
completionHandlerofpresentAnimated:, setdelegate = nil,printPageRenderer = nil, andprintingItem = nilto prevent memory leaks.
Evaluated with Chrome root at commit: 4a3e9db74111a3c6c4b3acfd70050a05077cf27a
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.