CVE-2026-87610
Overview
Background
- AIM (AI Mode) / Contextual Tasks
- an Omnibox feature that can intercept navigations to trusted search hosts and grant them privileged protocol access, gated by a host-allowlist check.
- `interception_allowed_hosts`
- a server-supplied list, carried in
AimEligibilityResponse, of hostnames whose pages are permitted to be intercepted for AIM; entries are plain unescaped hostnames such aswww.google.com. - `RE2::FullMatch`
- a regular-expression full-string matcher in which
.is a metacharacter matching any single character rather than a literal dot. - `base::MatchPattern`
- a glob-style matcher in which only
*and?are wildcards and every other character, including., is matched literally.
Root Cause Analysis
AimEligibilityService::IsAimHost decided whether a URL’s host was an authorized AIM host by looping over GetMostRecentResponse().interception_allowed_hosts() and calling re2::RE2::FullMatch(url.host(), host_pattern). Because the server sends unescaped hostnames (e.g. www.google.com), each . was interpreted by RE2 as a regex metacharacter matching any character, so an attacker-controlled near-domain like www0google.com or wwwagoogle.com satisfied FullMatch and was treated as an authorized host. This violated the invariant that only exactly the listed hostnames (and their explicit subdomains) are AIM-eligible, allowing an attacker domain to obtain the privileged protocol access AIM grants in Contextual Tasks.
The fix replaces RE2::FullMatch with base::MatchPattern(lower_url_host, base::ToLowerASCII(host_pattern)), so dots are matched literally and only */? act as wildcards, and additionally makes the host_override comparison and pattern matching case-insensitive so legitimate hosts still match while near-domains do not.
. silently became a “match any character” wildcard; the fix switches to base::MatchPattern, whose only wildcards are * and ?, so dots are enforced as literal separators.Attack Path
- Register a near-domain
An attacker registers a domain that differs from an allowlisted host only where a literal dot would be, e.g.
www0google.comversus the server-providedwww.google.com. - Reach the eligibility check
The victim navigates (or is induced to navigate) to the attacker host with the AIM-triggering URL shape, so
AimEligibilityService::IsAimHostis invoked withurl.host()equal to the attacker domain. - Bypass the allowlist
re2::RE2::FullMatch(url.host(), host_pattern)treats the.inwww.google.comas “any character,” sowww0google.commatches andIsAimHostreturnstrue, marking the attacker origin as an authorized AIM host. - Gain privileged access The attacker page is granted the privileged protocol access AIM extends to trusted hosts in Contextual Tasks (inferred: the specific downstream capabilities are not shown in the diff).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcomponents/omnibox/browser/aim_eligibility_service_unittest.cc |
modified |
Files Changed
components/omnibox/browser/aim_eligibility_service_unittest.cccomponents/omnibox/browser/aim_eligibility_service.cc
Audit Directions
- Literal strings into regex enginesAudit every call site that passes server- or config-supplied hostnames, paths, or identifiers into
RE2/re2::RE2::FullMatchorstd::regexwithout escaping; unescaped.,+,?, and*silently widen the match. - Allowlist/authorization matchersReview host and origin allowlist comparisons for case sensitivity and for using pattern matchers where exact or glob matching is intended, since over-broad matching is an authorization bypass rather than a mere logic error.
- Server-controlled matching dataTrace fields like
interception_allowed_hosts/interception_allowed_pathsfrom the proto response to their comparison sinks to confirm the client treats them as trusted literals with the intended wildcard semantics.
Patch
From 9e1d112b91b8752e43d3fdd11cdd1cb6a9fc8f7b Mon Sep 17 00:00:00 2001
From: Duncan Mercer <mercerd@google.com>
Date: Mon, 17 Aug 2026 18:01:19 -0700
Subject: [PATCH] [Omnibox] Use MatchPattern for AIM host matching
AimEligibilityService::IsAimHost previously evaluated server-provided
interception_allowed_hosts using RE2::FullMatch. Because the server
supplies unescaped hostnames (e.g. www.google.com), unescaped dots
matched any character in regex, allowing attacker-controlled near-
domains (e.g. www0google.com) to match and gain privileged protocol
access in Contextual Tasks.
This CL updates IsAimHost to use base::MatchPattern with case-
insensitive comparison so that dots are treated as literal characters
and wildcards are restricted to * and ?.
Bug: b:547592631
Change-Id: Ic0b9991722a19f2764fb36226d0c2fd672f17e31
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8254236
Reviewed-by: Justin Donnelly <jdonnelly@chromium.org>
Commit-Queue: Justin Donnelly <jdonnelly@chromium.org>
Auto-Submit: Duncan Mercer <mercerd@google.com>
Reviewed-by: Matthew Jones <mdjones@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1680968}
---
diff --git a/components/omnibox/browser/aim_eligibility_service.cc b/components/omnibox/browser/aim_eligibility_service.cc
index 0d6ba3f7..5e1a853 100644
--- a/components/omnibox/browser/aim_eligibility_service.cc
+++ b/components/omnibox/browser/aim_eligibility_service.cc
@@ -15,6 +15,7 @@
#include "base/logging.h"
#include "base/memory/scoped_refptr.h"
#include "base/metrics/histogram_functions.h"
+#include "base/strings/pattern.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
@@ -48,7 +49,6 @@
#include "services/network/public/mojom/url_response_head.mojom.h"
#include "third_party/omnibox_proto/aim_eligibility_client_request.pb.h"
#include "third_party/omnibox_proto/aim_eligibility_response.pb.h"
-#include "third_party/re2/src/re2/re2.h"
#include "url/gurl.h"
namespace {
@@ -541,16 +541,18 @@
const GURL& url,
std::optional<std::string> host_override) const {
OMNIBOX_LOG("aim_url_check") << "IsAimHost: Checking host...";
- if (host_override && host_override.value() == url.host()) {
+ if (host_override &&
+ base::EqualsCaseInsensitiveASCII(host_override.value(), url.host())) {
OMNIBOX_LOG("aim_url_check") << "Found overridden host!";
return true;
}
OMNIBOX_LOG("aim_url_check")
<< "IsAimHost: Available hosts: "
<< GetMostRecentResponse().interception_allowed_hosts().size();
+ std::string lower_url_host = base::ToLowerASCII(url.host());
for (const auto& host_pattern :
GetMostRecentResponse().interception_allowed_hosts()) {
- if (re2::RE2::FullMatch(url.host(), host_pattern)) {
+ if (base::MatchPattern(lower_url_host, base::ToLowerASCII(host_pattern))) {
OMNIBOX_LOG("aim_url_check") << "IsAimHost: Matched : " << host_pattern;
return true;
}
diff --git a/components/omnibox/browser/aim_eligibility_service_unittest.cc b/components/omnibox/browser/aim_eligibility_service_unittest.cc
index 5ccdbade..dd1dc30 100644
--- a/components/omnibox/browser/aim_eligibility_service_unittest.cc
+++ b/components/omnibox/browser/aim_eligibility_service_unittest.cc
@@ -253,13 +253,8 @@
rule.mutable_required_params()->Add(CreateQueryParam("b", "2"));
response.mutable_aim_detection_url_rule()->Add(std::move(rule));
- // The example here does not represent what should be sent from the
- // backend. If a zero-or-one subdomain needs to be accounted for two
- // rules should be sent instead:
- // - google.com
- // - *.google.com
- // This will avoid catching cases like fakegoogle.com.
- response.mutable_interception_allowed_hosts()->Add(".*.?google.com");
+ response.mutable_interception_allowed_hosts()->Add("google.com");
+ response.mutable_interception_allowed_hosts()->Add("*.google.com");
response.mutable_interception_allowed_paths()->Add("/search");
@@ -278,6 +273,47 @@
GURL("https://google.example.com/search?a=1&b=2"), std::nullopt));
}
+TEST_F(AimEligibilityServiceTest, IsAimUrl_SecurityNearDomains) {
+ omnibox::AimEligibilityResponse response;
+
+ omnibox::AimEligibilityResponse::AimDetectionUrlRule rule;
+ rule.mutable_required_params()->Add(CreateQueryParam("udm", "50"));
+ response.mutable_aim_detection_url_rule()->Add(std::move(rule));
+
+ response.mutable_interception_allowed_hosts()->Add("www.google.com");
+ response.mutable_interception_allowed_hosts()->Add("google.com");
+
+ response.mutable_interception_allowed_paths()->Add("/search");
+
+ aim_eligibility_service_->SetAimEligibilityResponse(std::move(response));
+
+ // Legitimate domains match.
+ EXPECT_TRUE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://www.google.com/search?udm=50"), std::nullopt));
+ EXPECT_TRUE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://google.com/search?udm=50"), std::nullopt));
+ EXPECT_TRUE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://WWW.GOOGLE.COM/search?udm=50"), std::nullopt));
+
+ // Attacker-controlled near-domains and similar strings MUST NOT match.
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://www0google.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://wwwagoogle.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://www-google.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://google0com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://googleicom/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://fakegoogle.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://google.com.evil.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://notgoogle.com/search?udm=50"), std::nullopt));
+}
+
TEST_F(AimEligibilityServiceTest, HasNoCobrowseParams_ExactMatch) {
omnibox::AimEligibilityResponse response;
response.mutable_no_cobrowse_params()->Add(CreateQueryParam("ncb", "1"));
Regression Test / PoC
diff --git a/components/omnibox/browser/aim_eligibility_service_unittest.cc b/components/omnibox/browser/aim_eligibility_service_unittest.cc
index 5ccdbade..dd1dc30 100644
--- a/components/omnibox/browser/aim_eligibility_service_unittest.cc
+++ b/components/omnibox/browser/aim_eligibility_service_unittest.cc
@@ -253,13 +253,8 @@
rule.mutable_required_params()->Add(CreateQueryParam("b", "2"));
response.mutable_aim_detection_url_rule()->Add(std::move(rule));
- // The example here does not represent what should be sent from the
- // backend. If a zero-or-one subdomain needs to be accounted for two
- // rules should be sent instead:
- // - google.com
- // - *.google.com
- // This will avoid catching cases like fakegoogle.com.
- response.mutable_interception_allowed_hosts()->Add(".*.?google.com");
+ response.mutable_interception_allowed_hosts()->Add("google.com");
+ response.mutable_interception_allowed_hosts()->Add("*.google.com");
response.mutable_interception_allowed_paths()->Add("/search");
@@ -278,6 +273,47 @@
GURL("https://google.example.com/search?a=1&b=2"), std::nullopt));
}
+TEST_F(AimEligibilityServiceTest, IsAimUrl_SecurityNearDomains) {
+ omnibox::AimEligibilityResponse response;
+
+ omnibox::AimEligibilityResponse::AimDetectionUrlRule rule;
+ rule.mutable_required_params()->Add(CreateQueryParam("udm", "50"));
+ response.mutable_aim_detection_url_rule()->Add(std::move(rule));
+
+ response.mutable_interception_allowed_hosts()->Add("www.google.com");
+ response.mutable_interception_allowed_hosts()->Add("google.com");
+
+ response.mutable_interception_allowed_paths()->Add("/search");
+
+ aim_eligibility_service_->SetAimEligibilityResponse(std::move(response));
+
+ // Legitimate domains match.
+ EXPECT_TRUE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://www.google.com/search?udm=50"), std::nullopt));
+ EXPECT_TRUE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://google.com/search?udm=50"), std::nullopt));
+ EXPECT_TRUE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://WWW.GOOGLE.COM/search?udm=50"), std::nullopt));
+
+ // Attacker-controlled near-domains and similar strings MUST NOT match.
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://www0google.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://wwwagoogle.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://www-google.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://google0com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://googleicom/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://fakegoogle.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://google.com.evil.com/search?udm=50"), std::nullopt));
+ EXPECT_FALSE(aim_eligibility_service_->IsAimUrl(
+ GURL("https://notgoogle.com/search?udm=50"), std::nullopt));
+}
+
TEST_F(AimEligibilityServiceTest, HasNoCobrowseParams_ExactMatch) {
omnibox::AimEligibilityResponse response;
response.mutable_no_cobrowse_params()->Add(CreateQueryParam("ncb", "1"));
Original Bug Report
Unescaped AIM hostname regex lets a near-domain become Chrome's privileged Contextual Tasks protocol peer
Report description
Unescaped AIM hostname regex lets a near-domain become Chrome’s privileged Contextual Tasks protocol peer
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?
The problem
Please describe the technical details of the vulnerability
AimEligibilityService::IsAimHost passes each server-provided
interception_allowed_hosts string directly to RE2::FullMatch. The live
response supplied www.google.com, but the dots are not escaped, so the
registrable near-domain www0google.com also matches.
For a matching /search?udm=50 URL, Chrome treats the attacker origin as a
Google AIM page, intercepts the navigation, stores the complete near-domain URL
as a Contextual Tasks creation URL, and loads it as the embedded WebView. That
page receives Chrome’s privileged protocol handshake. It can send a valid
AimToClientMessage.inject_input with submit_after_injection=true, causing
the browser-owned composebox to submit attacker-selected text and return the
serialized ClientToAimMessage.submit_query to the attacker origin.
I reproduced the complete diagnostic chain 3/3 on Canary 153.0.8009.0. A matched non-AIM host remained an ordinary page and received no handshake or returned query in 3/3 trials. The exact bundled-RE2 control confirms that escaping the dots prevents the near-domain match.
The minimized attachment uses loopback hostname mappings and disables
AimCoBrowseEligibilityCheckEnabled, because a fresh response for my current
test account sets is_cobrowse_eligible=false. It does not use DevTools to
navigate the WebView. I am explicitly reporting this as a deterministic
feature-gated authorization-boundary violation; universal stock remote delivery
is not claimed.
Impact analysis
An attacker controlling a hostname that matches an unescaped AIM allowlist pattern can impersonate Chrome’s intended Google AIM protocol peer. For an eligible Contextual Tasks user who follows an AIM-shaped URL, the attacker page is loaded inside the privileged Contextual Tasks host instead of as an ordinary web page.
The concrete demonstrated capability is bidirectional access to a browser-managed trusted channel: the attacker forces a composebox submission without a second confirmation and receives Chrome’s resulting serialized query. This crosses the intended origin/authorization boundary between arbitrary web content and the trusted Google AIM WebView. I do not claim native code execution, sandbox escape, unrelated-tab disclosure, or universal reachability for accounts whose co-browse gate is disabled.
The cause
What version of Chrome have you found the security issue in?
153.0.8009.0Canary, macOS arm64 -152.0.7977.42Chrome for Testing / Beta-era build, macOS arm64
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Permissions Bypass
How would you like to be publicly acknowledged for your report?
Arni Hardarson (Neonix Security)