CVE-2026-14109
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fmojo/public/cpp/bindings/tests/binder_map_unittest.cc |
modified |
Files Changed
mojo/public/cpp/bindings/binder_map.hmojo/public/cpp/bindings/tests/binder_map_unittest.ccmojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom
Patch
From 227e91af07590b791c4019c6a8ddc945264b9632 Mon Sep 17 00:00:00 2001
From: Fred Shih <ffred@chromium.org>
Date: Wed, 20 May 2026 13:55:32 -0700
Subject: [PATCH] Fix mojo binder map not honouring RuntimeFeature flags
The binder map still registers an interface, regardless of whether the
Runtime feature is enabled or not. This change prevents binding an
interface if the runtime feature is disabled.
Bug: 513694957
Change-Id: Id091006d1549bbd90014b8ff6c8d6fbba59ac933
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7856683
Commit-Queue: Fred Shih <ffred@chromium.org>
Reviewed-by: Andrea Orru <andreaorru@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1633813}
---
diff --git a/mojo/public/cpp/bindings/binder_map.h b/mojo/public/cpp/bindings/binder_map.h
index b55e6d49..b0a7d054 100644
--- a/mojo/public/cpp/bindings/binder_map.h
+++ b/mojo/public/cpp/bindings/binder_map.h
@@ -82,9 +82,9 @@
// one replaces any existing binder.
template <typename Interface>
void Add(std::type_identity_t<BinderType<Interface>> binder) {
- Add(internal::StaticString(Interface::Name_),
- internal::GenericCallbackBinderWithContext<ContextType>(
- Traits::MakeGenericBinder(std::move(binder))));
+ Add<Interface>(internal::StaticString(Interface::Name_),
+ internal::GenericCallbackBinderWithContext<ContextType>(
+ Traits::MakeGenericBinder(std::move(binder))));
}
// Adds a new binder specifically for Interface receivers. This exists for the
@@ -102,10 +102,10 @@
template <typename Interface>
void Add(std::type_identity_t<SequenceBinderType<Interface>> binder,
scoped_refptr<base::SequencedTaskRunner> task_runner) {
- Add(internal::StaticString(Interface::Name_),
- internal::GenericCallbackBinderWithContext<ContextType>(
- SequenceTraits::MakeGenericBinder(std::move(binder)),
- std::move(task_runner)));
+ Add<Interface>(internal::StaticString(Interface::Name_),
+ internal::GenericCallbackBinderWithContext<ContextType>(
+ SequenceTraits::MakeGenericBinder(std::move(binder)),
+ std::move(task_runner)));
}
// Adds a new binder specifically for Interface functors. This exists for the
@@ -119,9 +119,9 @@
// one replaces any existing binder.
template <typename Interface>
void Add(std::type_identity_t<FuncType<Interface>>* func) {
- Add(internal::StaticString(Interface::Name_),
- internal::GenericCallbackBinderWithContext<ContextType>(
- Traits::MakeGenericBinder(func)));
+ Add<Interface>(internal::StaticString(Interface::Name_),
+ internal::GenericCallbackBinderWithContext<ContextType>(
+ Traits::MakeGenericBinder(func)));
}
// Adds a new binder specifically for Interface functors. This exists for the
@@ -139,7 +139,8 @@
template <typename Interface>
void Add(std::type_identity_t<SequenceFuncType<Interface>>* func,
scoped_refptr<base::SequencedTaskRunner> task_runner) {
- Add(internal::StaticString(Interface::Name_),
+ Add<Interface>(
+ internal::StaticString(Interface::Name_),
internal::GenericCallbackBinderWithContext<ContextType>(
SequenceTraits::MakeGenericBinder(func), std::move(task_runner)));
}
@@ -216,8 +217,12 @@
private:
using IsVoidContext = std::is_same<ContextType, void>;
+ template <typename Interface>
void Add(internal::StaticString name,
internal::GenericCallbackBinderWithContext<ContextType>&& binder) {
+ if (!internal::GetRuntimeFeature_IsEnabled<Interface>()) {
+ return;
+ }
// This is not a public method because it is not safe to use with a
// non-static `name`. The map key is a `string_view` which would result in
// a dangling pointer if the underlying string were to be freed.
diff --git a/mojo/public/cpp/bindings/tests/binder_map_unittest.cc b/mojo/public/cpp/bindings/tests/binder_map_unittest.cc
index 81100200..78e3f99 100644
--- a/mojo/public/cpp/bindings/tests/binder_map_unittest.cc
+++ b/mojo/public/cpp/bindings/tests/binder_map_unittest.cc
@@ -9,11 +9,13 @@
#include "base/task/sequenced_task_runner.h"
#include "base/task/thread_pool.h"
#include "base/test/bind.h"
+#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
#include "mojo/public/cpp/bindings/generic_pending_receiver.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "mojo/public/cpp/bindings/self_owned_receiver.h"
+#include "mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom-features.h"
#include "mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -163,6 +165,31 @@
loop.Run();
}
+TEST_F(BinderMapTest, RuntimeFeature) {
+ BinderMap map;
+ map.Add<mojom::TestRuntimeFeatureInterface>(
+ base::BindLambdaForTesting(
+ [&](mojo::PendingReceiver<mojom::TestRuntimeFeatureInterface>
+ receiver) {}),
+ base::SequencedTaskRunner::GetCurrentDefault());
+
+ EXPECT_TRUE(map.Contains<mojom::TestRuntimeFeatureInterface>());
+}
+
+TEST_F(BinderMapTest, RuntimeFeature_Disabled) {
+ base::test::ScopedFeatureList feature_list;
+ feature_list.InitAndDisableFeature(mojom::TestRuntimeFeature);
+
+ BinderMap map;
+ map.Add<mojom::TestRuntimeFeatureInterface>(
+ base::BindLambdaForTesting(
+ [&](mojo::PendingReceiver<mojom::TestRuntimeFeatureInterface>
+ receiver) {}),
+ base::SequencedTaskRunner::GetCurrentDefault());
+
+ EXPECT_FALSE(map.Contains<mojom::TestRuntimeFeatureInterface>());
+}
+
TEST_F(BinderMapTest, WithContext) {
Remote<mojom::TestInterface1> remote;
GenericPendingReceiver receiver(remote.BindNewPipeAndPassReceiver());
diff --git a/mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom b/mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom
index 0f20197d..0b0cd37 100644
--- a/mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom
+++ b/mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom
@@ -4,6 +4,13 @@
module mojo.test.binder_map_unittest.mojom;
+feature TestRuntimeFeature {
+ const string name = "TestRuntimeFeature";
+ const bool default_state = true;
+};
+
interface TestInterface1 {};
interface TestInterface2 {};
+[RuntimeFeature=TestRuntimeFeature]
+interface TestRuntimeFeatureInterface{};
Regression Test / PoC
diff --git a/mojo/public/cpp/bindings/tests/binder_map_unittest.cc b/mojo/public/cpp/bindings/tests/binder_map_unittest.cc
index 81100200..78e3f99 100644
--- a/mojo/public/cpp/bindings/tests/binder_map_unittest.cc
+++ b/mojo/public/cpp/bindings/tests/binder_map_unittest.cc
@@ -9,11 +9,13 @@
#include "base/task/sequenced_task_runner.h"
#include "base/task/thread_pool.h"
#include "base/test/bind.h"
+#include "base/test/scoped_feature_list.h"
#include "base/test/task_environment.h"
#include "mojo/public/cpp/bindings/generic_pending_receiver.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "mojo/public/cpp/bindings/remote.h"
#include "mojo/public/cpp/bindings/self_owned_receiver.h"
+#include "mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom-features.h"
#include "mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -163,6 +165,31 @@
loop.Run();
}
+TEST_F(BinderMapTest, RuntimeFeature) {
+ BinderMap map;
+ map.Add<mojom::TestRuntimeFeatureInterface>(
+ base::BindLambdaForTesting(
+ [&](mojo::PendingReceiver<mojom::TestRuntimeFeatureInterface>
+ receiver) {}),
+ base::SequencedTaskRunner::GetCurrentDefault());
+
+ EXPECT_TRUE(map.Contains<mojom::TestRuntimeFeatureInterface>());
+}
+
+TEST_F(BinderMapTest, RuntimeFeature_Disabled) {
+ base::test::ScopedFeatureList feature_list;
+ feature_list.InitAndDisableFeature(mojom::TestRuntimeFeature);
+
+ BinderMap map;
+ map.Add<mojom::TestRuntimeFeatureInterface>(
+ base::BindLambdaForTesting(
+ [&](mojo::PendingReceiver<mojom::TestRuntimeFeatureInterface>
+ receiver) {}),
+ base::SequencedTaskRunner::GetCurrentDefault());
+
+ EXPECT_FALSE(map.Contains<mojom::TestRuntimeFeatureInterface>());
+}
+
TEST_F(BinderMapTest, WithContext) {
Remote<mojom::TestInterface1> remote;
GenericPendingReceiver receiver(remote.BindNewPipeAndPassReceiver());
diff --git a/mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom b/mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom
index 0f20197d..0b0cd37 100644
--- a/mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom
+++ b/mojo/public/cpp/bindings/tests/binder_map_unittest.test-mojom
@@ -4,6 +4,13 @@
module mojo.test.binder_map_unittest.mojom;
+feature TestRuntimeFeature {
+ const string name = "TestRuntimeFeature";
+ const bool default_state = true;
+};
+
interface TestInterface1 {};
interface TestInterface2 {};
+[RuntimeFeature=TestRuntimeFeature]
+interface TestRuntimeFeatureInterface{};
Original Bug Report
Potential [RuntimeFeature] security gate bypass in mojo::BinderMap
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The mojo::BinderMap class fails to consult [RuntimeFeature] gates during interface registration and dispatch. This allows a compromised renderer to trigger the execution of browser-side binder callbacks for interfaces that have been explicitly disabled via feature flags.
Affected files:
mojo/public/cpp/bindings/binder_map.hmojo/public/cpp/bindings/lib/binder_map_internal.hchrome/browser/chrome_browser_interface_binders.cc
Estimated timestamp from git blame: Unknown (Google3 checkout)
Summary
A potential security control bypass exists in Mojo’s BinderMap implementation where [RuntimeFeature] gates are not consulted during interface registration or dispatch. This allows a compromised renderer process to reach and execute binder callbacks in the browser process for interfaces that are supposedly disabled via the [RuntimeFeature] mojom attribute and its corresponding feature flag.
Root Cause Analysis
mojo::BinderMapWithContext is a registry used by BrowserInterfaceBroker to expose per-frame interfaces to renderers. Unlike mojo::ServiceFactory, which checks if an interface is enabled before allowing registration (see mojo/public/cpp/bindings/service_factory.h), BinderMap registration and dispatch code does not consult the generated GetRuntimeFeature_IsEnabled<Interface>() function.
When a renderer requests an interface via BrowserInterfaceBroker::GetInterface, the request is handled by BrowserInterfaceBrokerImpl::BindInterface, which calls TryBind on its BinderMap. The TryBind implementation in mojo/public/cpp/bindings/binder_map.h extracts the raw message pipe handle from the GenericPendingReceiver using PassPipe():
it->second.BindInterface(std::move(context), receiver->PassPipe());
By using PassPipe(), it bypasses the GenericPendingReceiver::As<Interface>() method, which is the standard path that enforces [RuntimeFeature] checks (see mojo/public/cpp/bindings/generic_pending_receiver.h). The raw pipe is then re-wrapped into a strongly-typed PendingReceiver<Interface> using an unchecked constructor in mojo/public/cpp/bindings/lib/binder_map_internal.h before being passed to the developer-provided binder callback.
This bypass means that any interface registered via BinderMap::Add<Interface> can have its binder callback executed even if its associated [RuntimeFeature] is disabled, unless the developer has manually added a feature check at the registration site.
Impact
An attacker who has compromised a renderer process can trigger the execution of binder callbacks for disabled interfaces. While the eventual Bind() call on a browser-side Receiver or ReceiverSet will typically fail and reset the pipe if the feature is disabled, the binder callback itself is executed first. This callback may have unintended side effects such as instantiating KeyedServices, allocating objects, or emitting UMA metrics before the bound state is finally checked.
For example, screen_ai::mojom::ScreenAIAnnotator is guarded by [RuntimeFeature=ax.mojom.features.kScreenAIOCREnabled]. It is registered in chrome/browser/chrome_browser_interface_binders.cc without a manual feature check. If the feature is disabled, a compromised renderer can still trigger BindScreenAIAnnotator, which instantiates a ScreenAIServiceRouter (a KeyedService) and emits UMA histograms before the request is eventually dropped.
Potential Reproduction Steps
- Identify a
[RuntimeFeature]-guarded interface registered viaBinderMapin the browser process that lacks a manual feature check at registration (e.g.,screen_ai::mojom::ScreenAIAnnotatorinchrome_browser_interface_binders.cc). - Disable the corresponding feature flag (e.g., via
--disable-features=ScreenAIOCREnabled). - From a compromised renderer, call
blink.mojom.BrowserInterfaceBroker::GetInterfacerequesting the disabled interface name. - Observe (via breakpoint or logging) that the browser-side binder callback is executed despite the feature being disabled.
Note: These are potential steps as our current analysis is based on code review.
Suggested Fix
Mirror the implementation of mojo::ServiceFactory::Add: in each BinderMap::Add<Interface> overload, early-return if !internal::GetRuntimeFeature_IsEnabled<Interface>(). Additionally, TryBind could be updated to use As<Interface>() to ensure feature gates are respected during dispatch.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.