|
| 1 | +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 |
| 2 | +From: Mikel Astiz <mastiz@chromium.org> |
| 3 | +Date: Tue, 10 Mar 2026 13:22:17 -0700 |
| 4 | +Subject: [M146][base] Fix UAF in base::OnceCallbackList on re-entrant Notify() |
| 5 | + |
| 6 | +Before this patch, `base::OnceCallbackList` was susceptible to a |
| 7 | +heap-use-after-free when `Notify()` was called re-entrantly. |
| 8 | + |
| 9 | +The UAF occurred because `OnceCallbackList::RunCallback()` immediately |
| 10 | +spliced executed nodes out of `callbacks_` and into `null_callbacks_`. |
| 11 | +If a nested `Notify()` executed a node that an outer `Notify()` loop was |
| 12 | +already holding an iterator to, and that node's subscription was |
| 13 | +subsequently destroyed during the re-entrant cycle, the node would be |
| 14 | +physically erased from `null_callbacks_`. When control returned to the |
| 15 | +outer loop, it would attempt to evaluate the now-dangling iterator. |
| 16 | + |
| 17 | +This CL fixes the bug by deferring list mutations until the outermost |
| 18 | +iteration completes: |
| 19 | +1. `RunCallback()` no longer splices nodes during iteration. |
| 20 | +2. Cancellation logic is pushed down to the subclasses via a new |
| 21 | + `CancelCallback()` hook, which is an extension to the pre-existing |
| 22 | + `CancelNullCallback()` with increased responsibilities and clearer |
| 23 | + semantics. |
| 24 | +3. If a subscription is destroyed while `is_iterating` is true, |
| 25 | + `OnceCallbackList` resets the node and stashes its iterator in |
| 26 | + `pending_erasures_`. |
| 27 | +4. A new `CleanUpNullCallbacksPostIteration()` phase runs at the end |
| 28 | + of the outermost `Notify()`, which safely splices executed nodes |
| 29 | + into `null_callbacks_` and physically erases the pending dead nodes. |
| 30 | + |
| 31 | +As a side effect, the type-trait hack in `Notify()` based on |
| 32 | +`is_instantiation<CallbackType, OnceCallback>` can be removed, because |
| 33 | +this information is exposed directly by |
| 34 | +`OnceCallbackList::CleanUpNullCallbacksPostIteration()`. |
| 35 | + |
| 36 | +The newly-added unit-test |
| 37 | +CallbackListTest.OnceCallbackListCancelDuringReentrantNotify reproduces |
| 38 | +the scenario and crashed before this patch. |
| 39 | + |
| 40 | +(cherry picked from commit 36acd49636845be2419269acbe9a5137da3d5d96) |
| 41 | + |
| 42 | +Change-Id: I6b1e2bcb97be1bc8d6a15e5ca7511992e00e1772 |
| 43 | +Fixed: 489381399 |
| 44 | +Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7627506 |
| 45 | +Commit-Queue: Mikel Astiz <mastiz@chromium.org> |
| 46 | +Reviewed-by: Gabriel Charette <gab@chromium.org> |
| 47 | +Cr-Original-Commit-Position: refs/heads/main@{#1594520} |
| 48 | +Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7653916 |
| 49 | +Bot-Commit: Rubber Stamper <rubber-stamper@appspot.gserviceaccount.com> |
| 50 | +Cr-Commit-Position: refs/branch-heads/7680@{#2287} |
| 51 | +Cr-Branched-From: 76b7d80e5cda23fe6537eed26d68c92e995c7f39-refs/heads/main@{#1582197} |
| 52 | + |
| 53 | +diff --git a/base/callback_list.h b/base/callback_list.h |
| 54 | +index 82cb11dc0ee02906b009cc383c41a056861199d0..d5f99cf685486f1ea74718b4e6b228a5d83f0c29 100644 |
| 55 | +--- a/base/callback_list.h |
| 56 | ++++ b/base/callback_list.h |
| 57 | +@@ -9,6 +9,7 @@ |
| 58 | + #include <list> |
| 59 | + #include <memory> |
| 60 | + #include <utility> |
| 61 | ++#include <vector> |
| 62 | + |
| 63 | + #include "base/auto_reset.h" |
| 64 | + #include "base/base_export.h" |
| 65 | +@@ -16,7 +17,6 @@ |
| 66 | + #include "base/functional/bind.h" |
| 67 | + #include "base/functional/callback.h" |
| 68 | + #include "base/memory/weak_ptr.h" |
| 69 | +-#include "base/types/is_instantiation.h" |
| 70 | + |
| 71 | + // OVERVIEW: |
| 72 | + // |
| 73 | +@@ -240,17 +240,14 @@ class CallbackListBase { |
| 74 | + |
| 75 | + // Any null callbacks remaining in the list were canceled due to |
| 76 | + // Subscription destruction during iteration, and can safely be erased now. |
| 77 | +- const size_t erased_callbacks = |
| 78 | +- std::erase_if(callbacks_, [](const auto& cb) { return cb.is_null(); }); |
| 79 | +- |
| 80 | +- // Run |removal_callback_| if any callbacks were canceled. Note that we |
| 81 | +- // cannot simply compare list sizes before and after iterating, since |
| 82 | +- // notification may result in Add()ing new callbacks as well as canceling |
| 83 | +- // them. Also note that if this is a OnceCallbackList, the OnceCallbacks |
| 84 | +- // that were executed above have all been removed regardless of whether |
| 85 | +- // they're counted in |erased_callbacks_|. |
| 86 | +- if (removal_callback_ && |
| 87 | +- (erased_callbacks || is_instantiation<CallbackType, OnceCallback>)) { |
| 88 | ++ const bool any_callbacks_erased = static_cast<CallbackListImpl*>(this) |
| 89 | ++ ->CleanUpNullCallbacksPostIteration(); |
| 90 | ++ |
| 91 | ++ // Run |removal_callback_| if any callbacks were canceled or executed. Note |
| 92 | ++ // that simply comparing list sizes before and after iterating cannot be |
| 93 | ++ // done, since notification may result in Add()ing new callbacks as well as |
| 94 | ++ // canceling them. |
| 95 | ++ if (removal_callback_ && any_callbacks_erased) { |
| 96 | + removal_callback_.Run(); // May delete |this|! |
| 97 | + } |
| 98 | + } |
| 99 | +@@ -264,21 +261,9 @@ class CallbackListBase { |
| 100 | + private: |
| 101 | + // Cancels the callback pointed to by |it|, which is guaranteed to be valid. |
| 102 | + void CancelCallback(const typename Callbacks::iterator& it) { |
| 103 | +- if (static_cast<CallbackListImpl*>(this)->CancelNullCallback(it)) { |
| 104 | +- return; |
| 105 | +- } |
| 106 | +- |
| 107 | +- if (iterating_) { |
| 108 | +- // Calling erase() here is unsafe, since the loop in Notify() may be |
| 109 | +- // referencing this same iterator, e.g. if adjacent callbacks' |
| 110 | +- // Subscriptions are both destroyed when the first one is Run(). Just |
| 111 | +- // reset the callback and let Notify() clean it up at the end. |
| 112 | +- it->Reset(); |
| 113 | +- } else { |
| 114 | +- callbacks_.erase(it); |
| 115 | +- if (removal_callback_) { |
| 116 | +- removal_callback_.Run(); // May delete |this|! |
| 117 | +- } |
| 118 | ++ if (static_cast<CallbackListImpl*>(this)->CancelCallback(it, iterating_) && |
| 119 | ++ removal_callback_) { |
| 120 | ++ removal_callback_.Run(); // May delete |this|! |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | +@@ -304,23 +289,71 @@ class OnceCallbackList |
| 125 | + // Runs the current callback, which may cancel it or any other callbacks. |
| 126 | + template <typename... RunArgs> |
| 127 | + void RunCallback(typename Traits::Callbacks::iterator it, RunArgs&&... args) { |
| 128 | +- // OnceCallbacks still have Subscriptions with outstanding iterators; |
| 129 | +- // splice() removes them from |callbacks_| without invalidating those. |
| 130 | +- null_callbacks_.splice(null_callbacks_.end(), this->callbacks_, it); |
| 131 | ++ // Do not splice here. Splicing during iteration breaks re-entrant Notify() |
| 132 | ++ // by invalidating the outer loop's iterator. Splicing is deferred to |
| 133 | ++ // CleanUpNullCallbacksPostIteration(), which is called when the outermost |
| 134 | ++ // Notify() finishes. |
| 135 | + |
| 136 | + // NOTE: Intentionally does not call std::forward<RunArgs>(args)...; see |
| 137 | + // comments in Notify(). |
| 138 | + std::move(*it).Run(args...); |
| 139 | + } |
| 140 | + |
| 141 | +- // If |it| refers to an already-canceled callback, does any necessary cleanup |
| 142 | +- // and returns true. Otherwise returns false. |
| 143 | +- bool CancelNullCallback(const typename Traits::Callbacks::iterator& it) { |
| 144 | ++ // Called during subscription destruction to cancel the callback. Returns true |
| 145 | ++ // if the callback was removed from the active list and the generic removal |
| 146 | ++ // callback should be executed. Returns false if the callback was already |
| 147 | ++ // executed, or if the erasure is deferred due to active iteration. |
| 148 | ++ bool CancelCallback(const typename Traits::Callbacks::iterator& it, |
| 149 | ++ bool is_iterating) { |
| 150 | ++ if (is_iterating) { |
| 151 | ++ // During iteration, nodes cannot be safely erased from |callbacks_| |
| 152 | ++ // without invalidating iterators. They also cannot be spliced into |
| 153 | ++ // |null_callbacks_| right now. Thus, the node is reset and tracked for |
| 154 | ++ // erasure in CleanUpNullCallbacksPostIteration(). |
| 155 | ++ it->Reset(); |
| 156 | ++ pending_erasures_.push_back(it); |
| 157 | ++ return false; |
| 158 | ++ } |
| 159 | ++ |
| 160 | + if (it->is_null()) { |
| 161 | ++ // The callback already ran, so it's safely sitting in |null_callbacks_|. |
| 162 | + null_callbacks_.erase(it); |
| 163 | +- return true; |
| 164 | ++ return false; |
| 165 | + } |
| 166 | +- return false; |
| 167 | ++ |
| 168 | ++ // The callback hasn't run yet, so it's still in |callbacks_|. |
| 169 | ++ this->callbacks_.erase(it); |
| 170 | ++ return true; |
| 171 | ++ } |
| 172 | ++ |
| 173 | ++ // Performs post-iteration cleanup. Successfully executed callbacks (which |
| 174 | ++ // become null) are spliced into |null_callbacks_| to keep their |
| 175 | ++ // Subscriptions' iterators valid. Callbacks explicitly canceled during |
| 176 | ++ // iteration (tracked in |pending_erasures_|) are erased. Returns true if any |
| 177 | ++ // callbacks were erased or spliced out. |
| 178 | ++ bool CleanUpNullCallbacksPostIteration() { |
| 179 | ++ bool any_spliced = false; |
| 180 | ++ for (auto it = this->callbacks_.begin(); it != this->callbacks_.end();) { |
| 181 | ++ if (it->is_null()) { |
| 182 | ++ any_spliced = true; |
| 183 | ++ auto next = std::next(it); |
| 184 | ++ null_callbacks_.splice(null_callbacks_.end(), this->callbacks_, it); |
| 185 | ++ it = next; |
| 186 | ++ } else { |
| 187 | ++ ++it; |
| 188 | ++ } |
| 189 | ++ } |
| 190 | ++ |
| 191 | ++ bool any_erased = !pending_erasures_.empty(); |
| 192 | ++ for (auto pending_it : pending_erasures_) { |
| 193 | ++ // Note: `pending_it` was originally an iterator into `callbacks_`, but |
| 194 | ++ // the node it points to has just been spliced into `null_callbacks_`. The |
| 195 | ++ // iterator itself remains valid and can now be used for erasure from |
| 196 | ++ // `null_callbacks_`. |
| 197 | ++ null_callbacks_.erase(pending_it); |
| 198 | ++ } |
| 199 | ++ pending_erasures_.clear(); |
| 200 | ++ return any_spliced || any_erased; |
| 201 | + } |
| 202 | + |
| 203 | + // Holds null callbacks whose Subscriptions are still alive, so the |
| 204 | +@@ -328,6 +361,11 @@ class OnceCallbackList |
| 205 | + // OnceCallbacks, since RepeatingCallbacks are not canceled except by |
| 206 | + // Subscription destruction. |
| 207 | + typename Traits::Callbacks null_callbacks_; |
| 208 | ++ |
| 209 | ++ // Holds iterators for callbacks canceled during iteration. |
| 210 | ++ // Erasure is deferred to CleanUpNullCallbacksPostIteration() when iteration |
| 211 | ++ // completes to prevent invalidating iterators that an outer loop might hold. |
| 212 | ++ std::vector<typename Traits::Callbacks::iterator> pending_erasures_; |
| 213 | + }; |
| 214 | + |
| 215 | + template <typename Signature> |
| 216 | +@@ -344,14 +382,29 @@ class RepeatingCallbackList |
| 217 | + it->Run(args...); |
| 218 | + } |
| 219 | + |
| 220 | +- // If |it| refers to an already-canceled callback, does any necessary cleanup |
| 221 | +- // and returns true. Otherwise returns false. |
| 222 | +- bool CancelNullCallback(const typename Traits::Callbacks::iterator& it) { |
| 223 | +- // Because at most one Subscription can point to a given callback, and |
| 224 | +- // RepeatingCallbacks are only reset by CancelCallback(), no one should be |
| 225 | +- // able to request cancellation of a canceled RepeatingCallback. |
| 226 | +- DCHECK(!it->is_null()); |
| 227 | +- return false; |
| 228 | ++ // Called during subscription destruction to cancel the callback. Returns true |
| 229 | ++ // if the callback was removed from the active list and the generic removal |
| 230 | ++ // callback should be executed. Returns false if the callback was already |
| 231 | ++ // executed, or if the erasure is deferred due to active iteration. |
| 232 | ++ bool CancelCallback(const typename Traits::Callbacks::iterator& it, |
| 233 | ++ bool is_iterating) { |
| 234 | ++ if (is_iterating) { |
| 235 | ++ // During iteration, nodes cannot be safely erased from |callbacks_| |
| 236 | ++ // without invalidating iterators. The node is reset and will be swept up |
| 237 | ++ // by CleanUpNullCallbacksPostIteration(). |
| 238 | ++ it->Reset(); |
| 239 | ++ return false; |
| 240 | ++ } |
| 241 | ++ |
| 242 | ++ this->callbacks_.erase(it); |
| 243 | ++ return true; |
| 244 | ++ } |
| 245 | ++ |
| 246 | ++ // Performs post-iteration cleanup by erasing all canceled callbacks. Returns |
| 247 | ++ // true if any callbacks were erased. |
| 248 | ++ bool CleanUpNullCallbacksPostIteration() { |
| 249 | ++ return std::erase_if(this->callbacks_, |
| 250 | ++ [](const auto& cb) { return cb.is_null(); }) > 0; |
| 251 | + } |
| 252 | + }; |
| 253 | + |
| 254 | +diff --git a/base/callback_list_unittest.cc b/base/callback_list_unittest.cc |
| 255 | +index 7474278525e5efecc0de903809a54d366896d524..a855443fbae862befbc3a2a484ea335632136e94 100644 |
| 256 | +--- a/base/callback_list_unittest.cc |
| 257 | ++++ b/base/callback_list_unittest.cc |
| 258 | +@@ -10,6 +10,7 @@ |
| 259 | + #include "base/functional/bind.h" |
| 260 | + #include "base/functional/callback_helpers.h" |
| 261 | + #include "base/memory/raw_ptr.h" |
| 262 | ++#include "base/test/bind.h" |
| 263 | + #include "base/test/test_future.h" |
| 264 | + #include "testing/gtest/include/gtest/gtest.h" |
| 265 | + |
| 266 | +@@ -577,6 +578,30 @@ TEST(CallbackListTest, ReentrantNotify) { |
| 267 | + EXPECT_EQ(1, d.total()); |
| 268 | + } |
| 269 | + |
| 270 | ++// Regression test for crbug.com/489381399: Verifies Notify() can be called |
| 271 | ++// reentrantly for OnceCallbackList even if a callback is canceled during the |
| 272 | ++// reentrant notification. |
| 273 | ++TEST(CallbackListTest, OnceCallbackListCancelDuringReentrantNotify) { |
| 274 | ++ OnceClosureList cb_reg; |
| 275 | ++ CallbackListSubscription sub_a, sub_b; |
| 276 | ++ |
| 277 | ++ auto cb_a = base::BindLambdaForTesting([&]() { |
| 278 | ++ // Re-entrant notification. |
| 279 | ++ cb_reg.Notify(); |
| 280 | ++ // After re-entrant notification returns, sub_b has been run. Destroying it |
| 281 | ++ // now should be a no-op. |
| 282 | ++ sub_b = {}; |
| 283 | ++ }); |
| 284 | ++ |
| 285 | ++ auto cb_b = base::DoNothing(); |
| 286 | ++ |
| 287 | ++ sub_a = cb_reg.Add(std::move(cb_a)); |
| 288 | ++ sub_b = cb_reg.Add(std::move(cb_b)); |
| 289 | ++ |
| 290 | ++ // This should not crash. |
| 291 | ++ cb_reg.Notify(); |
| 292 | ++} |
| 293 | ++ |
| 294 | + TEST(CallbackListTest, ClearPreventsInvocation) { |
| 295 | + Listener listener; |
| 296 | + RepeatingClosureList cb_reg; |
0 commit comments