Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in browser UI
DescriptionInsufficient policy enforcement in browser UI
ComponentChromium
Bug ClassLogic Error
Tracker487471101
Fix commita12e651f6f62 (chromium/src) +63/-0
CISA KEVNot listed
CreditedTianyi Hu
Disclosed2026-04-07

Files Changed

  • content/browser/bad_message.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
  • tools/metrics/histograms/metadata/stability/enums.xml
From a12e651f6f62614ab4a0c0eed6d686fec8414682 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <[email protected]>
Date: Sat, 28 Feb 2026 11:18:02 -0800
Subject: [PATCH] Add browser-side validation for allow-popups sandboxed frame attribute

Sandboxed frames created without an allow-popups attribute should not
be allowed to create popups. Previously, this attribute was only
checked in the renderer. This CL adds additional validation in
RenderFrameHostImpl::CreateNewWindow(), the browser process's
entrypoint for creating popups via web APIs like window.open().

Bug: 487471101
Change-Id: I0776f9d6df4200ecf20b29e8ecd18efa2edcd45a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7617636
Reviewed-by: Mark Pearson <[email protected]>
Auto-Submit: Alex Moshchuk <[email protected]>
Commit-Queue: Mark Pearson <[email protected]>
Reviewed-by: Charlie Reis <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1592057}
---

diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index 6873983..372417d 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -360,6 +360,7 @@
   RFH_INVALID_DOCUMENT_SEQUENCE_NUMBER = 332,
   RFH_NEW_ISOLATED_WEB_APP_PERMISSION_POLICIES = 333,
   RFH_CREATE_NEW_WINDOW_INVALID_DISPOSITION = 334,
+  RFH_CREATE_NEW_WINDOW_FROM_SANDBOXED_FRAME = 335,
 
   // Please add new elements here. The naming convention is abbreviated class
   // name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index a71222b..b5cd09d 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -9968,6 +9968,16 @@
     return;
   }
 
+  // Sandboxed frames should only be allowed to create a popup when they have
+  // the "allow-popups" attribute. This should have already been checked by the
+  // renderer process (see blink::CreateNewWindow()), and this browser-side
+  // check defends against compromised renderers.
+  if (IsSandboxed(network::mojom::WebSandboxFlags::kPopups)) {
+    bad_message::ReceivedBadMessage(
+        GetProcess(), bad_message::RFH_CREATE_NEW_WINDOW_FROM_SANDBOXED_FRAME);
+    return;
+  }
+
   // Fenced frames that have revoked network access can't open popups.
   if (base::FeatureList::IsEnabled(
           blink::features::kFencedFramesLocalUnpartitionedDataAccess)) {
diff --git a/content/browser/renderer_host/render_frame_host_impl.h b/content/browser/renderer_host/render_frame_host_impl.h
index e7821c9b..81c5099 100644
--- a/content/browser/renderer_host/render_frame_host_impl.h
+++ b/content/browser/renderer_host/render_frame_host_impl.h
@@ -3466,6 +3466,8 @@
                            AttemptDuplicateRenderWidgetHost);
   FRIEND_TEST_ALL_PREFIXES(SecurityExploitBrowserTest,
                            BindToWebUIFromWebViaMojo);
+  FRIEND_TEST_ALL_PREFIXES(SecurityExploitBrowserTest,
+                           WindowOpenDisallowedFromSandboxedFrame);
   FRIEND_TEST_ALL_PREFIXES(SitePerProcessBrowserTest,
                            RenderViewHostIsNotReusedAfterDelayedUnloadACK);
   FRIEND_TEST_ALL_PREFIXES(SitePerProcessBrowserTest,
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 4d0c4617..802f0ad 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1111,6 +1111,55 @@
             kill_waiter.Wait());
 }
 
+// Regression test for browser-side validation of the allow-popups sandbox
+// attribute. See https://crbug.com/487471101.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       WindowOpenDisallowedFromSandboxedFrame) {
+  // Explicitly isolating b.com helps establish consistent expectations for the
+  // sandboxed frame across platforms.
+  IsolateOrigin("b.com");
+
+  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();
+
+  // Create cross-site sandboxed child frame. The frame lacks the allow-popups
+  // attribute, so it should not be allowed to create popups.
+  GURL child_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+  {
+    std::string js_str = base::StringPrintf(
+        "var frame = document.createElement('iframe'); "
+        "frame.sandbox = 'allow-scripts'; "
+        "frame.src = '%s'; "
+        "document.body.appendChild(frame);",
+        child_url.spec().c_str());
+    EXPECT_TRUE(ExecJs(main_frame, js_str));
+    ASSERT_TRUE(WaitForLoadStop(web_contents));
+  }
+
+  RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+  EXPECT_TRUE(
+      subframe->GetSiteInstance()->GetSecurityPrincipal().IsSandboxed());
+  EXPECT_TRUE(subframe->IsSandboxed(network::mojom::WebSandboxFlags::kPopups));
+
+  // Simulate that the b.com renderer is compromised and sends an IPC to open a
+  // popup, bypassing renderer-side checks in blink::CreateNewWindow(). The
+  // browser process should detect this and terminate the renderer.
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+  mojom::CreateNewWindowParamsPtr params = mojom::CreateNewWindowParams::New();
+  params->target_url = GURL("about:blank");
+  params->disposition = WindowOpenDisposition::NEW_FOREGROUND_TAB;
+  subframe->CreateNewWindow(std::move(params), base::DoNothing());
+  EXPECT_EQ(bad_message::RFH_CREATE_NEW_WINDOW_FROM_SANDBOXED_FRAME,
+            kill_waiter.Wait());
+  EXPECT_FALSE(subframe->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
diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml
index bfaf4c1..de97da0 100644
--- a/tools/metrics/histograms/metadata/stability/enums.xml
+++ b/tools/metrics/histograms/metadata/stability/enums.xml
@@ -500,6 +500,7 @@
   <int value="332" label="RFH_INVALID_DOCUMENT_SEQUENCE_NUMBER"/>
   <int value="333" label="RFH_NEW_ISOLATED_WEB_APP_PERMISSION_POLICIES"/>
   <int value="334" label="RFH_CREATE_NEW_WINDOW_INVALID_DISPOSITION"/>
+  <int value="335" label="RFH_CREATE_NEW_WINDOW_FROM_SANDBOXED_FRAME"/>
 </enum>
 
 <enum name="BadMessageReasonExtensions">
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 4d0c4617..802f0ad 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1111,6 +1111,55 @@
             kill_waiter.Wait());
 }
 
+// Regression test for browser-side validation of the allow-popups sandbox
+// attribute. See https://crbug.com/487471101.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       WindowOpenDisallowedFromSandboxedFrame) {
+  // Explicitly isolating b.com helps establish consistent expectations for the
+  // sandboxed frame across platforms.
+  IsolateOrigin("b.com");
+
+  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();
+
+  // Create cross-site sandboxed child frame. The frame lacks the allow-popups
+  // attribute, so it should not be allowed to create popups.
+  GURL child_url(embedded_test_server()->GetURL("b.com", "/title2.html"));
+  {
+    std::string js_str = base::StringPrintf(
+        "var frame = document.createElement('iframe'); "
+        "frame.sandbox = 'allow-scripts'; "
+        "frame.src = '%s'; "
+        "document.body.appendChild(frame);",
+        child_url.spec().c_str());
+    EXPECT_TRUE(ExecJs(main_frame, js_str));
+    ASSERT_TRUE(WaitForLoadStop(web_contents));
+  }
+
+  RenderFrameHostImpl* subframe = root->child_at(0)->current_frame_host();
+  EXPECT_TRUE(
+      subframe->GetSiteInstance()->GetSecurityPrincipal().IsSandboxed());
+  EXPECT_TRUE(subframe->IsSandboxed(network::mojom::WebSandboxFlags::kPopups));
+
+  // Simulate that the b.com renderer is compromised and sends an IPC to open a
+  // popup, bypassing renderer-side checks in blink::CreateNewWindow(). The
+  // browser process should detect this and terminate the renderer.
+  RenderProcessHostBadIpcMessageWaiter kill_waiter(subframe->GetProcess());
+  mojom::CreateNewWindowParamsPtr params = mojom::CreateNewWindowParams::New();
+  params->target_url = GURL("about:blank");
+  params->disposition = WindowOpenDisposition::NEW_FOREGROUND_TAB;
+  subframe->CreateNewWindow(std::move(params), base::DoNothing());
+  EXPECT_EQ(bad_message::RFH_CREATE_NEW_WINDOW_FROM_SANDBOXED_FRAME,
+            kill_waiter.Wait());
+  EXPECT_FALSE(subframe->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]

Sandboxed iframe bypasses allow-popups restriction via CreateNewWindow Mojo IPC — browser has zero sandbox enforcement


Report description

Sandboxed iframe bypasses allow-popups restriction via CreateNewWindow Mojo IPC — browser has zero sandbox enforcement


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/renderer_host/render_frame_host_impl.cc


The problem

Please describe the technical details of the vulnerability

RenderFrameHostImpl::CreateNewWindow() in content/browser/renderer_host/render_frame_host_impl.cc does not check the allow-popups sandbox flag (WebSandboxFlags::kPopups) before opening a new window. The sandbox enforcement for popup creation is only performed on the renderer side in create_window.cc. A compromised renderer in a sandboxed iframe can call the CreateNewWindow Mojo IPC directly with allow_popup=true, bypassing all Blink-side sandbox enforcement and opening arbitrary popups.

Vulnerable files:

Transmission chain:

  1. A sandboxed <iframe sandbox="allow-scripts allow-same-origin"> (without allow-popups) contains a compromised renderer.
  2. The renderer patches out the Blink-side IsSandboxed(kPopups) check in create_window.cc:347-358 — the only enforcement point — and sets params->allow_popup = true unconditionally in render_frame_impl.cc:6779-6781.
  3. RenderFrameHostImpl::CreateNewWindow() receives the IPC. It computes effective_transient_activation_state by OR-ing params->allow_popup directly:
// render_frame_host_impl.cc:9994-9997
bool effective_transient_activation_state =
    params->allow_popup || HasTransientUserActivation() ||
    (transient_allow_popup_.IsActive() &&
     params->disposition == WindowOpenDisposition::NEW_POPUP);
  1. Since params->allow_popup = true, effective_transient_activation_state is true regardless of any sandbox restrictions.
  2. CanCreateWindow() is called with effective_transient_activation_state=true — popup is allowed. No IsSandboxed() check anywhere in the browser handler.
  3. The new window opens in full non-sandboxed browser context.

The renderer-side sandbox check in create_window.cc:

// create_window.cc:347-358
if (opener_window.IsSandboxed(
        network::mojom::blink::WebSandboxFlags::kPopups)) {
  opener_window.AddConsoleMessage(...
      "Blocked opening '...' in a new window because the request was made "
      "in a sandboxed frame whose 'allow-popups' permission is not set.");
  return nullptr;
}

…is Blink renderer code only. The browser-side CreateNewWindow() handler has no equivalent checkIsSandboxed() and WebSandboxFlags::kPopups do not appear anywhere in the method.

Steps to reproduce:

  1. Apply h6_patch_renderer_poc.diff to chromium-stable/src/ (Chrome 145.0.7632.117):
    cd chromium-stable/src
    git apply h6_patch_renderer_poc.diff
    
  2. Build Chrome:
    autoninja -C out/Default chrome
    
  3. Start the PoC HTTP server:
    python3 serve.py
    
  4. Launch the patched Chrome:
    out/Default/Chromium.app/Contents/MacOS/Chromium \
      --user-data-dir=/tmp/chrome-h6-test \
      http://localhost:8080/index.html
    
  5. After 1 second, pwned.html opens in a new popup window — despite the iframe having sandbox="allow-scripts allow-same-origin" with no allow-popups.

h6_patch_renderer_poc.diff simulates a compromised renderer by (1) removing the IsSandboxed(kPopups) check from create_window.cc and (2) forcing params->allow_popup = true unconditionally in render_frame_impl.cc. In practice, a compromised renderer would call the CreateNewWindow Mojo IPC directly with allow_popup=true, skipping Blink entirely.

Bisect:

Introducing commit: c4cb716e319efaa1c2582565c5f18965dfa39366

Evidence:

  • Gitiles blame on render_frame_host_impl.cc line 9994 (params->allow_popup || HasTransientUserActivation()) traces directly to this commit.
  • This CL introduced effective_transient_activation_state which OR-s the renderer-supplied mimic_user_gesture (later renamed allow_popup) into the browser’s activation decision, with no IsSandboxed(kPopups) check.
  • The field was later renamed mimic_user_gestureallow_popup in commit 8601a5646d6e (Mustaq Ahmed, 2020-01-31, CL: https://chromium-review.googlesource.com/c/chromium/src/+/2031187, refs/heads/master@{#737378}), but the missing browser-side sandbox check persisted.
  • The renderer-side IsSandboxed(kPopups) check in create_window.cc has existed since the early Blink sandbox implementation and was never mirrored browser-side.

Earliest affected: Chrome M68 (stable July 2018, when c4cb716e landed). Latest confirmed: Chrome M145 (145.0.7632.117, current Stable). Vulnerable for ~7.5 years.

Suggested fix:

I will upload a Gerrit CL with this fix to chromium-review.googlesource.com and add the link in a follow-up comment. h6_fix.diff is attached for reference.

Add a browser-side sandbox check in RenderFrameHostImpl::CreateNewWindow(), after the fenced frame check and before effective_transient_activation_state is computed:

+  // Sandboxed frames without allow-popups cannot open new windows.
+  // This enforces the kPopups sandbox flag browser-side; the renderer-side
+  // check in create_window.cc can be bypassed by a compromised renderer
+  // sending allow_popup=true in the Mojo params.
+  if (IsSandboxed(network::mojom::WebSandboxFlags::kPopups)) {
+    bad_message::ReceivedBadMessage(
+        GetProcess(),
+        bad_message::RFH_CREATE_NEW_WINDOW_FROM_SANDBOXED_FRAME);
+    std::move(callback).Run(mojom::CreateNewWindowStatus::kBlocked, nullptr);
+    return;
+  }

And in bad_message.h, after RFH_CREATE_NEW_WINDOW_INVALID_DISPOSITION = 334:

+  RFH_CREATE_NEW_WINDOW_FROM_SANDBOXED_FRAME = 335,

This mirrors the renderer-side check but enforces it browser-side with ReceivedBadMessage to kill the compromised renderer. IsSandboxed() uses the browser’s own active_sandbox_flags() which cannot be forged by a compromised renderer.

Verified: applying both h6_patch_renderer_poc.diff and h6_fix.diff, the exploit is blocked and the renderer process is terminated. Normal popups from non-sandboxed frames still work correctly.

Impact analysis

A compromised renderer inside a sandboxed iframe (e.g., a sandboxed ad frame with sandbox="allow-scripts allow-same-origin") can open arbitrary popup windows, bypassing the allow-popups sandbox restriction.

Exploitation requires a compromised renderer (renderer RCE), which is a standard assumption in Chrome’s threat model. The attacker gains the ability to:

  • Open arbitrary popups from content that is explicitly sandboxed to prevent popup creation
  • Bypass the iframe sandbox contract that embedders rely on to restrict ad/third-party content
  • UI spoofing and phishing — a sandboxed ad frame could open convincing browser windows mimicking security dialogs or login pages
  • Popup flood attacks — repeatedly open windows from a sandboxed context that should not have this capability
  • Bypass permission-policy enforcement — sites use allow-popups restriction to prevent untrusted third-party content from opening new browsing contexts

The allow-popups sandbox flag exists specifically to prevent untrusted content from opening new windows. The browser’s failure to enforce this flag means the sandbox guarantee is illusory against a compromised renderer.

This is the same vulnerability class as H4 (download sandbox bypass via DownloadURL) and H1 (user gesture spoofing), both previously accepted. The pattern: renderer-only enforcement of a WebSandboxFlags restriction, with the browser unconditionally trusting a renderer-supplied parameter.


The cause

What version of Chrome have you found the security issue in?

145.0.7632.117 (Stable, current as of February 25, 2026)

No, it is not related to a crash.

Choose the type of vulnerability

Site Isolation Bypass

How would you like to be publicly acknowledged for your report?

Tianyi Hu

View on issue tracker