CVE-2026-78937
Overview
Files Changed
chrome/android/junit/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapterTest.java
Patch
From 982964a86897dd7578cc03e56f166c36eef0540c Mon Sep 17 00:00:00 2001
From: Amelie Schneider <amelies@google.com>
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 <pnoland@chromium.org>
Commit-Queue: Amelie Schneider <amelies@google.com>
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);
Original Bug Report
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.javacomponents/search_engines/android/template_url_android.cccomponents/search_engines/template_url_service.cccomponents/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:
- 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. - In the renderer,
ChromeRenderFrameObserverdetects these tags and sends a Mojo message to the browser process, which downloads and adds the engines (E1, E2, E3, E4) toTemplateURLService. - The attacker’s webpage triggers background navigations to the search endpoints of E1, E2, E3, and E4. The Chrome History service updates their
last_visitedtimestamps. The attacker spaces these so the visit recency is ordered: E4 > E3 > E2 > E1. - The victim is persuaded to open Chrome Settings and navigate to the “Search engine” section. The Java
SearchEngineAdapterinitializes and callsrefreshData(). refreshData()retrieves the engines, sorts them bylast_visited, and keeps at mostMAX_RECENT_ENGINE_NUM(3). It retains E4, E3, and E2, while E1 is filtered out. The JavaTemplateUrlwrappers for E4, E3, and E2 (holding raw C++ pointers) are cached inmRecentSearchEngines.- 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.
TemplateURLService::Add(E5)is called. It uses aScoperto temporarily suppress observer notifications and callsRemoveDuplicateReplaceableEnginesOf(E5).RemoveDuplicateReplaceableEnginesOffinds the conflicting engine E2. Because E5 was created more recently, E5 is deemed “better”.TemplateURLService::Remove(E2)is called, which destroys thestd::unique_ptr<TemplateURL>. The C++ memory is freed. The BRP refcount reaches 0 because the only remaining reference is the rawlongin the Java heap.- The
Scopergoes out of scope, triggeringOnTemplateURLServiceChangednotifications. SearchEngineAdapter.onTemplateURLServiceChanged()callsrefreshData(). It retrieves the new list: E1, E3, E4, and E5.- E5 is filtered out because it has never been visited (
last_visitedis 0). The new list of recent engines is exactly 3: E4, E3, and E1. refreshData()callsdidSearchEnginesChange(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.- It calls
containsTemplateUrl(mRecentSearchEngines, E1). This iterates over the old list, which contains the stale wrapper for E2. - The comparison invokes
E2.getIsPrepopulated()andE2.getKeyword(). These calls cross the JNI boundary and cast the danglinglongback to aTemplateURL*, dereferencing the freed memory. base::android::ConvertUTF16ToJavaStringreads the underlying UTF-16 character data using thestring_viewderived 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:
- Use
base::android::ScopedJavaGlobalRef: Have the C++TemplateURLobject 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. - 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
getTemplateUrlsis 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.