CVE-2026-14109
Overview
Background
- `BinderMapWithContext` / `BinderMap`
- the Mojo template (in
binder_map.h, withBinderMapbeing thevoid-context alias) that a host process populates with per-interface binders and later queries by interface name to bind an incomingGenericPendingReceiver. - `[RuntimeFeature=...]` interface annotation
- a mojom attribute that ties an interface’s availability to a
base::Feature, so the interface is only meant to be exposed when that feature is enabled. - `internal::GetRuntimeFeature_IsEnabled<Interface>()`
- the generated helper that reports whether an interface’s associated
RuntimeFeatureis currently enabled; it returnstruefor interfaces that carry no annotation. - Interface broker
- the browser-process surface through which a client process (e.g. a renderer) requests an interface by name, which dispatches into the host’s
BinderMap.
Root Cause Analysis
The public Add<Interface>(...) overloads in binder_map.h each funneled into a single private, non-templated Add(internal::StaticString name, GenericCallbackBinderWithContext<ContextType>&&) that unconditionally inserted the binder into the map. Because the private method had no knowledge of the Interface type, it could not consult the interface’s RuntimeFeature, so an interface annotated [RuntimeFeature=...] was registered and remained bindable even when its feature was disabled.
The fix makes the private Add a template <typename Interface> and inserts an early if (!internal::GetRuntimeFeature_IsEnabled<Interface>()) { return; } guard before the map insertion, and rewrites every public overload to forward the type as Add<Interface>(...). This tightens behavior: a disabled-feature interface is now simply never added to the map, so a later lookup finds no binder and the receiver is not bound. The change adds a previously-missing policy check rather than relaxing anything.
RuntimeFeature gate was never evaluated; threading Interface through to a templated private Add lets the feature state be checked at registration time and the binder suppressed when the feature is off.Attack Path
- A feature-gated interface exists
A mojom interface is annotated
[RuntimeFeature=SomeFeature]withSomeFeaturedisabled (by default, Finch, or enterprise/field-trial configuration), so it is intended to be unavailable. - Host registers it anyway
The host process (commonly the browser process) builds its
BinderMapand callsAdd<Interface>(...); pre-patch this inserts the binder regardless of the feature state, leaving the interface present in the map. - Client requests the interface A client process requests the interface by name through the interface broker; because many such interfaces are exposed to renderers, this request can originate from ordinary renderer content and does not strictly require a compromised renderer.
- Binding succeeds despite the policy
The map lookup finds the binder and binds the receiver, giving the client access to an interface that the disabled
RuntimeFeatureshould have withheld — an enforcement bypass of the feature gate.
Impact Assessment
RuntimeFeature was supposed to keep unavailable, defeating the intended policy/feature gating in the host process. The vendor advisory rates this low severity and classifies it as insufficient policy enforcement; it does not report in-the-wild exploitation. The concrete capability gained depends entirely on what the mis-exposed interface does, and any further escalation from reaching such an interface is (inferred) and not demonstrated by this patch.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
Audit Directions
- Type-erasing registration pathsAudit other IPC/registration helpers that collapse a typed template entry point into an untyped internal insertion; the lost type parameter frequently drops annotation-driven checks such as
RuntimeFeaturegating. - RuntimeFeature coverageEnumerate interfaces annotated
[RuntimeFeature=...]and confirm every registration surface (interface brokers,BinderMappopulators, per-process broker tables) actually consultsGetRuntimeFeature_IsEnabledbefore exposing them. - Feature-gate vs. exposure skewLook for interfaces whose implementation checks a feature flag internally but whose binder is still registered, since a disabled feature should remove the IPC surface entirely rather than rely on downstream runtime checks.
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.