Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOut of bounds read in Extensions
DescriptionOut of bounds read in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassOOB
Tracker518240362
Fix commit32ed4c822f7d (chromium/src) +112/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
extensions/browser/event_listener_map.cc
modified
TEST_F
extensions/browser/event_listener_map_unittest.cc
modified

Files Changed

  • extensions/browser/event_listener_map_unittest.cc
  • extensions/browser/event_listener_map.cc
From 32ed4c822f7dd7201f7d744087c30fc5eef4a9ee Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
Date: Tue, 15 Sep 2026 04:34:55 -0700
Subject: [PATCH] [Extensions] Correct filtered_events_ cleanup in EventListenerMap

In EventListenerMap::CleanupListener(), the code previously checked
whether `iter->second.size() == 1` when determining whether to remove an
event from `filtered_events_`. However, this only checked the total
number of listeners for the event, not whether any remaining listeners
were filtered.

If unfiltered listeners remained registered for the event after the last
filtered listener was removed, the event was left in `filtered_events_`.
Consequently, subsequent events were treated as filtered and dispatched
via EventFilter (which had no remaining matchers), preventing them from
being delivered to the unfiltered listeners.

This change updates CleanupListener() to check whether any other
filtered listeners remain for the event before erasing it from
`filtered_events_`.

Fixed: 518240362
Change-Id: I9642b6ee039b6a1f12bee963ab7dda3fd7308148
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8384258
Reviewed-by: Solomon Kinard <solomonkinard@chromium.org>
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1697988}
---
diff --git a/extensions/browser/event_listener_map.cc b/extensions/browser/event_listener_map.cc
index d7aa8f7..e7ed2c3 100644
--- a/extensions/browser/event_listener_map.cc
+++ b/extensions/browser/event_listener_map.cc
@@ -591,11 +591,21 @@
   if (listener->matcher_id() == -1) {
     return;
   }
-  // If we're removing the final listener for an event, we can remove the
-  // entry from |filtered_events_|, as well.
+  // If we're removing the final filtered listener for an event, remove the
+  // entry from `filtered_events_` so that any remaining unfiltered listeners
+  // are still reached via `GetEventListeners()`. `listener` may still be in the
+  // list at this point, so it is excluded from the check.
   auto iter = listeners_.find(listener->event_name());
-  if (iter->second.size() == 1) {
-    filtered_events_.erase(iter->first);
+  if (iter != listeners_.end()) {
+    const bool has_other_filtered_listener =
+        std::ranges::any_of(iter->second, [listener](const auto& other) {
+          // `other` can be null if it was moved from during batch removal
+          // (e.g. RemoveListenersForProcess()).
+          return other && other.get() != listener && other->matcher_id() != -1;
+        });
+    if (!has_other_filtered_listener) {
+      filtered_events_.erase(iter->first);
+    }
   }
   CHECK_EQ(1u, listeners_by_matcher_id_.erase(listener->matcher_id()));
 }
diff --git a/extensions/browser/event_listener_map_unittest.cc b/extensions/browser/event_listener_map_unittest.cc
index 88eedbe..85d75bc7 100644
--- a/extensions/browser/event_listener_map_unittest.cc
+++ b/extensions/browser/event_listener_map_unittest.cc
@@ -766,6 +766,104 @@
   delegate.SetListeners(nullptr);  // Avoid dangling raw_ptr.
 }
 
+// Tests that removing the last filtered listener for an event while an
+// unfiltered listener for the same event remains still allows the unfiltered
+// listener to receive events. Regression test for crbug.com/518240362.
+TEST_F(EventListenerMapTest,
+       RemovingLastFilteredListenerLeavesUnfilteredListenerReachable) {
+  listeners_->AddListener(EventListener::ForExtension(
+      kEvent1Name, kExt1Id, process_.get(), std::nullopt));
+
+  std::unique_ptr<Event> event(CreateNamedEvent(kEvent1Name));
+  ASSERT_EQ(1u, listeners_->GetEventListeners(*event).size());
+
+  listeners_->AddListener(
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("google.com")));
+
+  // While the filtered listener is present, unfiltered events do not match.
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  std::unique_ptr<EventListener> filtered =
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("google.com"));
+  listeners_->RemoveListener(filtered.get());
+
+  EXPECT_EQ(1u, listeners_->GetEventListeners(*event).size());
+}
+
+// As above, but the filtered listener goes away via
+// RemoveListenersForProcess().
+TEST_F(EventListenerMapTest,
+       RemovingLastFilteredListenerByProcessLeavesUnfilteredReachable) {
+  content::MockRenderProcessHost other_process(browser_context());
+
+  listeners_->AddListener(EventListener::ForExtension(
+      kEvent1Name, kExt1Id, process_.get(), std::nullopt));
+  listeners_->AddListener(
+      EventListener::ForURL(kEvent1Name, GURL(kURL), &other_process,
+                            CreateHostSuffixFilter("google.com")));
+
+  std::unique_ptr<Event> event(CreateNamedEvent(kEvent1Name));
+  // While the filtered listener is present, unfiltered events do not match.
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  listeners_->RemoveListenersForProcess(&other_process);
+
+  EXPECT_EQ(1u, listeners_->GetEventListeners(*event).size());
+}
+
+// As above, but the filtered listener goes away via
+// RemoveListenersForExtension().
+TEST_F(EventListenerMapTest,
+       RemovingLastFilteredListenerByExtensionLeavesUnfilteredReachable) {
+  listeners_->AddListener(EventListener::ForExtension(
+      kEvent1Name, kExt1Id, process_.get(), std::nullopt));
+  listeners_->AddListener(CreateLazyListener(
+      kEvent1Name, kExt2Id, CreateHostSuffixFilter("google.com"),
+      /*is_for_service_worker=*/false));
+
+  std::unique_ptr<Event> event(CreateNamedEvent(kEvent1Name));
+  // While the filtered listener is present, unfiltered events do not match.
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  listeners_->RemoveListenersForExtension(kExt2Id);
+
+  EXPECT_EQ(1u, listeners_->GetEventListeners(*event).size());
+}
+
+// Verify retention of filtered_events_ when multiple filtered listeners exist.
+TEST_F(EventListenerMapTest,
+       RemovingOneOfMultipleFilteredListenersLeavesEventFiltered) {
+  listeners_->AddListener(EventListener::ForExtension(
+      kEvent1Name, kExt1Id, process_.get(), std::nullopt));
+
+  std::unique_ptr<EventListener> filtered1 =
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("google.com"));
+  std::unique_ptr<EventListener> filtered2 =
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("chromium.org"));
+
+  listeners_->AddListener(
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("google.com")));
+  listeners_->AddListener(
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("chromium.org")));
+
+  std::unique_ptr<Event> event(CreateNamedEvent(kEvent1Name));
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  // Removing only the first filtered listener must not clear filtered_events_.
+  listeners_->RemoveListener(filtered1.get());
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  // Removing the final filtered listener clears filtered_events_.
+  listeners_->RemoveListener(filtered2.get());
+  EXPECT_EQ(1u, listeners_->GetEventListeners(*event).size());
+}
+
 // 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) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/extensions/browser/event_listener_map_unittest.cc b/extensions/browser/event_listener_map_unittest.cc
index 88eedbe..85d75bc7 100644
--- a/extensions/browser/event_listener_map_unittest.cc
+++ b/extensions/browser/event_listener_map_unittest.cc
@@ -766,6 +766,104 @@
   delegate.SetListeners(nullptr);  // Avoid dangling raw_ptr.
 }
 
+// Tests that removing the last filtered listener for an event while an
+// unfiltered listener for the same event remains still allows the unfiltered
+// listener to receive events. Regression test for crbug.com/518240362.
+TEST_F(EventListenerMapTest,
+       RemovingLastFilteredListenerLeavesUnfilteredListenerReachable) {
+  listeners_->AddListener(EventListener::ForExtension(
+      kEvent1Name, kExt1Id, process_.get(), std::nullopt));
+
+  std::unique_ptr<Event> event(CreateNamedEvent(kEvent1Name));
+  ASSERT_EQ(1u, listeners_->GetEventListeners(*event).size());
+
+  listeners_->AddListener(
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("google.com")));
+
+  // While the filtered listener is present, unfiltered events do not match.
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  std::unique_ptr<EventListener> filtered =
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("google.com"));
+  listeners_->RemoveListener(filtered.get());
+
+  EXPECT_EQ(1u, listeners_->GetEventListeners(*event).size());
+}
+
+// As above, but the filtered listener goes away via
+// RemoveListenersForProcess().
+TEST_F(EventListenerMapTest,
+       RemovingLastFilteredListenerByProcessLeavesUnfilteredReachable) {
+  content::MockRenderProcessHost other_process(browser_context());
+
+  listeners_->AddListener(EventListener::ForExtension(
+      kEvent1Name, kExt1Id, process_.get(), std::nullopt));
+  listeners_->AddListener(
+      EventListener::ForURL(kEvent1Name, GURL(kURL), &other_process,
+                            CreateHostSuffixFilter("google.com")));
+
+  std::unique_ptr<Event> event(CreateNamedEvent(kEvent1Name));
+  // While the filtered listener is present, unfiltered events do not match.
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  listeners_->RemoveListenersForProcess(&other_process);
+
+  EXPECT_EQ(1u, listeners_->GetEventListeners(*event).size());
+}
+
+// As above, but the filtered listener goes away via
+// RemoveListenersForExtension().
+TEST_F(EventListenerMapTest,
+       RemovingLastFilteredListenerByExtensionLeavesUnfilteredReachable) {
+  listeners_->AddListener(EventListener::ForExtension(
+      kEvent1Name, kExt1Id, process_.get(), std::nullopt));
+  listeners_->AddListener(CreateLazyListener(
+      kEvent1Name, kExt2Id, CreateHostSuffixFilter("google.com"),
+      /*is_for_service_worker=*/false));
+
+  std::unique_ptr<Event> event(CreateNamedEvent(kEvent1Name));
+  // While the filtered listener is present, unfiltered events do not match.
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  listeners_->RemoveListenersForExtension(kExt2Id);
+
+  EXPECT_EQ(1u, listeners_->GetEventListeners(*event).size());
+}
+
+// Verify retention of filtered_events_ when multiple filtered listeners exist.
+TEST_F(EventListenerMapTest,
+       RemovingOneOfMultipleFilteredListenersLeavesEventFiltered) {
+  listeners_->AddListener(EventListener::ForExtension(
+      kEvent1Name, kExt1Id, process_.get(), std::nullopt));
+
+  std::unique_ptr<EventListener> filtered1 =
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("google.com"));
+  std::unique_ptr<EventListener> filtered2 =
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("chromium.org"));
+
+  listeners_->AddListener(
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("google.com")));
+  listeners_->AddListener(
+      EventListener::ForURL(kEvent1Name, GURL(kURL), process_.get(),
+                            CreateHostSuffixFilter("chromium.org")));
+
+  std::unique_ptr<Event> event(CreateNamedEvent(kEvent1Name));
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  // Removing only the first filtered listener must not clear filtered_events_.
+  listeners_->RemoveListener(filtered1.get());
+  EXPECT_EQ(0u, listeners_->GetEventListeners(*event).size());
+
+  // Removing the final filtered listener clears filtered_events_.
+  listeners_->RemoveListener(filtered2.get());
+  EXPECT_EQ(1u, listeners_->GetEventListeners(*event).size());
+}
+
 // 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) {
Loading diff…

Original Bug Report

reported by vm...@google.com

Denial of service of extension events via EventListenerMap::CleanupListener logic flaw

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: A logic flaw in EventListenerMap::CleanupListener allows a compromised web renderer to permanently prevent delivery of arbitrary unfiltered extension events profile-wide. By adding and then removing a valid filtered listener, a renderer can leave the target event permanently flagged as filtered. Because no matchers remain after removal, all subsequent events of that type are silently dropped instead of being dispatched to unfiltered listeners.

Affected files:

  • extensions/browser/event_listener_map.cc

Estimated timestamp from git blame: 2020-06-12

Root Cause Analysis

In extensions/browser/event_listener_map.cc, EventListenerMap::CleanupListener is responsible for cleaning up state when an EventListener is removed. Specifically, it decides whether to erase an event name from filtered_events_ by checking whether the listener being removed is the only listener currently registered for that event name:

// extensions/browser/event_listener_map.cc
void EventListenerMap::CleanupListener(EventListener* listener) {
  if (listener->matcher_id() == -1) {
    return;
  }
  auto iter = listeners_.find(listener->event_name());
  if (iter->second.size() == 1) {            // <--- BUG: Counts itself before removal, ignores other listeners' filter status
    filtered_events_.erase(iter->first);
  }
  CHECK_EQ(1u, listeners_by_matcher_id_.erase(listener->matcher_id()));
}

Both removal paths invoke CleanupListener before the listener is actually removed from the vector in the map:

// extensions/browser/event_listener_map.cc: RemoveListener
event_filter_.RemoveEventMatchers(matcher_ids_to_remove);
CleanupListener(it.get());                 // size() still includes `it`
std::swap(it, listeners.back());
listeners.pop_back();

The Flaw

If a filtered listener is removed while any other listener (such as a victim extension’s unfiltered listener, which has matcher_id() == -1) is registered for the same event, the size of the listener list is 2 (or greater).

As a result, iter->second.size() == 1 evaluates to false, and filtered_events_.erase is skipped. The event name remains permanently in filtered_events_ even though no filtered listeners remain.

Subsequently, during event broadcasting, GetEventListeners sees the event is marked as a “filtered event” and routes it through the filter matching pipeline instead of returning the active unfiltered listeners:

// extensions/browser/event_listener_map.cc: GetEventListeners
if (IsFilteredEvent(event)) {                    // evaluates to true due to stale entry
  std::set<MatcherID> ids = event_filter_.MatchEvent(
      event.event_name, *event.filter_info, IPC::mojom::kRoutingIdNone);
  for (const MatcherID& id : ids) {
    auto it = listeners_by_matcher_id_.find(id);
    if (it != listeners_by_matcher_id_.end()) {
      interested_listeners.insert(it->second);
    }
  }
} else {
  for (const auto& listener : GetEventListenersByName(event.event_name)) {
    interested_listeners.insert(listener.get()); // Never reached
  }
}

Since the attacker’s matcher was already removed from event_filter_, MatchEvent returns an empty set. The victim’s unfiltered listener is completely ignored, and the event is silently dropped.

Potential Attack Scenario

An attacker with control of a compromised sandboxed web renderer could potentially execute the following steps to permanently silence extension events (such as tabs.onUpdated, storage.onChanged, or security/audit-related events) profile-wide:

  1. Target an event that has an active unfiltered listener registered by a victim extension.
  2. Bind the extensions.mojom.EventRouter interface (exposed to renderers in ChromeContentBrowserClientExtensionsPart::ExposeInterfacesToRendererForRenderFrameHost).
  3. Call AddFilteredListenerForMainThread with a valid filter (e.g., {"url": [{"hostEquals": "attacker.example"}]}), specifying the renderer’s own committed origin URL to pass the ValidateMainThreadListenerOwner check.
  4. This causes filtered_events_.insert(event_name) to execute, marking the target event as filtered.
  5. Call RemoveFilteredListenerForMainThread (or terminate/navigate the renderer process, triggering RenderProcessExited -> RemoveListenersForProcess).
  6. Due to the logic bug, the listener is removed, but the event remains permanently in filtered_events_ because the victim’s unfiltered listener is still present.
  7. All future dispatches of that event type are permanently blackholed.

Note: Our tooling does not currently have the capability to run code to supply a live proof-of-concept, but static analysis indicates this logic path is fully reachable.

Proposed Fix

Instead of checking iter->second.size() == 1, CleanupListener should determine if any other filtered listeners (listeners where matcher_id() != -1) remain in the listener list for that event name. If no other filtered listeners exist, the event name should be erased from filtered_events_:

void EventListenerMap::CleanupListener(EventListener* listener) {
  if (listener->matcher_id() == -1) {
    return;
  }
  auto iter = listeners_.find(listener->event_name());
  if (iter != listeners_.end()) {
    bool remaining_filtered_listener = false;
    for (const auto& remaining : iter->second) {
      if (remaining.get() != listener && remaining->matcher_id() != -1) {
        remaining_filtered_listener = true;
        break;
      }
    }
    if (!remaining_filtered_listener) {
      filtered_events_.erase(iter->first);
    }
  }
  CHECK_EQ(1u, listeners_by_matcher_id_.erase(listener->matcher_id()));
}

Evaluated with Chrome root at commit: 208ca3371d87589335b108c431b95a36d768dc47


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.

View on issue tracker