Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Search
DescriptionUse after free in Search
ComponentSearch
Bug ClassUAF
Tracker501590191
Fix commit982964a86897 (chromium/src) +280/-69
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Files Changed

  • chrome/android/junit/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapterTest.java
From 982964a86897dd7578cc03e56f166c36eef0540c Mon Sep 17 00:00:00 2001
From: Amelie Schneider <[email protected]>
Date: Mon, 06 Jul 2026 08:33:16 -0700
Subject: [PATCH] [Android] Use TemplateUrlSnapshot in SearchEngineAdapter to prevent UAF

SearchEngineAdapter previously held references to TemplateUrl Java
wrappers, which contain raw pointers to native TemplateURL objects. When
TemplateURLService refreshes (e.g., due to an OpenSearch description
discovery), these native objects can be destroyed, possibly resulting in
a Use-After-Free (UAF).

This CL introduces TemplateUrlSnapshot, which captures the required
TemplateUrl properties (keyword, name, ID, etc.) on the Java heap at the
time the engine list is fetched. SearchEngineAdapter now caches these
snapshots. By performing all subsequent comparisons and UI updates using
snapshot data, we eliminate JNI calls to potentially destroyed native
objects. The template's id is now used for identification.

Bug: 501590191
Change-Id: I3cd3f08a7002c7879c515d077b074363f283a1aa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8011489
Reviewed-by: Patrick Noland <[email protected]>
Commit-Queue: Amelie Schneider <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1657217}
---

diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapterTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapterTest.java
index b3e79c1..f79303c 100644
--- a/chrome/android/junit/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapterTest.java
+++ b/chrome/android/junit/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapterTest.java
@@ -10,6 +10,7 @@
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -265,14 +266,12 @@
 
         // Checking the data that was used to render the view.
         assertEquals(SearchEngineAdapter.ViewType.ITEM, adapter.getItemViewType(0));
-        verify(p1, never()).getShortName();
         View v = adapter.getView(0, null, null);
         verify(p1, atLeastOnce()).getShortName();
         assertEquals(View.VISIBLE, v.findViewById(R.id.url).getVisibility());
         assertThat(v.findViewById(R.id.logo), notNullValue());
 
         assertEquals(SearchEngineAdapter.ViewType.ITEM, adapter.getItemViewType(1));
-        verify(p2, never()).getShortName();
         v = adapter.getView(1, null, null);
         verify(p2, atLeastOnce()).getShortName();
         assertEquals(View.GONE, v.findViewById(R.id.url).getVisibility()); // Because no keyword.
@@ -282,7 +281,6 @@
         assertNotNull(adapter.getView(2, null, null));
 
         assertEquals(SearchEngineAdapter.ViewType.ITEM, adapter.getItemViewType(3));
-        verify(c1, never()).getShortName();
         v = adapter.getView(3, null, null);
         verify(c1, atLeastOnce()).getShortName();
         assertEquals(View.VISIBLE, v.findViewById(R.id.url).getVisibility());
@@ -312,10 +310,10 @@
 
         // The adapter will show 2 prepopulated engines, a divider, and the unknown DSE.
         assertEquals(4, adapter.getCount());
-        assertEquals(p1, adapter.getItem(0));
-        assertEquals(p2, adapter.getItem(1));
+        assertEquals(p1.getKeyword(), adapter.getItem(0).getKeyword());
+        assertEquals(p2.getKeyword(), adapter.getItem(1).getKeyword());
         // Item 2 is a divider.
-        assertEquals(unknownDse, adapter.getItem(3));
+        assertEquals(unknownDse.getKeyword(), adapter.getItem(3).getKeyword());
     }
 
     @Test
@@ -340,8 +338,8 @@
 
         // The adapter will show 2 prepopulated engines, a divider, and the unknown DSE.
         assertEquals(2, adapter.getCount());
-        assertEquals(p1, adapter.getItem(0));
-        assertEquals(p2, adapter.getItem(1));
+        assertEquals(p1.getKeyword(), adapter.getItem(0).getKeyword());
+        assertEquals(p2.getKeyword(), adapter.getItem(1).getKeyword());
     }
 
     @Test
@@ -366,14 +364,12 @@
 
         // Checking the data that was used to render the view.
         assertEquals(SearchEngineAdapter.ViewType.ITEM, adapter.getItemViewType(0));
-        verify(p1, never()).getShortName();
         View v = adapter.getView(0, null, null);
         verify(p1, atLeastOnce()).getShortName();
         assertEquals(View.VISIBLE, v.findViewById(R.id.url).getVisibility());
         assertThat(v.findViewById(R.id.logo), notNullValue());
 
         assertEquals(SearchEngineAdapter.ViewType.ITEM, adapter.getItemViewType(1));
-        verify(p2, never()).getShortName();
         v = adapter.getView(1, null, null);
         verify(p2, atLeastOnce()).getShortName();
         assertEquals(View.GONE, v.findViewById(R.id.url).getVisibility()); // Because no keyword.
@@ -383,7 +379,6 @@
         assertNotNull(adapter.getView(2, null, null));
 
         assertEquals(SearchEngineAdapter.ViewType.ITEM, adapter.getItemViewType(3));
-        verify(c1, never()).getShortName();
         v = adapter.getView(3, null, null);
         verify(c1, atLeastOnce()).getShortName();
         assertEquals(View.VISIBLE, v.findViewById(R.id.url).getVisibility());
@@ -414,10 +409,10 @@
 
         // The adapter will show 2 prepopulated engines, a divider, and the unknown DSE.
         assertEquals(4, adapter.getCount());
-        assertEquals(p1, adapter.getItem(0));
-        assertEquals(p2, adapter.getItem(1));
+        assertEquals(p1.getKeyword(), adapter.getItem(0).getKeyword());
+        assertEquals(p2.getKeyword(), adapter.getItem(1).getKeyword());
         // Item 2 is a divider.
-        assertEquals(unknownDse, adapter.getItem(3));
+        assertEquals(unknownDse.getKeyword(), adapter.getItem(3).getKeyword());
 
         // Test for EEA country.
         doReturn(true).when(mRegionalCapabilities).isInEeaCountry();
@@ -428,10 +423,10 @@
 
         // The adapter will show 2 prepopulated engines, a divider, and the unknown DSE.
         assertEquals(4, adapter.getCount());
-        assertEquals(p1, adapter.getItem(0));
-        assertEquals(p2, adapter.getItem(1));
+        assertEquals(p1.getKeyword(), adapter.getItem(0).getKeyword());
+        assertEquals(p2.getKeyword(), adapter.getItem(1).getKeyword());
         // Item 2 is a divider.
-        assertEquals(unknownDse, adapter.getItem(3));
+        assertEquals(unknownDse.getKeyword(), adapter.getItem(3).getKeyword());
     }
 
     @Test
@@ -457,7 +452,73 @@
 
         // The adapter will show 2 prepopulated engines, a divider, and the unknown DSE.
         assertEquals(2, adapter.getCount());
-        assertEquals(p1, adapter.getItem(0));
-        assertEquals(p2, adapter.getItem(1));
+        assertEquals(p1.getKeyword(), adapter.getItem(0).getKeyword());
+        assertEquals(p2.getKeyword(), adapter.getItem(1).getKeyword());
+    }
+
+    @Test
+    @EnableFeatures(ChromeFeatureList.SEARCH_SETTINGS_UPDATE_V2)
+    @DisableFeatures(OmniboxFeatureList.OMNIBOX_SITE_SEARCH)
+    public void refreshData_V2_doesNotDereferenceStaleTemplateUrls() {
+        TemplateUrl p1 = buildMockTemplateUrl("p1", 1);
+        TemplateUrl r1 = buildMockTemplateUrl("r1", 0);
+
+        doReturn(true).when(mTemplateUrlService).isLoaded();
+        doReturn(new PrepopulatedAndRecentlyVisitedTemplateURLs(List.of(p1), List.of(r1)))
+                .when(mTemplateUrlService)
+                .getPrepopulatedAndRecentlyVisitedTemplateURLs();
+        TemplateUrlServiceFactory.setInstanceForTesting(mTemplateUrlService);
+
+        var adapter = new SearchEngineAdapter(mContext, mProfile, null);
+        adapter.start();
+
+        // New list: r1 is replaced by r2.
+        TemplateUrl r2 = buildMockTemplateUrl("r2", 0);
+        doReturn(new PrepopulatedAndRecentlyVisitedTemplateURLs(List.of(p1), List.of(r2)))
+                .when(mTemplateUrlService)
+                .getPrepopulatedAndRecentlyVisitedTemplateURLs();
+
+        // Simulate r1 being freed in native.
+        clearInvocations(r1);
+
+        // This should not crash if the fix is correct.
+        adapter.onTemplateURLServiceChanged();
+
+        verify(r1, never()).getKeyword();
+        verify(r1, never()).getShortName();
+        verify(r1, never()).getIsPrepopulated();
+    }
+
+    @Test
+    @DisableFeatures({
+        ChromeFeatureList.SEARCH_SETTINGS_UPDATE_V2,
+        OmniboxFeatureList.OMNIBOX_SITE_SEARCH
+    })
+    public void refreshData_Legacy_doesNotDereferenceStaleTemplateUrls() {
+        TemplateUrl p1 = buildMockTemplateUrl("p1", 1);
+        TemplateUrl r1 = buildMockTemplateUrl("r1", 0);
+
+        doReturn(true).when(mTemplateUrlService).isLoaded();
+        // In legacy mode, it uses getTemplateUrls() and sorts them.
+        doReturn(new ArrayList<>(List.of(p1, r1))).when(mTemplateUrlService).getTemplateUrls();
+        TemplateUrlServiceFactory.setInstanceForTesting(mTemplateUrlService);
+        RegionalCapabilitiesServiceFactory.setInstanceForTesting(mRegionalCapabilities);
+
+        var adapter = new SearchEngineAdapter(mContext, mProfile, null);
+        adapter.start();
+
+        // New list: r1 is replaced by r2.
+        TemplateUrl r2 = buildMockTemplateUrl("r2", 0);
Loading diff…

Original Bug Report

reported by [email protected]

Potential Browser Process UAF in SearchEngineAdapter via OSDD replacement

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 (UAF) exists in the browser process on Chrome for Android when updating search engines via OpenSearch. The SearchEngineAdapter Java UI cache retains a raw JNI pointer to a TemplateURL that can be freed when a duplicate engine is added in the background, leading to a UAF read that bypasses MiraclePtr during UI refreshes.

Affected files:

  • chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapter.java
  • components/search_engines/android/template_url_android.cc
  • components/search_engines/template_url_service.cc
  • components/search_engines/android/java/src/org/chromium/components/search_engines/TemplateUrl.java

Estimated timestamp from git blame: 2026-01-20

Summary

On Android, the SearchEngineAdapter (used for the ‘Settings → Search engine’ list) maintains a cache of TemplateUrl Java wrappers to represent recent and prepopulated search engines. Each Java wrapper holds a raw C++ pointer (long mTemplateUrlPtr) to its underlying TemplateURL object in the browser process.

When a TemplateURL is destroyed in the C++ backend—such as when it is automatically replaced by a newer OpenSearch Description Document (OSDD) engine with the same keyword—the Java observer is notified asynchronously. However, the update logic in SearchEngineAdapter performs a comparison between the new search engine list and the old cached list before updating its cache. This comparison invokes JNI methods on the cached wrappers, resulting in a Use-After-Free (UAF) dereference of the freed C++ TemplateURL pointer.

Because the reference is stored as a raw long on the Java heap, this vulnerability bypasses MiraclePtr (BackupRefPtr) protections.

Potential Exploitation Steps

The following sequence outlines how an attacker could potentially trigger this vulnerability:

  1. The attacker crafts a web page that registers 4 distinct OSDD engines by injecting <link rel="search" type="application/opensearchdescription+xml" href="osdd1.xml"> tags.
  2. In the renderer, ChromeRenderFrameObserver detects these tags and sends a Mojo message to the browser process, which downloads and adds the engines (E1, E2, E3, E4) to TemplateURLService.
  3. The attacker’s webpage triggers background navigations to the search endpoints of E1, E2, E3, and E4. The Chrome History service updates their last_visited timestamps. The attacker spaces these so the visit recency is ordered: E4 > E3 > E2 > E1.
  4. The victim is persuaded to open Chrome Settings and navigate to the “Search engine” section. The Java SearchEngineAdapter initializes and calls refreshData().
  5. refreshData() retrieves the engines, sorts them by last_visited, and keeps at most MAX_RECENT_ENGINE_NUM (3). It retains E4, E3, and E2, while E1 is filtered out. The Java TemplateUrl wrappers for E4, E3, and E2 (holding raw C++ pointers) are cached in mRecentSearchEngines.
  6. While the Settings page remains open, the attacker’s web page injects a 5th OSDD file (E5) configured with the same search keyword as E2.
  7. TemplateURLService::Add(E5) is called. It uses a Scoper to temporarily suppress observer notifications and calls RemoveDuplicateReplaceableEnginesOf(E5).
  8. RemoveDuplicateReplaceableEnginesOf finds the conflicting engine E2. Because E5 was created more recently, E5 is deemed “better”.
  9. TemplateURLService::Remove(E2) is called, which destroys the std::unique_ptr<TemplateURL>. The C++ memory is freed. The BRP refcount reaches 0 because the only remaining reference is the raw long in the Java heap.
  10. The Scoper goes out of scope, triggering OnTemplateURLServiceChanged notifications.
  11. SearchEngineAdapter.onTemplateURLServiceChanged() calls refreshData(). It retrieves the new list: E1, E3, E4, and E5.
  12. E5 is filtered out because it has never been visited (last_visited is 0). The new list of recent engines is exactly 3: E4, E3, and E1.
  13. refreshData() calls didSearchEnginesChange(newList). Because the old list had 3 engines and the new list has 3 engines, it bypasses the early return and proceeds to an element-wise comparison.
  14. It calls containsTemplateUrl(mRecentSearchEngines, E1). This iterates over the old list, which contains the stale wrapper for E2.
  15. The comparison invokes E2.getIsPrepopulated() and E2.getKeyword(). These calls cross the JNI boundary and cast the dangling long back to a TemplateURL*, dereferencing the freed memory.
  16. base::android::ConvertUTF16ToJavaString reads the underlying UTF-16 character data using the string_view derived from the attacker-controlled memory layout, resulting in an arbitrary memory read in the Browser process.

Suggested Fix

To prevent this UAF and safely manage the lifecycle of the C++ object across the JNI boundary, the design should be updated so that Java does not hold raw pointers to memory it doesn’t own.

Potential fixes include:

  1. Use base::android::ScopedJavaGlobalRef: Have the C++ TemplateURL object own a global reference to its Java counterpart. When the C++ object is destroyed, it can explicitly invalidate or clear the pointer inside the Java wrapper, preventing subsequent JNI calls from dereferencing a stale pointer.
  2. Pass Data by Value: Instead of caching wrappers with raw pointers, pass the required display data (keyword, short name, prepopulated ID) to Java by value when getTemplateUrls is called. The Java side would then compare these value objects rather than invoking native methods during the UI refresh loop.

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