Medium watch CVSS 9.6 chrome UAF 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
9.6
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Sessions
DescriptionUse after free in Sessions
ComponentSessions
Sub-componentTab Restore
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.
Tracker501572758
Fix commit8c6e83aa8a20 (chromium/src) +166/-16
CWECWE-416 (Use-after-free)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
CISA KEVNot listed
CreditedGoogle
Reported2026-04-11 (per vendor advisory)
Fix landed2026-07-17 (public git)
Disclosed2026-08-25
Patch gap39 days — fix public in source before the advisory

Background

`TabRestoreServiceHelper`
the cross-platform sessions component that tracks recently closed tabs, groups, splits, and windows so they can be restored later.
`restoring_`
an internal flag on the helper that is set while a restore operation (e.g. RestoreEntryById) is executing.
Historical entry creation
BrowserClosing, CreateHistoricalGroup, and CreateHistoricalSplit each append a new entry to the service’s entries() list (and touch closing_contexts_ / closing_groups_).
Pending tab closure
on Android, a tab closed with undo enabled is not immediately committed; committing it later synchronously invokes the sessions callbacks that create historical entries.

Root Cause Analysis

Restoring an entry can synchronously commit a pending tab-group closure, which re-enters the same TabRestoreServiceHelper through CreateHistoricalGroup (and, analogously, BrowserClosing / CreateHistoricalSplit) while a restore is still in progress. These re-entrant paths mutate the entries_ list — appending a new entry and potentially pruning old ones once kMaxEntries is reached — while the outer restore loop is still holding iterators/pointers into that same list, so the restore’s cached data pointers become dangling (a use-after-free). Only RestoreEntryById already consulted restoring_; the historical-creation paths did not, so the re-entrant mutation was unguarded.

The fix adds an early if (restoring_) return; to BrowserClosing, CreateHistoricalGroup, and CreateHistoricalSplit, so no new entry is created (and no pruning occurs) during an active restore. As a defense-in-depth complement, the Android RecentlyClosedBridge now commits all pending closures before starting a restore, so the re-entrant commit no longer happens mid-restore.

Key insight
The single mistake was that the entry-creation callbacks could re-enter and mutate entries_ during an in-flight restore because they never checked restoring_; adding that guard (and pre-committing closures on Android) closes the concurrent-modification window that produced dangling pointers.

Attack Path

  1. Set up a pending closure Leave a tab or tab group closed with undo pending in the regular tab model, so a later commit will synchronously call back into CreateHistoricalGroup.
  2. Trigger a restore Invoke a restore (e.g. openRecentlyClosedTab / RestoreEntryById) for another entry, setting restoring_ and starting iteration over entries_.
  3. Re-enter during restore The restore synchronously commits the pending group closure, which (pre-patch) calls CreateHistoricalGroup, appending a new entry and pruning the list once kMaxEntries is hit.
  4. Dangle the pointer The concurrent mutation invalidates the entry/data pointers the outer restore is still using, yielding a use-after-free (inferred) as the concrete corruption/dereference is not shown in the diff.

Impact Assessment

An attacker who can drive tab-restore and tab-closure sequences reaches a use-after-free in the browser-process Sessions component; the advisory classifies this as a use-after-free in Sessions. Preconditions are having a pending (undoable) closure and triggering a concurrent restore, which the shipped Android test reproduces deterministically. No exploited-in-the-wild status or further escalation (e.g. code execution) is stated in the advisory, so any such escalation is (inferred).

Changed Functions

FunctionChangeNotes
if
chrome/android/java/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridge.java
modified
for
chrome/android/javatests/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridgeTest.java
modified
if
chrome/android/javatests/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridgeTest.java
modified
if
components/sessions/core/tab_restore_service_helper.cc
modified
for
chrome/browser/sessions/tab_restore_service_unittest.cc
modified

Files Changed

  • components/sessions/core/tab_restore_service_helper.cc
  • chrome/android/java/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridge.java
  • chrome/browser/sessions/tab_restore_service_unittest.cc
  • chrome/android/javatests/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridgeTest.java

Audit Directions

  • Re-entrancy into container mutation
    Look for other sessions/tab-model paths that append to or prune entries_/closing_* collections and can be re-entered by synchronous callbacks (commit-on-restore, close-on-open) while an outer operation holds iterators.
  • Single-guard invariants
    When one entry point guards on a state flag like restoring_, audit all sibling functions that mutate the same shared state; a guard applied to only some paths leaves the others exploitable.
  • Undo/commit timing
    Review flows where undoable (deferred) closures are committed synchronously as a side effect of an unrelated action, and ensure the commit cannot fire in the middle of an operation over the same data structures.
From 8c6e83aa8a20f8d1af9aafb86df0d0a77899460c Mon Sep 17 00:00:00 2001
From: Calder Kitagawa <[email protected]>
Date: Fri, 17 Jul 2026 12:10:36 -0700
Subject: [PATCH] [Tab Restore] Fix issue if restore and closure are concurrent

If adding to the TabRestoreService is concurrent with a
restore then the internal state of the service can become
broken (invalid data pointers). Fix this by adding the
`restoring_` check to other restore cases.

To help reduce places where this could occur; commit tab
closures in Java BEFORE starting any restore operation.

Fixed: 501572758
Change-Id: I59ebe30b479c45ed5be3e6deb32f33bd6a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8116512
Reviewed-by: Fiaz Muhammad <[email protected]>
Commit-Queue: Calder Kitagawa <[email protected]>
Reviewed-by: Darryl James <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1664102}
---
diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridge.java b/chrome/android/java/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridge.java
index b0080e4..8d6bc9d 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridge.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridge.java
@@ -137,10 +137,24 @@
         return received ? entries : null;
     }
 
+    /**
+     * Commits all pending tab closures in the regular tab model. Restoring a tab can synchronously
+     * commit pending tab group closures, which calls back into TabRestoreService's
+     * CreateHistoricalGroup. To prevent concurrent modification issues in TabRestoreService, we
+     * commit all pending closures before starting restoration.
+     */
+    private void commitTabClosuresInRegularModel() {
+        TabModel regularModel = mTabModelSelector.getModel(/* incognito= */ false);
+        if (regularModel != null) {
+            regularModel.commitAllTabClosures();
+        }
+    }
+
     @Override
     public boolean openRecentlyClosedTab(
             TabModel tabModel, RecentlyClosedTab recentTab, int windowOpenDisposition) {
         assert mTabModelSelector.getModel(tabModel.isIncognito()) == tabModel;
+        commitTabClosuresInRegularModel();
         return RecentlyClosedBridgeJni.get()
                 .openRecentlyClosedTab(
                         mNativeBridge, tabModel, recentTab.getSessionId(), windowOpenDisposition);
@@ -150,6 +164,7 @@
     public boolean openRecentlyClosedEntry(TabModel tabModel, RecentlyClosedEntry recentEntry) {
         assert mTabModelSelector.getModel(tabModel.isIncognitoBranded()) == tabModel
                 && recentEntry instanceof SessionRecentlyClosedEntry;
+        commitTabClosuresInRegularModel();
         SessionRecentlyClosedEntry sessionRecentEntry = (SessionRecentlyClosedEntry) recentEntry;
         return RecentlyClosedBridgeJni.get()
                 .openRecentlyClosedEntry(
@@ -159,6 +174,7 @@
     @Override
     public void openMostRecentlyClosedEntry(TabModel tabModel) {
         assert mTabModelSelector.getModel(tabModel.isIncognito()) == tabModel;
+        commitTabClosuresInRegularModel();
         RecentlyClosedBridgeJni.get().openMostRecentlyClosedEntry(mNativeBridge, tabModel);
     }
 
diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridgeTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridgeTest.java
index 509a9bdc..f65fc05 100644
--- a/chrome/android/javatests/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridgeTest.java
+++ b/chrome/android/javatests/src/org/chromium/chrome/browser/ntp/RecentlyClosedBridgeTest.java
@@ -1670,33 +1670,26 @@
                 });
 
         // 1. Blank tab
-        // 2. Restored tabB
-        // 3. Restored tabC
+        // 2. Restored tabA (Group 2)
         final List<Tab> tabs = getAllTabs();
-        Assert.assertEquals(3, tabs.size());
-        Assert.assertEquals(group1Titles[1], ChromeTabUtils.getTitleOnUiThread(tabs.get(1)));
-        Assert.assertEquals(group1Urls[1], ChromeTabUtils.getUrlOnUiThread(tabs.get(1)).getSpec());
-        Assert.assertEquals(group1Titles[0], ChromeTabUtils.getTitleOnUiThread(tabs.get(2)));
-        Assert.assertEquals(group1Urls[0], ChromeTabUtils.getUrlOnUiThread(tabs.get(2)).getSpec());
+        Assert.assertEquals(2, tabs.size());
+        Assert.assertEquals(group2Titles[0], ChromeTabUtils.getTitleOnUiThread(tabs.get(1)));
+        Assert.assertEquals(group2Urls[0], ChromeTabUtils.getUrlOnUiThread(tabs.get(1)).getSpec());
         ThreadUtils.runOnUiThreadBlocking(
                 () -> {
                     Assert.assertEquals(
-                            "Group 1", mTabModel.getTabGroupTitle(tabs.get(1).getTabGroupId()));
+                            "Group 2", mTabModel.getTabGroupTitle(tabs.get(1).getTabGroupId()));
                     Assert.assertTrue(mTabModel.isTabInTabGroup(tabs.get(1)));
-                    Assert.assertEquals(
-                            Arrays.asList(new Tab[] {tabs.get(1), tabs.get(2)}),
-                            mTabModel.getRelatedTabList(tabs.get(1).getId()));
                 });
-
         tabCount = getRecentEntriesAndReturnActiveTabCount(recentEntries);
-        Assert.assertEquals(3, tabCount);
+        Assert.assertEquals(2, tabCount);
         Assert.assertEquals(1, recentEntries.size());
         assertEntryIs(
                 recentEntries.get(0),
                 RecentlyClosedGroup.class,
-                new String[] {"Group 2"},
-                group2Titles,
-                group2Urls);
+                new String[] {"Group 1"},
+                group1Titles,
+                group1Urls);
     }
 
     // TODO(crbug.com/40218713): Add a test a case where bulk closures remain in the native service,
@@ -1763,6 +1756,62 @@
         assertTabsAre(event.getTabs(), titles, urls);
     }
 
+    @Test
+    @MediumTest
+    public void testOpenRecentlyClosedTab_CommitsPendingClosures() {
+        final String urlA = getUrl(TEST_PAGE_A);
+        final String urlB = getUrl(TEST_PAGE_B);
+        final Tab tabA = mActivityTestRule.loadUrlInNewTab(urlA, /* incognito= */ false);
+        final Tab tabB = mActivityTestRule.loadUrlInNewTab(urlB, /* incognito= */ false);
+
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    // Close tabB without undo so it goes to recently closed.
+                    closeTabs(TabClosureParams.closeTab(tabB).allowUndo(false).build());
+                    // Close tabA with undo so it is pending.
+                    closeTabs(TabClosureParams.closeTab(tabA).allowUndo(true).build());
+                });
+
+        // Verify tabA closure is pending.
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    Assert.assertTrue(mTabModel.isClosurePending(tabA.getId()));
+                });
+
+        final List<RecentlyClosedEntry> recentEntries = new ArrayList<>();
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    recentEntries.addAll(
+                            mRecentlyClosedBridge.getRecentlyClosedEntries(MAX_ENTRY_COUNT));
+                });
+
+        RecentlyClosedTab recentTabB = null;
+        for (RecentlyClosedEntry entry : recentEntries) {
+            if (entry instanceof RecentlyClosedTab) {
+                RecentlyClosedTab rt = (RecentlyClosedTab) entry;
+                if (rt.getUrl().getSpec().equals(urlB)) {
+                    recentTabB = rt;
+                    break;
+                }
+            }
+        }
+        Assert.assertNotNull(recentTabB);
+        final RecentlyClosedTab finalRecentTabB = recentTabB;
+
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    // Restore tabB. This should commit tabA's closure.
+                    mRecentlyClosedBridge.openRecentlyClosedTab(
+                            mTabModel, finalRecentTabB, WindowOpenDisposition.NEW_FOREGROUND_TAB);
+                });
+
+        // Verify tabA closure is no longer pending (it was committed).
+        ThreadUtils.runOnUiThreadBlocking(
+                () -> {
+                    Assert.assertFalse(mTabModel.isClosurePending(tabA.getId()));
+                });
+    }
+
     private List<Tab> getAllTabs() {
         final List<Tab> list = new ArrayList<>();
         ThreadUtils.runOnUiThreadBlocking(
diff --git a/components/sessions/core/tab_restore_service_helper.cc b/components/sessions/core/tab_restore_service_helper.cc
index 14a14d2..8ee937a 100644
--- a/components/sessions/core/tab_restore_service_helper.cc
+++ b/components/sessions/core/tab_restore_service_helper.cc
@@ -304,6 +304,10 @@
 }
 
 void TabRestoreServiceHelper::BrowserClosing(LiveTabContext* context) {
+  if (restoring_) {
+    return;
+  }
+
   closing_contexts_.insert(context);
 
   auto window = std::make_unique<Window>();
@@ -386,6 +390,10 @@
 void TabRestoreServiceHelper::CreateHistoricalGroup(
     LiveTabContext* context,
     const tab_groups::TabGroupId& id) {
+  if (restoring_) {
+    return;
+  }
+
   closing_groups_.insert(id);
 
   auto group = CreateHistoricalGroupImpl(context, id);
@@ -397,6 +405,10 @@
 void TabRestoreServiceHelper::CreateHistoricalSplit(
     LiveTabContext* context,
     const split_tabs::SplitTabId& id) {
+  if (restoring_) {
+    return;
+  }
+
   std::vector<std::pair<int, LiveTab*>> split_tabs;
   for (int tab_index = 0; tab_index < context->GetTabCount(); ++tab_index) {
     if (context->GetSplitForTab(tab_index) == id) {
diff --git a/chrome/browser/sessions/tab_restore_service_unittest.cc b/chrome/browser/sessions/tab_restore_service_unittest.cc
index a495e72..e5f6eb37 100644
--- a/chrome/browser/sessions/tab_restore_service_unittest.cc
+++ b/chrome/browser/sessions/tab_restore_service_unittest.cc
@@ -1645,3 +1645,76 @@
 
   EXPECT_TRUE(service_->entries().empty());
 }
+
+TEST_F(TabRestoreServiceImplWithMockClientTest,
+       DontCreateGroupEntryWhileRestoring) {
+  ON_CALL(*mock_tab_restore_service_client_, ShouldTrackURLForRestore(_))
+      .WillByDefault(Return(true));
+
+  // Create a group entry with two tabs and place it at the back of the list so
+  // that it would be the first entry dropped if the list were ever pruned.
+  auto group = std::make_unique<sessions::tab_restore::Group>();
+  group->group_id = tab_groups::TabGroupId::GenerateNew();
+  for (int i = 0; i < 2; ++i) {
+    auto tab = std::make_unique<Tab>();
+    tab->navigations.push_back(ContentTestHelper::CreateNavigation(
+        base::StringPrintf("http://group/%d", i), "title"));
+    tab->current_navigation_index = 0;
+    group->tabs.push_back(std::move(tab));
+  }
+  const SessionID group_entry_id = group->id;
+  mutable_entries()->push_back(std::move(group));
+
+  // Fill the rest of the list up to kMaxEntries with tab entries that are
+  // newer than the group entry.
+  const size_t max_entries = kMaxEntries;
+  for (size_t i = 0; i < max_entries - 1; ++i) {
+    auto tab = std::make_unique<Tab>();
+    tab->navigations.push_back(ContentTestHelper::CreateNavigation(
+        base::StringPrintf("http://%d", static_cast<int>(i)), "title"));
+    tab->current_navigation_index = 0;
+    mutable_entries()->push_front(std::move(tab));
+  }
+  ASSERT_EQ(max_entries, service_->entries().size());
+
+  // A separate context that simulates a tab group closure being committed
+  // while the restore below is in progress.
+  testing::NiceMock<MockLiveTabContext> closing_context;
+  testing::NiceMock<MockLiveTab> closing_tab;
+  tab_groups::TabGroupId closing_group_id =
+      tab_groups::TabGroupId::GenerateNew();
+  tab_groups::TabGroupVisualData closing_group_visual_data;
+  ON_CALL(closing_context, GetSessionID())
+      .WillByDefault(Return(SessionID::NewUnique()));
+  ON_CALL(closing_context, GetTabCount()).WillByDefault(Return(1));
+  ON_CALL(closing_context, GetTabGroupForTab(0))
+      .WillByDefault(Return(closing_group_id));
+  ON_CALL(closing_context, GetVisualDataForGroup(_))
+      .WillByDefault(Return(&closing_group_visual_data));
+  ON_CALL(closing_context, GetLiveTabAt(0)).WillByDefault(Return(&closing_tab));
+  ON_CALL(closing_tab, GetEntryCount()).WillByDefault(Return(1));
+  ON_CALL(closing_tab, GetEntryAtIndex(_))
+      .WillByDefault(
+          Return(ContentTestHelper::CreateNavigation("http://closing", "T")));
+
+  // Restoring a tab can synchronously commit pending tab group closures, which
+  // calls back into CreateHistoricalGroup. The service must not add a new
+  // entry while a restore is already in progress.
+  testing::NiceMock<MockLiveTabContext> restore_context;
+  int restored_tab_count = 0;
+  ON_CALL(restore_context, AddRestoredTab(_, _, _, _, _))
+      .WillByDefault([&](const sessions::tab_restore::Tab&, int, bool, bool,
+                         sessions::tab_restore::Type) -> sessions::LiveTab* {
+        ++restored_tab_count;
+        EXPECT_TRUE(service_->IsRestoring());
+        service_->CreateHistoricalGroup(&closing_context, closing_group_id);
+        EXPECT_EQ(group_entry_id, service_->entries().back()->id);
+        return nullptr;
+      });
+
+  service_->RestoreEntryById(&restore_context, group_entry_id,
+                             WindowOpenDisposition::NEW_FOREGROUND_TAB);
+
+  EXPECT_EQ(2, restored_tab_count);
+  EXPECT_EQ(max_entries - 1, service_->entries().size());
+}
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/sessions/tab_restore_service_unittest.cc b/chrome/browser/sessions/tab_restore_service_unittest.cc
index a495e72..e5f6eb37 100644
--- a/chrome/browser/sessions/tab_restore_service_unittest.cc
+++ b/chrome/browser/sessions/tab_restore_service_unittest.cc
@@ -1645,3 +1645,76 @@
 
   EXPECT_TRUE(service_->entries().empty());
 }
+
+TEST_F(TabRestoreServiceImplWithMockClientTest,
+       DontCreateGroupEntryWhileRestoring) {
+  ON_CALL(*mock_tab_restore_service_client_, ShouldTrackURLForRestore(_))
+      .WillByDefault(Return(true));
+
+  // Create a group entry with two tabs and place it at the back of the list so
+  // that it would be the first entry dropped if the list were ever pruned.
+  auto group = std::make_unique<sessions::tab_restore::Group>();
+  group->group_id = tab_groups::TabGroupId::GenerateNew();
+  for (int i = 0; i < 2; ++i) {
+    auto tab = std::make_unique<Tab>();
+    tab->navigations.push_back(ContentTestHelper::CreateNavigation(
+        base::StringPrintf("http://group/%d", i), "title"));
+    tab->current_navigation_index = 0;
+    group->tabs.push_back(std::move(tab));
+  }
+  const SessionID group_entry_id = group->id;
+  mutable_entries()->push_back(std::move(group));
+
+  // Fill the rest of the list up to kMaxEntries with tab entries that are
+  // newer than the group entry.
+  const size_t max_entries = kMaxEntries;
+  for (size_t i = 0; i < max_entries - 1; ++i) {
+    auto tab = std::make_unique<Tab>();
+    tab->navigations.push_back(ContentTestHelper::CreateNavigation(
+        base::StringPrintf("http://%d", static_cast<int>(i)), "title"));
+    tab->current_navigation_index = 0;
+    mutable_entries()->push_front(std::move(tab));
+  }
+  ASSERT_EQ(max_entries, service_->entries().size());
+
+  // A separate context that simulates a tab group closure being committed
+  // while the restore below is in progress.
+  testing::NiceMock<MockLiveTabContext> closing_context;
+  testing::NiceMock<MockLiveTab> closing_tab;
+  tab_groups::TabGroupId closing_group_id =
+      tab_groups::TabGroupId::GenerateNew();
+  tab_groups::TabGroupVisualData closing_group_visual_data;
+  ON_CALL(closing_context, GetSessionID())
+      .WillByDefault(Return(SessionID::NewUnique()));
+  ON_CALL(closing_context, GetTabCount()).WillByDefault(Return(1));
+  ON_CALL(closing_context, GetTabGroupForTab(0))
+      .WillByDefault(Return(closing_group_id));
+  ON_CALL(closing_context, GetVisualDataForGroup(_))
+      .WillByDefault(Return(&closing_group_visual_data));
+  ON_CALL(closing_context, GetLiveTabAt(0)).WillByDefault(Return(&closing_tab));
+  ON_CALL(closing_tab, GetEntryCount()).WillByDefault(Return(1));
+  ON_CALL(closing_tab, GetEntryAtIndex(_))
+      .WillByDefault(
+          Return(ContentTestHelper::CreateNavigation("http://closing", "T")));
+
+  // Restoring a tab can synchronously commit pending tab group closures, which
+  // calls back into CreateHistoricalGroup. The service must not add a new
+  // entry while a restore is already in progress.
+  testing::NiceMock<MockLiveTabContext> restore_context;
+  int restored_tab_count = 0;
+  ON_CALL(restore_context, AddRestoredTab(_, _, _, _, _))
+      .WillByDefault([&](const sessions::tab_restore::Tab&, int, bool, bool,
+                         sessions::tab_restore::Type) -> sessions::LiveTab* {
+        ++restored_tab_count;
+        EXPECT_TRUE(service_->IsRestoring());
+        service_->CreateHistoricalGroup(&closing_context, closing_group_id);
+        EXPECT_EQ(group_entry_id, service_->entries().back()->id);
+        return nullptr;
+      });
+
+  service_->RestoreEntryById(&restore_context, group_entry_id,
+                             WindowOpenDisposition::NEW_FOREGROUND_TAB);
+
+  EXPECT_EQ(2, restored_tab_count);
+  EXPECT_EQ(max_entries - 1, service_->entries().size());
+}
Loading diff…

Original Bug Report

reported by [email protected]

Potential Use-After-Free in TabRestoreServiceHelper::RestoreEntryById via JNI re-entrancy

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 without the Chrome Security team.

Overview: A potential Use-After-Free vulnerability exists in TabRestoreServiceHelper::RestoreEntryById on Android due to synchronous Java-to-C++ re-entrancy. When restoring a tab, pending tab closures are synchronously committed, which can prune the exact tab restore entry currently being restored. Subsequent C++ vector operations on the freed stack references allow for memory corruption in the browser process.

Affected files:

  • components/sessions/core/tab_restore_service_helper.cc
  • chrome/browser/ui/android/tab_model/android_live_tab_context_wrapper.cc
  • chrome/browser/android/historical_tab_saver.cc

Estimated timestamp from git blame: 2024-04-29

Description

A potential Use-After-Free (UAF) vulnerability exists in TabRestoreServiceHelper::RestoreEntryById in the non-sandboxed browser process on Android. The issue arises from unsafe raw stack references held across a complex, synchronous C++-to-Java-to-C++ call chain that can mutate the underlying data structure.

In RestoreEntryById, the service looks up the entry to restore and holds raw stack references to it (e.g., auto& group = static_cast<Group&>(entry);). It sets a restoring_ flag and proceeds to call RestoreTab, which eventually bridges into Java via TabModelJniBridge::CreateTab to instantiate the tab in the Android UI.

On the Java side, adding a new tab (TabCollectionTabModelImpl.addTabInternal) synchronously calls commitAllTabClosures(). This ensures any pending “Undo” snackbars are flushed before state changes. Flushing closures triggers a JNI callback into C++ (historical_tab_saver.cc -> TabRestoreServiceHelper::CreateHistoricalGroup or BrowserClosing).

Unlike CreateHistoricalTab, which safely checks if (restoring_) return std::nullopt;, the BrowserClosing and CreateHistoricalGroup methods do not check the restoring_ flag. They immediately add the newly committed historical entry to the front of entries_ and call PruneEntries(). If the service is already at its capacity (kMaxEntries, which is 25), PruneEntries() deletes the oldest entry. If the entry being restored happens to be this oldest entry, it is freed while RestoreEntryById still holds stack references to it.

When the JNI call unwinds, RestoreEntryById uses the now-dangling group reference to call group.tabs.erase(). This vector erasure primitive on attacker-controlled freed memory can lead to arbitrary memory writes or arbitrary free primitives, potentially allowing for arbitrary code execution in the browser process. MiraclePtr does not mitigate this bug as the dangling pointers are raw stack references (auto&) to a plain C++ struct.

Suggested Steps to Reproduce

Note: These are potential steps derived from static analysis; our tooling agent does not have the ability to run code or provide a live Proof of Concept.

  1. An attacker (or interaction flow) populates the TabRestoreService to its maximum limit (kMaxEntries = 25). The oldest entry (index 24) must be a Group entry with at least two tabs.
  2. The user closes a tab or group of tabs, triggering an “Undo” snackbar (this queues a pending closure in PendingTabClosureManager).
  3. Before the snackbar expires, the user navigates to “Recent tabs” and restores a single tab from the oldest group entry.
  4. C++ RestoreEntryById grabs a reference to the oldest group and calls into Java to create the tab.
  5. Java’s addTabInternal forces commitAllTabClosures(), committing the pending closure from Step 2.
  6. This fires a JNI call back to TabRestoreServiceHelper::BrowserClosing / CreateHistoricalGroup.
  7. The new entry is pushed to entries_. PruneEntries() executes and drops the 26th (oldest) entry, freeing the memory of the group currently being restored.
  8. The call stack unwinds to RestoreEntryById. group.tabs.erase() is executed on the freed memory, causing a UAF.

Suggested Fix

  1. Add a restoring_ check to TabRestoreServiceHelper::BrowserClosing and TabRestoreServiceHelper::CreateHistoricalGroup, identical to the one in CreateHistoricalTab. For example:
    if (restoring_) {
      return;
    }
    
  2. Alternatively, avoid holding raw C++ references across boundaries that can execute arbitrary UI/JNI delegates. Looking up the entry by ID after the RestoreTab call completes would prevent dangling references if the list is mutated.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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