Critical watch CVSS 8.8 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Critical
Severity
8.8
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Base
DescriptionUse after free in Base
ComponentBase
Sub-componentBase
Bug ClassUAF
Action tierwatch — corruption primitive but gated (uncommon) — not default-reachable
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker489381399
Fix commit36acd4963684 (chromium/src) +122/-44
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-03-03 (per vendor advisory)
Fix landed2026-03-05 (public git)
Disclosed2026-03-18
Patch gap13 days — fix public in source before the advisory

Background

`base::CallbackList` / `base::OnceCallbackList`
a list of callbacks run together by Notify(). Each Add() returns a CallbackListSubscription whose destruction cancels that entry through CancelCallback().
`callbacks_` / `null_callbacks_`
std::lists of callbacks. OnceCallbackList moves callbacks that have already run (now null) into null_callbacks_ so that their subscriptions’ stored iterators stay valid until the subscription is destroyed.
`iterating_`
a flag set while Notify() loops over callbacks_. When it is set, erasing a node is unsafe because the loop may hold an iterator to it.
Re-entrant `Notify()`
a callback run by Notify() can call Notify() on the same list again, so two loops over callbacks_ are active at once.

Root Cause Analysis

Before the patch, OnceCallbackList::RunCallback() called null_callbacks_.splice(..., this->callbacks_, it) right before running each callback, moving the node out of callbacks_ in the middle of iteration. In the commit’s scenario, an outer Notify() runs callback A, A calls Notify() again, and the inner loop runs callback B and moves B’s node into null_callbacks_. The outer loop is still holding an iterator to B’s node. When A then destroys B’s subscription, CancelCallback() calls CancelNullCallback(), sees it->is_null(), and calls null_callbacks_.erase(it) unconditionally (no iterating_ check on this path), freeing the node. When control returns to the outer loop, it uses the dangling iterator, causing a heap use-after-free. The broken invariant is that list nodes must not be unlinked or erased while any Notify() frame might hold an iterator to them.

The fix stops moving nodes in RunCallback(). The new OnceCallbackList::CancelCallback(it, is_iterating) only Reset()s the node and records it in pending_erasures_ while iteration is in progress. A new CleanUpNullCallbacksPostIteration(), called from the end of the outermost Notify(), moves null nodes into null_callbacks_ and erases the pending ones once no loop holds iterators.

Key insight
OnceCallbackList changed list structure (splice, then erase) during iteration, assuming only one Notify() loop holds iterators. Re-entrancy breaks that assumption. The fix defers every splice and erase until the outermost Notify() finishes, using pending_erasures_ and CleanUpNullCallbacksPostIteration().

Attack Path

  1. Two once-callbacks registered A component adds callbacks A and B to the same OnceCallbackList and holds their CallbackListSubscriptions, as in OnceCallbackListCancelDuringReentrantNotify.
  2. Re-entrant notification The outer Notify() runs A, and A calls Notify() on the same list. The inner loop runs B, and RunCallback() moves B’s node into null_callbacks_.
  3. Subscription destroyed mid-iteration After the inner Notify() returns, A destroys B’s subscription. CancelNullCallback() erases B’s node from null_callbacks_ and frees it.
  4. Dangling iterator used The outer Notify() loop resumes and uses its iterator to the freed node, a heap use-after-free. With control of the freed memory, an attacker could turn this into memory corruption (inferred).

Impact Assessment

The vendor rates this critical (“Use after free in Base”), and the provided advisory data does not report in-the-wild exploitation. The bug is in the generic base::OnceCallbackList template, so it can occur in any process. The diff does not show which production caller performs a re-entrant Notify() plus subscription destruction, or whether web content can drive that caller. The critical rating suggests a privileged-process caller reachable by an attacker (inferred). Any exploitation beyond the heap UAF shown by the regression test, such as control of the freed node or code execution, is (inferred).

Changed Functions

FunctionChangeNotes
erase_if
base/callback_list.h
modified
if
base/callback_list.h
modified
for
base/callback_list.h
modified

Files Changed

  • base/callback_list.h

Audit Directions

  • Container mutation during re-entrant iteration
    Find other observer/callback containers (ObserverList, custom std::list/std::vector notifiers) that splice, erase or reorder entries inside a notification loop, and check what happens when a callback re-enters the same notifier.
  • Iterator-holding subscriptions
    Review RAII subscription types that store raw container iterators. Any path that erases the node while some other frame may still reference it (nested loops, it++ before run) is a UAF candidate.
  • Fixes that only guard the outermost loop
    Single-boolean flags like iterating_ do not track nesting depth. Check that cleanup runs only when the outermost frame unwinds and that inner frames never assume they own the iteration.
From 36acd49636845be2419269acbe9a5137da3d5d96 Mon Sep 17 00:00:00 2001
From: Mikel Astiz <mastiz@chromium.org>
Date: Thu, 05 Mar 2026 00:44:39 -0800
Subject: [PATCH] [base] Fix UAF in base::OnceCallbackList on re-entrant Notify()

Before this patch, `base::OnceCallbackList` was susceptible to a
heap-use-after-free when `Notify()` was called re-entrantly.

The UAF occurred because `OnceCallbackList::RunCallback()` immediately
spliced executed nodes out of `callbacks_` and into `null_callbacks_`.
If a nested `Notify()` executed a node that an outer `Notify()` loop was
already holding an iterator to, and that node's subscription was
subsequently destroyed during the re-entrant cycle, the node would be
physically erased from `null_callbacks_`. When control returned to the
outer loop, it would attempt to evaluate the now-dangling iterator.

This CL fixes the bug by deferring list mutations until the outermost
iteration completes:
1. `RunCallback()` no longer splices nodes during iteration.
2. Cancellation logic is pushed down to the subclasses via a new
   `CancelCallback()` hook, which is an extension to the pre-existing
   `CancelNullCallback()` with increased responsibilities and clearer
   semantics.
3. If a subscription is destroyed while `is_iterating` is true,
   `OnceCallbackList` resets the node and stashes its iterator in
   `pending_erasures_`.
4. A new `CleanUpNullCallbacksPostIteration()` phase runs at the end
   of the outermost `Notify()`, which safely splices executed nodes
   into `null_callbacks_` and physically erases the pending dead nodes.

As a side effect, the type-trait hack in `Notify()` based on
`is_instantiation<CallbackType, OnceCallback>` can be removed, because
this information is exposed directly by
`OnceCallbackList::CleanUpNullCallbacksPostIteration()`.

The newly-added unit-test
CallbackListTest.OnceCallbackListCancelDuringReentrantNotify reproduces
the scenario and crashed before this patch.

Change-Id: I6b1e2bcb97be1bc8d6a15e5ca7511992e00e1772
Fixed: 489381399
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7627506
Commit-Queue: Mikel Astiz <mastiz@chromium.org>
Reviewed-by: Gabriel Charette <gab@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1594520}
---

diff --git a/base/callback_list.h b/base/callback_list.h
index 174c5769..5f8cc87 100644
--- a/base/callback_list.h
+++ b/base/callback_list.h
@@ -9,6 +9,7 @@
 #include <list>
 #include <memory>
 #include <utility>
+#include <vector>
 
 #include "base/auto_reset.h"
 #include "base/base_export.h"
@@ -16,7 +17,6 @@
 #include "base/functional/bind.h"
 #include "base/functional/callback.h"
 #include "base/memory/weak_ptr.h"
-#include "base/types/is_instantiation.h"
 
 // OVERVIEW:
 //
@@ -240,17 +240,14 @@
 
     // Any null callbacks remaining in the list were canceled due to
     // Subscription destruction during iteration, and can safely be erased now.
-    const size_t erased_callbacks =
-        std::erase_if(callbacks_, [](const auto& cb) { return cb.is_null(); });
+    const bool any_callbacks_erased = static_cast<CallbackListImpl*>(this)
+                                          ->CleanUpNullCallbacksPostIteration();
 
-    // Run |removal_callback_| if any callbacks were canceled. Note that we
-    // cannot simply compare list sizes before and after iterating, since
-    // notification may result in Add()ing new callbacks as well as canceling
-    // them. Also note that if this is a OnceCallbackList, the OnceCallbacks
-    // that were executed above have all been removed regardless of whether
-    // they're counted in |erased_callbacks_|.
-    if (removal_callback_ &&
-        (erased_callbacks || is_instantiation<CallbackType, OnceCallback>)) {
+    // Run |removal_callback_| if any callbacks were canceled or executed. Note
+    // that simply comparing list sizes before and after iterating cannot be
+    // done, since notification may result in Add()ing new callbacks as well as
+    // canceling them.
+    if (removal_callback_ && any_callbacks_erased) {
       removal_callback_.Run();  // May delete |this|!
     }
   }
@@ -264,21 +261,9 @@
  private:
   // Cancels the callback pointed to by |it|, which is guaranteed to be valid.
   void CancelCallback(const typename Callbacks::iterator& it) {
-    if (static_cast<CallbackListImpl*>(this)->CancelNullCallback(it)) {
-      return;
-    }
-
-    if (iterating_) {
-      // Calling erase() here is unsafe, since the loop in Notify() may be
-      // referencing this same iterator, e.g. if adjacent callbacks'
-      // Subscriptions are both destroyed when the first one is Run().  Just
-      // reset the callback and let Notify() clean it up at the end.
-      it->Reset();
-    } else {
-      callbacks_.erase(it);
-      if (removal_callback_) {
-        removal_callback_.Run();  // May delete |this|!
-      }
+    if (static_cast<CallbackListImpl*>(this)->CancelCallback(it, iterating_) &&
+        removal_callback_) {
+      removal_callback_.Run();  // May delete |this|!
     }
   }
 
@@ -304,23 +289,71 @@
   // Runs the current callback, which may cancel it or any other callbacks.
   template <typename... RunArgs>
   void RunCallback(typename Traits::Callbacks::iterator it, RunArgs&&... args) {
-    // OnceCallbacks still have Subscriptions with outstanding iterators;
-    // splice() removes them from |callbacks_| without invalidating those.
-    null_callbacks_.splice(null_callbacks_.end(), this->callbacks_, it);
+    // Do not splice here. Splicing during iteration breaks re-entrant Notify()
+    // by invalidating the outer loop's iterator. Splicing is deferred to
+    // CleanUpNullCallbacksPostIteration(), which is called when the outermost
+    // Notify() finishes.
 
     // NOTE: Intentionally does not call std::forward<RunArgs>(args)...; see
     // comments in Notify().
     std::move(*it).Run(args...);
   }
 
-  // If |it| refers to an already-canceled callback, does any necessary cleanup
-  // and returns true.  Otherwise returns false.
-  bool CancelNullCallback(const typename Traits::Callbacks::iterator& it) {
-    if (it->is_null()) {
-      null_callbacks_.erase(it);
-      return true;
+  // Called during subscription destruction to cancel the callback. Returns true
+  // if the callback was removed from the active list and the generic removal
+  // callback should be executed. Returns false if the callback was already
+  // executed, or if the erasure is deferred due to active iteration.
+  bool CancelCallback(const typename Traits::Callbacks::iterator& it,
+                      bool is_iterating) {
+    if (is_iterating) {
+      // During iteration, nodes cannot be safely erased from |callbacks_|
+      // without invalidating iterators. They also cannot be spliced into
+      // |null_callbacks_| right now. Thus, the node is reset and tracked for
+      // erasure in CleanUpNullCallbacksPostIteration().
+      it->Reset();
+      pending_erasures_.push_back(it);
+      return false;
     }
-    return false;
+
+    if (it->is_null()) {
+      // The callback already ran, so it's safely sitting in |null_callbacks_|.
+      null_callbacks_.erase(it);
+      return false;
+    }
+
+    // The callback hasn't run yet, so it's still in |callbacks_|.
+    this->callbacks_.erase(it);
+    return true;
+  }
+
+  // Performs post-iteration cleanup. Successfully executed callbacks (which
+  // become null) are spliced into |null_callbacks_| to keep their
+  // Subscriptions' iterators valid. Callbacks explicitly canceled during
+  // iteration (tracked in |pending_erasures_|) are erased. Returns true if any
+  // callbacks were erased or spliced out.
+  bool CleanUpNullCallbacksPostIteration() {
+    bool any_spliced = false;
+    for (auto it = this->callbacks_.begin(); it != this->callbacks_.end();) {
+      if (it->is_null()) {
+        any_spliced = true;
+        auto next = std::next(it);
+        null_callbacks_.splice(null_callbacks_.end(), this->callbacks_, it);
+        it = next;
+      } else {
+        ++it;
+      }
+    }
+
+    bool any_erased = !pending_erasures_.empty();
+    for (auto pending_it : pending_erasures_) {
+      // Note: `pending_it` was originally an iterator into `callbacks_`, but
+      // the node it points to has just been spliced into `null_callbacks_`. The
+      // iterator itself remains valid and can now be used for erasure from
+      // `null_callbacks_`.
+      null_callbacks_.erase(pending_it);
+    }
+    pending_erasures_.clear();
+    return any_spliced || any_erased;
   }
 
   // Holds null callbacks whose Subscriptions are still alive, so the
@@ -328,6 +361,11 @@
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/base/callback_list_unittest.cc b/base/callback_list_unittest.cc
index 74742785..a855443f 100644
--- a/base/callback_list_unittest.cc
+++ b/base/callback_list_unittest.cc
@@ -10,6 +10,7 @@
 #include "base/functional/bind.h"
 #include "base/functional/callback_helpers.h"
 #include "base/memory/raw_ptr.h"
+#include "base/test/bind.h"
 #include "base/test/test_future.h"
 #include "testing/gtest/include/gtest/gtest.h"
 
@@ -577,6 +578,30 @@
   EXPECT_EQ(1, d.total());
 }
 
+// Regression test for crbug.com/489381399: Verifies Notify() can be called
+// reentrantly for OnceCallbackList even if a callback is canceled during the
+// reentrant notification.
+TEST(CallbackListTest, OnceCallbackListCancelDuringReentrantNotify) {
+  OnceClosureList cb_reg;
+  CallbackListSubscription sub_a, sub_b;
+
+  auto cb_a = base::BindLambdaForTesting([&]() {
+    // Re-entrant notification.
+    cb_reg.Notify();
+    // After re-entrant notification returns, sub_b has been run. Destroying it
+    // now should be a no-op.
+    sub_b = {};
+  });
+
+  auto cb_b = base::DoNothing();
+
+  sub_a = cb_reg.Add(std::move(cb_a));
+  sub_b = cb_reg.Add(std::move(cb_b));
+
+  // This should not crash.
+  cb_reg.Notify();
+}
+
 TEST(CallbackListTest, ClearPreventsInvocation) {
   Listener listener;
   RepeatingClosureList cb_reg;
Loading diff…

Original Bug Report

reported by ma...@google.com

Heap-use-after-free in base::OnceCallbackList during re-entrant Notify()

base::OnceCallbackList contains a re-entrancy bug that leads to a heap-use-after-free (UAF) if a callback triggers a nested Notify() and a subsequent callback’s subscription is destroyed before the outer Notify() loop resumes.

The UAF occurs due to iterator invalidation caused by moving list nodes between std::list containers during active iteration.

View on issue tracker