CVE-2026-13891
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifextensions/browser/event_listener_map.cc |
modified | |
TEST_Fextensions/browser/event_listener_map_unittest.cc |
modified |
Files Changed
extensions/browser/event_listener_map.ccextensions/browser/event_listener_map_unittest.cc
Patch
From 3d5abe333372f2d6c32c43ef1701052fa639637d Mon Sep 17 00:00:00 2001
From: Andrea Orru <andreaorru@chromium.org>
Date: Fri, 29 May 2026 18:42:02 -0700
Subject: [PATCH] [Extensions] Fix DoS of events via malformed filters in EventListenerMap
Prevents a compromised renderer from permanently disabling delivery of
specific extension events by registering a listener with a malformed
filter. We now check the result of EventFilter::AddEventMatcher and
abort adding the listener if it fails. This avoids poisoning the
EventListenerMap state with invalid matchers that cause subsequent
legitimate events to be dropped.
Fixed: 501631475
Change-Id: I3fe0a9988408f2e2a5cbdb391208e4b1d1ec0fbd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7886165
Reviewed-by: Tim <tjudkins@chromium.org>
Commit-Queue: Andrea Orru <andreaorru@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1638898}
---
diff --git a/extensions/browser/event_listener_map.cc b/extensions/browser/event_listener_map.cc
index ef4465c..16265ae 100644
--- a/extensions/browser/event_listener_map.cc
+++ b/extensions/browser/event_listener_map.cc
@@ -180,6 +180,9 @@
ParseEventMatcher(*listener->filter()));
MatcherID id = event_filter_.AddEventMatcher(listener->event_name(),
std::move(matcher));
+ if (id == -1) {
+ return false;
+ }
listener->set_matcher_id(id);
listeners_by_matcher_id_[id] = listener.get();
filtered_events_.insert(listener->event_name());
@@ -278,6 +281,9 @@
MatcherID id = event_filter_.AddEventMatcher(
listener_ptr->event_name(),
ParseEventMatcher(*listener_ptr->filter()));
+ if (id == -1) {
+ return false;
+ }
listener_ptr->set_matcher_id(id);
listeners_by_matcher_id_[id] = listener_ptr;
filtered_events_.insert(listener_ptr->event_name());
diff --git a/extensions/browser/event_listener_map_unittest.cc b/extensions/browser/event_listener_map_unittest.cc
index ae4c572..88eedbe 100644
--- a/extensions/browser/event_listener_map_unittest.cc
+++ b/extensions/browser/event_listener_map_unittest.cc
@@ -766,6 +766,24 @@
delegate.SetListeners(nullptr); // Avoid dangling raw_ptr.
}
+// Tests that adding a listener with a malformed filter fails and does not
+// poison the map. Regression test for crbug.com/501631475.
+TEST_F(EventListenerMapTest, AddListenerWithMalformedFilter) {
+ base::DictValue filter_dict;
+ base::ListValue url_list;
+ base::DictValue url_dict;
+ url_dict.Set("invalid_key", "x");
+ url_list.Append(std::move(url_dict));
+ filter_dict.Set("url", std::move(url_list));
+
+ std::unique_ptr<EventListener> listener = EventListener::ForExtension(
+ kEvent1Name, kExt1Id, process_.get(), std::move(filter_dict));
+
+ bool added = listeners_->AddListener(std::move(listener));
+ EXPECT_FALSE(added);
+ EXPECT_FALSE(listeners_->HasListenerForEvent(kEvent1Name));
+}
+
} // namespace
} // namespace extensions
Regression Test / PoC
diff --git a/extensions/browser/event_listener_map_unittest.cc b/extensions/browser/event_listener_map_unittest.cc
index ae4c572..88eedbe 100644
--- a/extensions/browser/event_listener_map_unittest.cc
+++ b/extensions/browser/event_listener_map_unittest.cc
@@ -766,6 +766,24 @@
delegate.SetListeners(nullptr); // Avoid dangling raw_ptr.
}
+// Tests that adding a listener with a malformed filter fails and does not
+// poison the map. Regression test for crbug.com/501631475.
+TEST_F(EventListenerMapTest, AddListenerWithMalformedFilter) {
+ base::DictValue filter_dict;
+ base::ListValue url_list;
+ base::DictValue url_dict;
+ url_dict.Set("invalid_key", "x");
+ url_list.Append(std::move(url_dict));
+ filter_dict.Set("url", std::move(url_list));
+
+ std::unique_ptr<EventListener> listener = EventListener::ForExtension(
+ kEvent1Name, kExt1Id, process_.get(), std::move(filter_dict));
+
+ bool added = listeners_->AddListener(std::move(listener));
+ EXPECT_FALSE(added);
+ EXPECT_FALSE(listeners_->HasListenerForEvent(kEvent1Name));
+}
+
} // namespace
} // namespace extensions
Original Bug Report
DoS of extension events via EventListenerMap poisoning from compromised renderer
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.
Overview: A compromised renderer process can permanently disable the delivery of specific extension events (such as management.onInstalled) profile-wide. By registering a listener with a malformed filter via Mojo, the event is marked as filtered but fails to be cleaned up, causing subsequent legitimate events to be silently dropped.
Affected files:
extensions/browser/event_listener_map.ccextensions/browser/event_router.ccextensions/common/event_filter.cc
Estimated timestamp from git blame: 2026-02-02
Summary
A vulnerability in the Chrome extension event dispatching system allows a compromised renderer process to permanently ‘blackhole’ (silently drop) any extension event for all extensions in the current profile. This can be used to disable critical security features, enterprise monitoring, and auditing extensions that rely on events like management.onInstalled or runtime.onStartup.
Vulnerability Details
The vulnerability stems from a state mismatch in extensions::EventListenerMap when handling malformed event filters provided over Mojo.
1. Registration via Mojo
The extensions::mojom::EventRouter interface is bound as an associated interface for all renderer processes, making it accessible to a compromised web page or extension process. A compromised renderer can call AddFilteredListenerForMainThread, providing an arbitrary event name (e.g., management.onInstalled) and a malformed filter dictionary (e.g., {"url":[{"invalid_key":"x"}]}). To bypass the IsExtensionEnabled check in EventRouter::AddFilteredEventListener, the attacker can use the is_listener_url union member instead of an extension ID.
2. State Poisoning
In EventListenerMap::AddListener, the code attempts to parse the filter by calling EventFilter::AddEventMatcher. Because the filter contains an invalid key, the parser (URLMatcherFactory::CreateFromURLFilterDictionary) fails, and AddEventMatcher returns -1 to indicate an error.
The listener is correctly assigned a matcher_id_ of -1. However, EventListenerMap::AddListener unconditionally executes filtered_events_.insert(listener->event_name()) regardless of the parsing failure. The event is now marked as a ‘filtered event’ within the profile.
// extensions/browser/event_listener_map.cc
if (listener->filter()) {
// AddEventMatcher returns -1 on malformed filter
MatcherID id = event_filter_.AddEventMatcher(listener->event_name(), ...);
listener->set_matcher_id(id); // Set to -1
// ...
// Unconditionally inserts event name
filtered_events_.insert(listener->event_name());
}
3. Failed Cleanup
When the attacker terminates the renderer (or explicitly removes the listener), EventListenerMap::CleanupListener is called. This function contains an early return if the matcher_id is -1:
void EventListenerMap::CleanupListener(EventListener* listener) {
if (listener->matcher_id() == -1) {
return;
}
// ... code to erase from filtered_events_ if it was the last listener
}
Because the poisoned listener has a matcher_id_ of -1, the entry in filtered_events_ is never removed. The event name remains permanently ‘filtered’ for the lifetime of the browser session.
4. Event Blackholing
When the browser attempts to dispatch the poisoned event, EventListenerMap::GetEventListeners checks IsFilteredEvent(event). Since the event name is permanently stuck in filtered_events_, the code takes the filtered path, exclusively querying EventFilter::MatchEvent.
Most security-sensitive events (like management.onInstalled) do not support filters, and legitimate extensions listen for them without a filter. Listeners without filters are never added to the EventFilter. Consequently, MatchEvent returns empty, and the event is silently dropped, bypassing security monitoring.
Potential Reproduction Steps
Note: These are suggested steps based on static analysis.
- From a compromised renderer (e.g., a normal web page), obtain the
extensions.mojom.EventRouterassociated interface. - Call
AddFilteredListenerForMainThreadwithevent_name="management.onInstalled", a malformed filter (e.g.,{"url":[{"invalid_key":"x"}]}), and uselistener_owner.listener_urlset to any valid URL to bypass extension ID validation. - Terminate the renderer process to trigger listener cleanup.
- Observe that
management.onInstalledevents are no longer delivered to any extension in the profile.
Suggested Fix
In EventListenerMap::AddListener, verify that EventFilter::AddEventMatcher succeeded before modifying the state:
if (listener->filter()) {
// ...
MatcherID id = event_filter_.AddEventMatcher(...);
if (id == -1) {
// Handle error, do not add to filtered_events_ or listeners_by_matcher_id_
return false;
}
listener->set_matcher_id(id);
listeners_by_matcher_id_[id] = listener.get();
filtered_events_.insert(listener->event_name());
}
Additionally, consider validating the listener_owner in EventRouter::AddFilteredEventListener to ensure URLs are authorized for the calling process.
Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b
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.