Low chrome Sandbox Escape 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactPolicy bypass in IFrameSandbox
DescriptionPolicy bypass in IFrameSandbox
ComponentIFrameSandbox
Bug ClassSandbox Escape
Tracker483771899
Fix commit874355605d63 (chromium/src) +103/-1
CISA KEVNot listed
Credited@Ciarands
Disclosed2026-04-07

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/frame/web_frame_test.cc
modified
IframeBeginNavivationCountTestWebFrameClient
third_party/blink/renderer/core/frame/web_frame_test.cc
modified
TEST_F
third_party/blink/renderer/core/frame/web_frame_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/frame/frame_test_helpers.h
  • third_party/blink/renderer/core/frame/web_frame_test.cc
  • third_party/blink/renderer/core/loader/frame_loader.cc
  • third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
  • third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html
From 874355605d632c59770046b1a1fc1582f68361dc Mon Sep 17 00:00:00 2001
From: Nate Chapin <japhet@chromium.org>
Date: Fri, 20 Feb 2026 11:02:15 -0800
Subject: [PATCH] FrameLoader::StartNavigation should not allow emulated Ctrl+click from sandboxed iframes

Fixed: 483771899
Change-Id: I1e134f0c9dcfbb4760d339d909a7d7339a5e3077
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7572487
Commit-Queue: Nate Chapin <japhet@chromium.org>
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1587969}
---

diff --git a/third_party/blink/renderer/core/frame/frame_test_helpers.h b/third_party/blink/renderer/core/frame/frame_test_helpers.h
index 740591fa..6b0e55e1 100644
--- a/third_party/blink/renderer/core/frame/frame_test_helpers.h
+++ b/third_party/blink/renderer/core/frame/frame_test_helpers.h
@@ -581,6 +581,14 @@
     return sandbox_flags_;
   }
 
+  // Subclasses that override CreateChildFrame() to gave the child frame a
+  // custom TestWebFrameClient subclass lose the propagation of sandbox flags
+  // that is performed in TestWebFrameClient::CreateChildFrame(). This allows
+  // such cases to set the flags manually.
+  void set_sandbox_flags(network::mojom::WebSandboxFlags flags) {
+    sandbox_flags_ = flags;
+  }
+
   void DestroyChildViews();
 
   void SetFrameDetachedCallback(base::OnceClosure callback);
diff --git a/third_party/blink/renderer/core/frame/web_frame_test.cc b/third_party/blink/renderer/core/frame/web_frame_test.cc
index c274456..b12511d 100644
--- a/third_party/blink/renderer/core/frame/web_frame_test.cc
+++ b/third_party/blink/renderer/core/frame/web_frame_test.cc
@@ -7653,8 +7653,8 @@
 
   // frame_test_helpers::TestWebFrameClient:
   void BeginNavigation(std::unique_ptr<WebNavigationInfo> info) override {
+    begin_navigation_call_count_++;
     if (ignore_navigations_) {
-      begin_navigation_call_count_++;
       return;
     }
     TestWebFrameClient::BeginNavigation(std::move(info));
@@ -14845,5 +14845,56 @@
   EXPECT_EQ(old_parent->ConnectedSubframeCount(), 0u);
   EXPECT_EQ(new_parent->ConnectedSubframeCount(), 1u);
 }
+class IframeBeginNavivationCountTestWebFrameClient
+    : public frame_test_helpers::TestWebFrameClient {
+ public:
+  IframeBeginNavivationCountTestWebFrameClient() = default;
+  ~IframeBeginNavivationCountTestWebFrameClient() override = default;
+
+  // WebLocalFrameClient:
+  WebLocalFrame* CreateChildFrame(
+      mojom::blink::TreeScopeType scope,
+      const WebString& name,
+      const WebString& fallback_name,
+      const FramePolicy& frame_policy,
+      const WebFrameOwnerProperties&,
+      FrameOwnerElementType,
+      WebPolicyContainerBindParams policy_container_bind_params,
+      ukm::SourceId document_ukm_source_id,
+      FinishChildFrameCreationFn finish_creation) override {
+    auto client = std::make_unique<TestNewWindowWebFrameClient>();
+    client_ = client.get();
+    client_->set_sandbox_flags(frame_policy.sandbox_flags);
+    return CreateLocalChild(*Frame(), scope, std::move(client),
+                            std::move(policy_container_bind_params),
+                            finish_creation);
+  }
+
+  TestNewWindowWebFrameClient* iframe_client() const { return client_; }
+
+ private:
+  TestNewWindowWebFrameClient* client_ = nullptr;
+};
+
+TEST_F(WebFrameTest, SandboxedIframePopupCtrlClick) {
+  RegisterMockedHttpURLLoad("sandboxed-srcdoc-ctrl-click.html");
+  IframeBeginNavivationCountTestWebFrameClient web_frame_client;
+  frame_test_helpers::WebViewHelper web_view_helper;
+  web_view_helper.InitializeAndLoad(
+      base_url_ + "sandboxed-srcdoc-ctrl-click.html", &web_frame_client);
+
+  ASSERT_EQ(web_frame_client.iframe_client()->BeginNavigationCallCount(), 1);
+
+  LocalFrame* child = To<LocalFrame>(
+      web_view_helper.GetWebView()->GetPage()->MainFrame()->FirstChild());
+  Element* element =
+      child->GetDocument()->body()->getElementById(AtomicString("btn"));
+  To<HTMLElement>(element)->click();
+
+  // Clicking the button will attempt a synthetic Ctrl+Click from an iframe
+  // sandboxed without `allow-popups`. This should be blocked before reaching
+  // BeginNavigation().
+  EXPECT_EQ(web_frame_client.iframe_client()->BeginNavigationCallCount(), 1);
+}
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/loader/frame_loader.cc b/third_party/blink/renderer/core/loader/frame_loader.cc
index 64e74d0..f413c08d 100644
--- a/third_party/blink/renderer/core/loader/frame_loader.cc
+++ b/third_party/blink/renderer/core/loader/frame_loader.cc
@@ -868,6 +868,17 @@
     return;
   }
 
+  // A sandboxed iframe without `allow-popups` should not be able to
+  // open a new browsing context by emulating a user gesture in JS.
+  // (e.g. Ctrl+click).
+  if (request.GetNavigationPolicy() != kNavigationPolicyCurrentTab &&
+      request.GetTriggeringEventInfo() ==
+          mojom::blink::TriggeringEventInfo::kFromUntrustedEvent &&
+      frame_->GetSecurityContext()->IsSandboxed(
+          network::mojom::blink::WebSandboxFlags::kPopups)) {
+    return;
+  }
+
   if (request.GetNavigationPolicy() == kNavigationPolicyCurrentTab &&
       (!origin_window || origin_window->GetSecurityOrigin()->CanAccess(
                              frame_->DomWindow()->GetSecurityOrigin()))) {
diff --git a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
index d20416c3..03dd574b0 100644
--- a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
+++ b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
@@ -433,6 +433,7 @@
 testing/data/rtl-iframe-inner.html
 testing/data/rtl-iframe.html
 testing/data/rtl-overview-mode.html
+testing/data/sandboxed-srcdoc-ctrl-click.html
 testing/data/scale_oscillate.html
 testing/data/script/nested-script.js
 testing/data/script/not-parser-blocked-async-nested-script.html
diff --git a/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html b/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html
new file mode 100644
index 0000000..b325b44
--- /dev/null
+++ b/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html
@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+<body>
+<iframe
+    sandbox="allow-scripts"
+    srcdoc='
+        <div id="container"></div>
+        <script>
+            let clicked = false;
+            const btn = document.createElement("a");
+            btn.id = "btn";
+            btn.href = "about:blank";
+            document.body.appendChild(btn);
+
+            btn.addEventListener("click", (event) => {
+                if (clicked) return;
+                clicked = true;
+                event.preventDefault();
+                const clickEvent = new MouseEvent("click", {
+                    ctrlKey: true,
+                    metaKey: true,
+                    bubbles: true,
+                    cancelable: true,
+                });
+                btn.dispatchEvent(clickEvent);
+            });
+        </script>
+'
+>
+</iframe>
+</html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/frame/web_frame_test.cc b/third_party/blink/renderer/core/frame/web_frame_test.cc
index c274456..b12511d 100644
--- a/third_party/blink/renderer/core/frame/web_frame_test.cc
+++ b/third_party/blink/renderer/core/frame/web_frame_test.cc
@@ -7653,8 +7653,8 @@
 
   // frame_test_helpers::TestWebFrameClient:
   void BeginNavigation(std::unique_ptr<WebNavigationInfo> info) override {
+    begin_navigation_call_count_++;
     if (ignore_navigations_) {
-      begin_navigation_call_count_++;
       return;
     }
     TestWebFrameClient::BeginNavigation(std::move(info));
@@ -14845,5 +14845,56 @@
   EXPECT_EQ(old_parent->ConnectedSubframeCount(), 0u);
   EXPECT_EQ(new_parent->ConnectedSubframeCount(), 1u);
 }
+class IframeBeginNavivationCountTestWebFrameClient
+    : public frame_test_helpers::TestWebFrameClient {
+ public:
+  IframeBeginNavivationCountTestWebFrameClient() = default;
+  ~IframeBeginNavivationCountTestWebFrameClient() override = default;
+
+  // WebLocalFrameClient:
+  WebLocalFrame* CreateChildFrame(
+      mojom::blink::TreeScopeType scope,
+      const WebString& name,
+      const WebString& fallback_name,
+      const FramePolicy& frame_policy,
+      const WebFrameOwnerProperties&,
+      FrameOwnerElementType,
+      WebPolicyContainerBindParams policy_container_bind_params,
+      ukm::SourceId document_ukm_source_id,
+      FinishChildFrameCreationFn finish_creation) override {
+    auto client = std::make_unique<TestNewWindowWebFrameClient>();
+    client_ = client.get();
+    client_->set_sandbox_flags(frame_policy.sandbox_flags);
+    return CreateLocalChild(*Frame(), scope, std::move(client),
+                            std::move(policy_container_bind_params),
+                            finish_creation);
+  }
+
+  TestNewWindowWebFrameClient* iframe_client() const { return client_; }
+
+ private:
+  TestNewWindowWebFrameClient* client_ = nullptr;
+};
+
+TEST_F(WebFrameTest, SandboxedIframePopupCtrlClick) {
+  RegisterMockedHttpURLLoad("sandboxed-srcdoc-ctrl-click.html");
+  IframeBeginNavivationCountTestWebFrameClient web_frame_client;
+  frame_test_helpers::WebViewHelper web_view_helper;
+  web_view_helper.InitializeAndLoad(
+      base_url_ + "sandboxed-srcdoc-ctrl-click.html", &web_frame_client);
+
+  ASSERT_EQ(web_frame_client.iframe_client()->BeginNavigationCallCount(), 1);
+
+  LocalFrame* child = To<LocalFrame>(
+      web_view_helper.GetWebView()->GetPage()->MainFrame()->FirstChild());
+  Element* element =
+      child->GetDocument()->body()->getElementById(AtomicString("btn"));
+  To<HTMLElement>(element)->click();
+
+  // Clicking the button will attempt a synthetic Ctrl+Click from an iframe
+  // sandboxed without `allow-popups`. This should be blocked before reaching
+  // BeginNavigation().
+  EXPECT_EQ(web_frame_client.iframe_client()->BeginNavigationCallCount(), 1);
+}
 
 }  // namespace blink
diff --git a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
index d20416c3..03dd574b0 100644
--- a/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
+++ b/third_party/blink/renderer/core/testing/data/core_test_bundle_data.filelist
@@ -433,6 +433,7 @@
 testing/data/rtl-iframe-inner.html
 testing/data/rtl-iframe.html
 testing/data/rtl-overview-mode.html
+testing/data/sandboxed-srcdoc-ctrl-click.html
 testing/data/scale_oscillate.html
 testing/data/script/nested-script.js
 testing/data/script/not-parser-blocked-async-nested-script.html
diff --git a/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html b/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html
new file mode 100644
index 0000000..b325b44
--- /dev/null
+++ b/third_party/blink/renderer/core/testing/data/sandboxed-srcdoc-ctrl-click.html
@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+<body>
+<iframe
+    sandbox="allow-scripts"
+    srcdoc='
+        <div id="container"></div>
+        <script>
+            let clicked = false;
+            const btn = document.createElement("a");
+            btn.id = "btn";
+            btn.href = "about:blank";
+            document.body.appendChild(btn);
+
+            btn.addEventListener("click", (event) => {
+                if (clicked) return;
+                clicked = true;
+                event.preventDefault();
+                const clickEvent = new MouseEvent("click", {
+                    ctrlKey: true,
+                    metaKey: true,
+                    bubbles: true,
+                    cancelable: true,
+                });
+                btn.dispatchEvent(clickEvent);
+            });
+        </script>
+'
+>
+</iframe>
+</html>
Loading diff…

Original Bug Report

reported by ci...@exploit.cat

HTML5 Sandbox Security Model Violation with auxiliary browsing contexts being created despite the lack of "allow-popups" keyword within iframes


Report description

HTML5 Sandbox Security Model Violation with auxiliary browsing contexts being created despite the lack of “allow-popups” keyword within iframes


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:third_party/blink/renderer/core/loader/navigation_policy.cc


The problem

Please describe the technical details of the vulnerability

The vulnerability exists within the Blink rendering engine’s handling of navigation policies derived from mouse events. Specifically, the function NavigationPolicyFromEventInternal in third_party/blink/renderer/core/loader/navigation_policy.cc fails to validate the isTrusted property of a MouseEvent before honoring modifier keys (such as ctrlKey or metaKey).

In a standard HTML5 sandbox environment, an iframe without the allow-popups flag is prohibited from opening new auxiliary browsing contexts (windows or tabs). However, this restriction relies on the renderer correctly calculating the Navigation Policy for a given action.

When a user performs a Ctrl+Click (or Cmd+Click on macOS) on a link, Blink translates this into a navigation policy of kNavigationPolicyNewBackgroundTab or kNavigationPolicyNewForegroundTab. The vulnerability arises because JavaScript within a sandboxed iframe can programmatically construct a synthetic MouseEvent with these modifier keys set to true and dispatch it against an anchor element.

Because NavigationPolicyFromEventInternal does not check if the event is trusted (event.isTrusted()), it blindly processes the synthetic modifiers. Consequently, the renderer calculates a “New Tab” navigation policy and requests the browser to open a new window. This effectively bypasses the allow-popups restriction, as the browser treats the request as a legitimate link navigation with a specific disposition, rather than a blocked window.open call.

Impact analysis

This vulnerability is a security bypass of the HTML5 sandbox allow-popups restriction. While typically classified as a sandbox escape in standard browsers (allowing untrusted content to annoy users or facilitate phishing via popups), the impact is significantly amplified in certain Electron-based applications, such as the Discord Desktop Client, where it can serve as a primitive for Remote Code Execution (RCE).

Core Impact: Sandbox Escape & Security Control Bypass

The vulnerability allows sandboxed content (explicitly restricted from creating new windows) to force the creation of an auxiliary browsing context. This directly violates the integrity of the sandbox attribute, rendering the allow-popups flag ineffective against a malicious actor capable of executing JavaScript.

High-Severity Exploitation in Electron (Discord RCE Chain)

In the context of the Discord Desktop Client, this sandbox escape bridges the gap between a restricted renderer process and privileged system operations.

  • Mechanism: Electron applications often intercept new window creation events (such as via webContents.on('new-window') or setWindowOpenHandler) to delegate external link handling to the operating system using shell.openExternal().
  • The Bypass: Discord implements checks to prevent sandboxed iframes from triggering this flow. However, because the vulnerability forces the Blink engine to classify the synthetic event as a legitimate, user-initiated “New Tab” navigation (via the kNavigationPolicyNewForegroundTab policy), it bypasses renderer-side checks that rely on standard window.open restrictions.
  • RCE Vector: By successfully triggering shell.openExternal() with a malicious payload, we demonstrated the ability to execute arbitrary code on the host machine.
    • Violation: This directly violates the Electron security best practice: " Do not use shell.openExternal with untrusted content". Electron Checklist #15
    • Chain: Sandboxed Iframe -> Synthetic Ctrl+Click -> Sandbox Bypass (New Window Request) -> Electron Interception -> shell.openExternal(malicious_URI) -> RCE.

This finding confirms that the “One Permitted Navigator” principle and standard event validation are insufficient in their current state to protect embedding contexts from synthetic input attacks.


The cause

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

[145.0.7632.46]

No, it is not related to a crash.

Choose the type of vulnerability

Sandbox Escape

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

Credit to my GitHub @Ciarands

View on issue tracker