Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in TabStrip
DescriptionUse after free in TabStrip
ComponentTabStrip
Bug ClassUAF
Tracker499386363
Fix commit0985a479f32c (chromium/src) +37/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
ReentrancyCheck
chrome/browser/ui/tabs/tab_strip_model.cc
modified
TabStripModelCloseWebContentsOnChangeObserver
chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
modified
TabStripModelTestTabGroupEntryPointsEnabled
chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
modified

Files Changed

  • chrome/browser/ui/tabs/tab_strip_model.cc
  • chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
From 0985a479f32c2194a7632473c8d9c741bb388220 Mon Sep 17 00:00:00 2001
From: Charles Meng <charlesmeng@chromium.org>
Date: Fri, 10 Apr 2026 14:51:11 -0700
Subject: [PATCH] Fix UAF in tab strip model

Add a reentrancy check to CloseWebContentsAt so that it cannot be called
during a move operation (which would free the tab pointer, causing UAF).

Change UpdateTabInSplitImpl to use the private impl CloseTabs instead
of CloseWebContentsAt so that there is no reentrancy check.

Fixed: 499386363
Change-Id: Id2ad4abd1505259e0dcac07b0adebcafc3f50d87
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7745674
Commit-Queue: Charles Meng <charlesmeng@chromium.org>
Reviewed-by: Eshwar Stalin <estalin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1613117}
---

diff --git a/chrome/browser/ui/tabs/tab_strip_model.cc b/chrome/browser/ui/tabs/tab_strip_model.cc
index 7db62be3..8b9e8faa 100644
--- a/chrome/browser/ui/tabs/tab_strip_model.cc
+++ b/chrome/browser/ui/tabs/tab_strip_model.cc
@@ -129,13 +129,17 @@
 class ReentrancyCheck {
  public:
   explicit ReentrancyCheck(bool* guard_flag) : guard_flag_(guard_flag) {
-    CHECK_CURRENTLY_ON(content::BrowserThread::UI);
-    CHECK(!*guard_flag_);
+    ValidateNotReentrant(guard_flag_);
     *guard_flag_ = true;
   }
 
   ~ReentrancyCheck() { *guard_flag_ = false; }
 
+  static void ValidateNotReentrant(bool* guard_flag) {
+    CHECK_CURRENTLY_ON(content::BrowserThread::UI);
+    CHECK(!*guard_flag);
+  }
+
  private:
   const raw_ptr<bool> guard_flag_;
 };
@@ -1315,6 +1319,8 @@
 }
 
 void TabStripModel::CloseWebContentsAt(int index, uint32_t close_types) {
+  ReentrancyCheck::ValidateNotReentrant(&reentrancy_guard_);
+
   CHECK(ContainsIndex(index));
   CloseTabs({GetWebContentsAt(index)}, close_types);
 }
@@ -4250,8 +4256,7 @@
     const int split_index = GetIndexOfTab(split_tab);
     MoveTabToIndexImpl(update_index, split_index, split_tab->GetGroup(),
                        split_tab->IsPinned(), initial_split_active);
-    CloseWebContentsAt(GetIndexOfTab(split_tab),
-                       TabCloseTypes::CLOSE_USER_GESTURE);
+    CloseTabs({split_tab->GetContents()}, TabCloseTypes::CLOSE_USER_GESTURE);
   } else {
     tabs::TabInterface* update_tab = GetTabAtIndex(update_index);
     std::optional<tab_groups::TabGroupId> initial_split_group =
diff --git a/chrome/browser/ui/tabs/tab_strip_model_browsertest.cc b/chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
index 2d3e10b..dce8eae 100644
--- a/chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
+++ b/chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
@@ -478,6 +478,34 @@
             GetTabStripStateString(tab_strip_model));
 }
 
+IN_PROC_BROWSER_TEST_F(TabStripModelBrowserTest,
+                       TestCloseTabDuringMoveOperation) {
+  TabStripModel* const tab_strip_model = browser()->tab_strip_model();
+  ASSERT_NO_FATAL_FAILURE(
+      PrepareTabstripForSelectionTest(tab_strip_model, 2, 0, {0}));
+  ASSERT_EQ(2, tab_strip_model->count());
+
+  class TabStripModelCloseWebContentsOnChangeObserver
+      : public TabStripModelObserver {
+   public:
+    void OnTabStripModelChanged(
+        TabStripModel* tab_strip_model,
+        const TabStripModelChange& change,
+        const TabStripSelectionChange& selection) override {
+      if (change.type() == TabStripModelChange::Type::kMoved) {
+        tab_strip_model->CloseWebContentsAt(1,
+                                            TabCloseTypes::CLOSE_USER_GESTURE);
+      }
+    }
+  };
+  TabStripModelCloseWebContentsOnChangeObserver close_tab_observer;
+  tab_strip_model->AddObserver(&close_tab_observer);
+
+  EXPECT_DEATH_IF_SUPPORTED(tab_strip_model->MoveWebContentsAt(0, 1, false),
+                            "Check failed");
+  tab_strip_model->RemoveObserver(&close_tab_observer);
+}
+
 class TabStripModelTestTabGroupEntryPointsEnabled
     : public TabStripModelBrowserTest {
  public:
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/tabs/tab_strip_model_browsertest.cc b/chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
index 2d3e10b..dce8eae 100644
--- a/chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
+++ b/chrome/browser/ui/tabs/tab_strip_model_browsertest.cc
@@ -478,6 +478,34 @@
             GetTabStripStateString(tab_strip_model));
 }
 
+IN_PROC_BROWSER_TEST_F(TabStripModelBrowserTest,
+                       TestCloseTabDuringMoveOperation) {
+  TabStripModel* const tab_strip_model = browser()->tab_strip_model();
+  ASSERT_NO_FATAL_FAILURE(
+      PrepareTabstripForSelectionTest(tab_strip_model, 2, 0, {0}));
+  ASSERT_EQ(2, tab_strip_model->count());
+
+  class TabStripModelCloseWebContentsOnChangeObserver
+      : public TabStripModelObserver {
+   public:
+    void OnTabStripModelChanged(
+        TabStripModel* tab_strip_model,
+        const TabStripModelChange& change,
+        const TabStripSelectionChange& selection) override {
+      if (change.type() == TabStripModelChange::Type::kMoved) {
+        tab_strip_model->CloseWebContentsAt(1,
+                                            TabCloseTypes::CLOSE_USER_GESTURE);
+      }
+    }
+  };
+  TabStripModelCloseWebContentsOnChangeObserver close_tab_observer;
+  tab_strip_model->AddObserver(&close_tab_observer);
+
+  EXPECT_DEATH_IF_SUPPORTED(tab_strip_model->MoveWebContentsAt(0, 1, false),
+                            "Check failed");
+  tab_strip_model->RemoveObserver(&close_tab_observer);
+}
+
 class TabStripModelTestTabGroupEntryPointsEnabled
     : public TabStripModelBrowserTest {
  public:
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential Use-After-Free in TabStripModel::MoveTabToIndexImpl due to missing re-entrancy guard

Flapjack, 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 security team.

Overview: A potential Use-After-Free vulnerability exists in TabStripModel because CloseWebContentsAt and CloseTabs lack re-entrancy guards. This allows a synchronous observer to destroy a tab while it is being moved, bypassing MiraclePtr protection and leading to a use-after-free when the move operation resumes.

Affected files:

  • chrome/browser/ui/tabs/tab_strip_model.cc
  • chrome/browser/ui/tabs/tab_strip_model.h

Estimated timestamp from git blame: 2025-12-30

Summary

A potential Use-After-Free (UAF) vulnerability has been identified in TabStripModel within the browser process. The issue arises from the omission of ReentrancyCheck guards in the TabStripModel::CloseWebContentsAt and TabStripModel::CloseTabs methods. This omission permits synchronous observers to initiate tab closures while another model operation, such as moving a tab, is already underway.

Specifically, during a tab move operation, TabStripModel::MoveTabToIndexImpl caches a raw pointer to the tab, synchronously notifies observers, and then later dereferences the raw pointer. If an observer closes the tab during the notification, the tab is destroyed, leading to a UAF upon return. Furthermore, the local raw_ptr that temporarily protects the tab during the notification is destroyed before the vulnerable dereference, bypassing MiraclePtr (BRP) protections.

Technical Details

  1. Missing Guard: Most state-modifying methods in TabStripModel (e.g., MoveWebContentsAt, InsertWebContentsAt) instantiate a ReentrancyCheck reentrancy_check(&reentrancy_guard_) to prevent nested modifications. However, CloseWebContentsAt and CloseTabs do not.
  2. Vulnerable Flow: When a tab is moved, MoveWebContentsAt correctly sets the re-entrancy guard and calls MoveTabToIndexImpl.
  3. Raw Pointer Usage: MoveTabToIndexImpl stores a raw pointer: tabs::TabInterface* const tab = GetTabAtIndex(initial_index);.
  4. Observer Notification: It then calls SendMoveNotificationForTab(initial_index, final_index, tab, selection);.
  5. Temporary BRP Protection: Inside SendMoveNotificationForTab, the tab is placed into a TabStripModelChange::Move struct, which stores it as a raw_ptr<tabs::TabInterface>. This temporarily increments the BackupRefPtr (BRP) reference count.
  6. Re-entrant Close: Observers are notified synchronously. If an attacker-controlled or influenced observer (e.g., via an extension) calls CloseWebContentsAt for the moving tab, the call proceeds because CloseWebContentsAt lacks a ReentrancyCheck. The tab is subsequently detached and deleted.
  7. Protection Scope Ends: When the notification completes, SendMoveNotificationForTab returns, and its local TabStripModelChange object is destroyed. This decrements the BRP reference count to zero, freeing the memory for reallocation.
  8. The UAF: Execution resumes in MoveTabToIndexImpl, which immediately calls if (initial_pinned_state != tab->IsPinned()). The virtual call IsPinned() dereferences the now-freed and unquarantined raw tab pointer. If an attacker has sprayed the heap in the interim, this results in Remote Code Execution (RCE) in the browser process.

Note: These are potential steps, as our tooling does not currently run the code to produce a working proof-of-concept.

Suggested Fix

  1. Add Re-entrancy Guards: Instantiate ReentrancyCheck reentrancy_check(&reentrancy_guard_); in TabStripModel::CloseWebContentsAt and TabStripModel::CloseTabs to prevent nested tab closures.
  2. Defense in Depth: In MoveTabToIndexImpl (and similar methods like InsertTabAtIndexImpl), use base::raw_ptr<tabs::TabInterface> tab = GetTabAtIndex(...) instead of a raw pointer. This ensures that BRP quarantines the memory for the entire duration of the function’s execution, turning any potential UAFs from complex re-entrancy bugs into safe crashes.

Evaluated with Chrome root at commit: 09ec9e7cc4d24823d20b6d37cf3d282734f6bf0f


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.

View on issue tracker