Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Accessibility
DescriptionInsufficient validation of untrusted input in Accessibility
ComponentAccessibility
Bug ClassLogic Error
Tracker503333798
Fix commit58de0285df95 (chromium/src) +96/-0
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
DidCommitEmbeddingTokenReplacer
content/browser/security_exploit_browsertest.cc
modified

Files Changed

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/security_exploit_browsertest.cc
From 58de0285df959cb869c112eb54f61c035234c2c2 Mon Sep 17 00:00:00 2001
From: Peter KH <pkotwicz@google.com>
Date: Mon, 25 May 2026 07:39:17 -0700
Subject: [PATCH] Make DidCommitNavigation() crash if in-use embedding token is passed

This CL makes RenderFrameHostImpl::DidCommitProvisionalLoad() kill the
renderer if an in-use embedding token is passed in
mojom::DidCommitProvisionalLoadParams::embedding_token.

Bug:503333798
TEST=SecurityExploitBrowserTest.AttemptUseStolenEmbedderToken

Change-Id: I15fca4c66e1324ce28b64f28cf28018d52ce2322
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7841613
Reviewed-by: Dave Tapuska <dtapuska@chromium.org>
Commit-Queue: Peter Kotwicz <pkotwicz@chromium.org>
Reviewed-by: Ramin Halavati <rhalavati@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1635718}
---

diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 4370159f..8949727 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -15494,6 +15494,17 @@
     }
   }
 
+  ui::AXActionHandlerRegistry* action_handler_registry =
+      ui::AXActionHandlerRegistry::GetInstance();
+  if (navigation_request && !is_page_activation &&
+      !is_same_document_navigation && params->embedding_token.has_value() &&
+      action_handler_registry->GetActionHandler(
+          ui::AXTreeID::FromToken(params->embedding_token.value()))) {
+    bad_message::ReceivedBadMessage(
+        process, bad_message::RFH_UNEXPECTED_EMBEDDING_TOKEN);
+    return false;
+  }
+
   // Note: document_policy_header is the document policy state used to
   // initialize |document_policy_| in SecurityContext on renderer side. It is
   // supposed to be compatible with required_document_policy. If not, kill the
@@ -19324,6 +19335,12 @@
   const ui::AXTreeID old_id = GetAXTreeID();
   ui::AXTreeID ax_tree_id = ui::AXTreeID::FromToken(embedding_token);
   CHECK_NE(old_id, ax_tree_id);
+
+  // Should be enforced by ValidateDidCommitParams().
+  CHECK_EQ(
+      nullptr,
+      ui::AXActionHandlerRegistry::GetInstance()->GetActionHandler(ax_tree_id));
+
   SetAXTreeID(ax_tree_id);
   needs_ax_root_id_ = true;
   ui::AXActionHandlerRegistry::GetInstance()->SetFrameIDForAXTreeID(
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index e8a58d3..9c9c9eebc 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1384,6 +1384,85 @@
             kill_waiter.Wait());
 }
 
+namespace {
+
+// Interceptor that replaces the embedding token in the
+// DidCommitProvisionalLoadParams.
+class DidCommitEmbeddingTokenReplacer : public DidCommitNavigationInterceptor {
+ public:
+  DidCommitEmbeddingTokenReplacer(WebContents* web_contents,
+                                  base::UnguessableToken embedding_token)
+      : DidCommitNavigationInterceptor(web_contents),
+        embedding_token_(std::move(embedding_token)) {}
+
+  DidCommitEmbeddingTokenReplacer(const DidCommitEmbeddingTokenReplacer&) =
+      delete;
+  DidCommitEmbeddingTokenReplacer& operator=(
+      const DidCommitEmbeddingTokenReplacer&) = delete;
+
+  ~DidCommitEmbeddingTokenReplacer() override = default;
+
+ private:
+  // DidCommitNavigationInterceptor:
+  bool WillProcessDidCommitNavigation(
+      RenderFrameHost* render_frame_host,
+      NavigationRequest* navigation_request,
+      mojom::DidCommitProvisionalLoadParamsPtr* params,
+      mojom::DidCommitProvisionalLoadInterfaceParamsPtr* interface_params)
+      override {
+    (*params)->embedding_token = embedding_token_;
+    return true;
+  }
+
+  base::UnguessableToken embedding_token_;
+};
+
+}  // namespace
+
+// Verify that the renderer is terminated if a compromised renderer passes an
+// embedding token used by another frame to
+// RenderFrameHostImpl::DidCommitProvisionalLoad().
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       AttemptUseStolenEmbedderToken) {
+  // Explicitly isolating a.com helps ensure that this test is applicable on
+  // platforms without site-per-process.
+  IsolateOrigin("a.com");
+
+  const GURL kUrl(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), kUrl));
+  WebContents* web_contents = shell()->web_contents();
+
+  // Create a separate window and navigate it to b.com to get a valid,
+  // registered embedding token from another frame.
+  GURL kPopupUrl(embedded_test_server()->GetURL("b.com", "/title.html"));
+  Shell* new_window = OpenPopup(shell(), kPopupUrl, "popup");
+  std::optional<base::UnguessableToken> stolen_token =
+      new_window->web_contents()->GetPrimaryMainFrame()->GetEmbeddingToken();
+  ASSERT_TRUE(stolen_token.has_value());
+
+  // Check that replacing the embedding token of the next navigation
+  // with a fresh, unused embedding token doesn't terminate the renderer.
+  {
+    DidCommitEmbeddingTokenReplacer interceptor(
+        web_contents, base::UnguessableToken::Create());
+    const GURL kUrl2(embedded_test_server()->GetURL("a.com", "/title2.html"));
+    EXPECT_TRUE(NavigateToURL(web_contents, kUrl2));
+  }
+
+  // Test that overwriting the embedding token of the next navigation
+  // with a stolen token from another frame causes the navigation to fail and
+  // terminates the compromised renderer process.
+  {
+    RenderProcessHostBadIpcMessageWaiter kill_waiter(
+        web_contents->GetPrimaryMainFrame()->GetProcess());
+    DidCommitEmbeddingTokenReplacer interceptor(web_contents,
+                                                stolen_token.value());
+    const GURL kUrl3(embedded_test_server()->GetURL("a.com", "/title3.html"));
+    EXPECT_FALSE(NavigateToURL(shell(), kUrl3));
+    EXPECT_EQ(bad_message::RFH_UNEXPECTED_EMBEDDING_TOKEN, kill_waiter.Wait());
+  }
+}
+
 // Make sure that a renderer is terminated if it sends an invalid net error code
 // in a DidFailLoadWithError() IPC. See https://crbug.com/407069514.
 IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
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 e8a58d3..9c9c9eebc 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1384,6 +1384,85 @@
             kill_waiter.Wait());
 }
 
+namespace {
+
+// Interceptor that replaces the embedding token in the
+// DidCommitProvisionalLoadParams.
+class DidCommitEmbeddingTokenReplacer : public DidCommitNavigationInterceptor {
+ public:
+  DidCommitEmbeddingTokenReplacer(WebContents* web_contents,
+                                  base::UnguessableToken embedding_token)
+      : DidCommitNavigationInterceptor(web_contents),
+        embedding_token_(std::move(embedding_token)) {}
+
+  DidCommitEmbeddingTokenReplacer(const DidCommitEmbeddingTokenReplacer&) =
+      delete;
+  DidCommitEmbeddingTokenReplacer& operator=(
+      const DidCommitEmbeddingTokenReplacer&) = delete;
+
+  ~DidCommitEmbeddingTokenReplacer() override = default;
+
+ private:
+  // DidCommitNavigationInterceptor:
+  bool WillProcessDidCommitNavigation(
+      RenderFrameHost* render_frame_host,
+      NavigationRequest* navigation_request,
+      mojom::DidCommitProvisionalLoadParamsPtr* params,
+      mojom::DidCommitProvisionalLoadInterfaceParamsPtr* interface_params)
+      override {
+    (*params)->embedding_token = embedding_token_;
+    return true;
+  }
+
+  base::UnguessableToken embedding_token_;
+};
+
+}  // namespace
+
+// Verify that the renderer is terminated if a compromised renderer passes an
+// embedding token used by another frame to
+// RenderFrameHostImpl::DidCommitProvisionalLoad().
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       AttemptUseStolenEmbedderToken) {
+  // Explicitly isolating a.com helps ensure that this test is applicable on
+  // platforms without site-per-process.
+  IsolateOrigin("a.com");
+
+  const GURL kUrl(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), kUrl));
+  WebContents* web_contents = shell()->web_contents();
+
+  // Create a separate window and navigate it to b.com to get a valid,
+  // registered embedding token from another frame.
+  GURL kPopupUrl(embedded_test_server()->GetURL("b.com", "/title.html"));
+  Shell* new_window = OpenPopup(shell(), kPopupUrl, "popup");
+  std::optional<base::UnguessableToken> stolen_token =
+      new_window->web_contents()->GetPrimaryMainFrame()->GetEmbeddingToken();
+  ASSERT_TRUE(stolen_token.has_value());
+
+  // Check that replacing the embedding token of the next navigation
+  // with a fresh, unused embedding token doesn't terminate the renderer.
+  {
+    DidCommitEmbeddingTokenReplacer interceptor(
+        web_contents, base::UnguessableToken::Create());
+    const GURL kUrl2(embedded_test_server()->GetURL("a.com", "/title2.html"));
+    EXPECT_TRUE(NavigateToURL(web_contents, kUrl2));
+  }
+
+  // Test that overwriting the embedding token of the next navigation
+  // with a stolen token from another frame causes the navigation to fail and
+  // terminates the compromised renderer process.
+  {
+    RenderProcessHostBadIpcMessageWaiter kill_waiter(
+        web_contents->GetPrimaryMainFrame()->GetProcess());
+    DidCommitEmbeddingTokenReplacer interceptor(web_contents,
+                                                stolen_token.value());
+    const GURL kUrl3(embedded_test_server()->GetURL("a.com", "/title3.html"));
+    EXPECT_FALSE(NavigateToURL(shell(), kUrl3));
+    EXPECT_EQ(bad_message::RFH_UNEXPECTED_EMBEDDING_TOKEN, kill_waiter.Wait());
+  }
+}
+
 // Make sure that a renderer is terminated if it sends an invalid net error code
 // in a DidFailLoadWithError() IPC. See https://crbug.com/407069514.
 IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
Loading diff…

Original Bug Report

reported by vm...@google.com

Renderer-supplied embedding_token can potentially hijack browser-process AXActionHandlerRegistry

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: A potential logic flaw in the browser process’s handling of navigation commits allows a compromised renderer to hijack accessibility-related mappings. The browser fails to validate the uniqueness of an embedding_token supplied by the renderer, allowing an attacker to reuse a stolen token and overwrite AXTreeID mappings in the AXActionHandlerRegistry. This can lead to a cross-origin Site Isolation bypass within the accessibility subsystem, allowing the attacker to spoof or intercept accessibility data intended for a victim frame.

Affected files:

  • content/browser/renderer_host/render_frame_host_impl.cc
  • ui/accessibility/ax_action_handler_registry.cc
  • ui/accessibility/ax_action_handler_base.cc
  • content/browser/web_contents/web_contents_impl.cc
  • extensions/browser/api/automation_internal/automation_internal_api.cc

Estimated timestamp from git blame: 2026-04-01

Description

There is a potential vulnerability in how the browser process handles the embedding_token provided by a renderer process during a navigation commit. The browser process does not enforce the uniqueness of the embedding_token, which can lead to identity confusion in the AXActionHandlerRegistry.

When a cross-origin subframe commits a navigation, it generates an embedding_token (a base::UnguessableToken) and sends it to the browser via the FrameHost::DidCommitProvisionalLoad IPC. The browser then propagates this token to the parent frame’s process via blink::mojom::RemoteFrame::SetEmbeddingToken to allow Blink to link the accessibility trees. If the parent renderer process is compromised, an attacker can steal this token from the blink::Frame object in memory.

The attacker can then forge a navigation commit for a different, same-origin subframe and supply the stolen embedding_token in the DidCommitProvisionalLoadParams. The validation logic in RenderFrameHostImpl::ValidateDidCommitParams checks for the presence of the token but fails to verify if it is globally unique or already in use.

When the browser processes this forged commit, RenderFrameHostImpl::SetEmbeddingToken converts the token into a ui::AXTreeID and registers it. AXActionHandlerRegistry::SetAXTreeID uses a DCHECK to prevent duplicates, which is compiled out in release builds. This allows the attacker to silently overwrite the mapping in id_to_action_handler_ and ax_tree_to_frame_id_map_, redirecting all browser-side accessibility actions intended for the victim’s AXTreeID to the attacker’s RenderFrameHostImpl.

Potential Attack Steps

These are suggested steps an attacker might follow to trigger this vulnerability, assuming they have already achieved Remote Code Execution (RCE) in a sandboxed renderer process (Renderer A):

  1. The attacker uses their compromised Renderer A to create an iframe navigating to a cross-origin victim site.
  2. The victim frame is isolated into a separate process (Renderer B) and commits its navigation, generating a unique embedding_token.
  3. The browser propagates the victim’s embedding_token to Renderer A via blink::mojom::RemoteFrame::SetEmbeddingToken.
  4. The attacker reads Renderer A’s memory to steal the victim’s embedding_token.
  5. The attacker creates a second, same-origin subframe within Renderer A.
  6. The attacker initiates a navigation in this second subframe and intercepts the outgoing FrameHost::DidCommitProvisionalLoad IPC.
  7. The attacker modifies the IPC parameters, replacing the newly generated embedding_token with the victim’s stolen token.
  8. The browser process receives the IPC. RenderFrameHostImpl::ValidateDidCommitParams passes because it only checks for the token’s presence, not its uniqueness.
  9. RenderFrameHostImpl::SetEmbeddingToken processes the token, converts it to an AXTreeID, and calls AXActionHandlerRegistry::SetAXTreeID.
  10. The DCHECK in AXActionHandlerRegistry::SetAXTreeID is bypassed in release builds, and the mapping for the victim’s AXTreeID is silently overwritten to point to the attacker’s frame.

Impact

This constitutes a High severity (S1) Site Isolation bypass specifically impacting the accessibility subsystem. Any browser-side functionality that relies on resolving an AXTreeID (e.g., Extension Automation API, Read Anything) will be misdirected to the attacker’s frame. This allows the attacker to spoof accessibility data presented to the user or intercept accessibility actions intended for a cross-origin frame, potentially leading to information disclosure or spoofing attacks.

Suggested Fix

  1. Enforce Uniqueness Validation: Update RenderFrameHostImpl::ValidateDidCommitParams or RenderFrameHostImpl::SetEmbeddingToken to verify that the provided embedding_token is globally unique and not already associated with another active RenderFrameHost. If a duplicate is detected, the renderer should be killed with a bad_message.
  2. Harden Registry: Upgrade the DCHECK in AXActionHandlerRegistry::SetAXTreeID and AXActionHandlerRegistry::SetFrameIDForAXTreeID to a CHECK or use base::debug::DumpWithoutCrashing and gracefully handle the failure to prevent the silent overwrite of mappings in production builds.

Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker