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 Search
DescriptionUse after free in Search
ComponentSearch
Sub-componentAndroid
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.
Tracker501590191
Fix commit982964a86897 (chromium/src) +280/-69
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-06 (public git)
Disclosed2026-08-25
Patch gap50 days — fix public in source before the advisory

Background

`TemplateURL` / `TemplateURLService`
the browser-process native objects and service that hold the profile’s search engines. The service can rebuild or replace its TemplateURL objects whenever the engine set changes, for example after OpenSearch description discovery, as the commit message notes.
`TemplateUrl` Java wrapper
the Android Java class for a native TemplateURL. It keeps a raw native pointer (getNativePtr()), and its getters (getKeyword(), getShortName(), getIsPrepopulated(), …) cross JNI and dereference that pointer.
`SearchEngineAdapter`
the Android search-engine settings list adapter. It caches the engine lists (mPrepopulatedSearchEngines, mRecentSearchEngines) and rebuilds them when onTemplateURLServiceChanged() fires.
`TemplateUrlSnapshot`
a new Java-heap value object, added in TemplateUrlSnapshot.java, that copies the needed fields (id, keyword, short name, prepopulated flag, built-in icon) at fetch time, so later reads never touch native memory.

Root Cause Analysis

Before the patch, SearchEngineAdapter stored the TemplateUrl Java wrappers themselves in mPrepopulatedSearchEngines and mRecentSearchEngines and kept them across TemplateURLService updates. Each wrapper holds a raw pointer to a native TemplateURL. When the service refreshes (the commit message names OpenSearch description discovery as one cause), those native objects can be destroyed while the adapter still holds the wrappers. On the next refresh, didSearchEnginesChange() and containsTemplateUrl() compared each new engine with the cached ones by calling getIsPrepopulated(), getKeyword() and getShortName() on the cached wrappers. Those JNI calls dereference the freed native TemplateURL, which is a use after free in the browser process. View binding and updateLogo() likewise read the cached wrappers. The broken invariant is that the adapter must never keep a TemplateUrl wrapper past the service update that may free its native object.

The fix converts every cached entry into a TemplateUrlSnapshot (toSnapshots(...), TemplateUrlSnapshot.from(...)) when the list is fetched. It changes comparisons to use the stable getId() instead of keyword/name getters or getNativePtr() equality, and renders from snapshot fields. The regression tests refreshData_V2_doesNotDereferenceStaleTemplateUrls and refreshData_Legacy_doesNotDereferenceStaleTemplateUrls check that a replaced engine’s wrapper (r1) is never asked for getKeyword, getShortName or getIsPrepopulated after onTemplateURLServiceChanged().

Key insight
The settings adapter kept TemplateUrl JNI wrappers, which are thin handles to native objects owned by TemplateURLService, across service updates and dereferenced them after the native objects were freed. The fix caches immutable Java-side snapshots and identifies engines by id, so stale native pointers are never touched.

Attack Path

  1. Settings list populated On Android, the user opens the search engine settings screen. SearchEngineAdapter fetches the engines and caches their TemplateUrl wrappers.
  2. TemplateURLService changes The search engine set changes while the adapter is alive. The commit message cites OpenSearch description discovery, which a web page can cause by advertising an OpenSearch descriptor when visited (inferred trigger chain). The service then destroys or replaces native TemplateURL objects, such as a recently visited engine (r1 in the tests).
  3. Change notification onTemplateURLServiceChanged() runs the adapter’s refresh routine. Before the fix, didSearchEnginesChange()/containsTemplateUrl() call getKeyword()/getShortName()/getIsPrepopulated() on the cached, now-stale wrapper.
  4. Use after free in the browser process Those JNI getters read freed native TemplateURL memory. With heap grooming this could become memory corruption in the browser process (inferred).

Impact Assessment

The bug is a use after free of native TemplateURL objects in the Chrome browser process on Android, which the advisory describes as “Use after free in Search”. It requires the user to have the search engine settings UI open, or the adapter otherwise alive, while TemplateURLService changes. A website could plausibly cause such a change through OpenSearch discovery, but that chain and its timing are (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 9.6. Because the freed object lives in the unsandboxed browser process, code execution there would be serious, but it is (inferred) and not demonstrated by the patch.

Changed Functions

FunctionChangeNotes
if
chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapter.java
modified

Files Changed

  • chrome/android/junit/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapterTest.java
  • components/search_engines/android/java/src/org/chromium/components/search_engines/TemplateUrl.java
  • components/search_engines/android/template_url_android.cc
  • chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineIconUtils.java
  • chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/common/BaseSiteSearchMediator.java
  • chrome/browser/search_engines/android/BUILD.gn
  • chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/TemplateUrlSnapshot.java
  • components/search_engines/android/java/src/org/chromium/components/search_engines/FakeTemplateUrl.java
  • chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapter.java
  • components/search_engines/android/java/src/org/chromium/components/search_engines/TemplateUrlTestHelpers.java

Audit Directions

  • Long-lived JNI wrappers over service-owned native objects
    Search Android Java code for classes that cache TemplateUrl or similar @CalledByNative/getNativePtr() wrappers in fields or lists beyond a single call, especially across onTemplateURLServiceChanged, TemplateUrlService.LoadListener or observer callbacks. BaseSiteSearchMediator and other site-search settings code are the first places to check.
  • Identity by native pointer
    Look for getNativePtr() == comparisons or Objects.equals on JNI wrappers. Both silently depend on the native object still being alive and not reused.
  • Native list rebuilds behind Java views
    Review other settings and list adapters (autofill, passwords, site settings) whose native service may replace its objects asynchronously, for example after sync, policy or web-triggered discovery. Check that the Java side snapshots data and does not keep handles.
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);
+        doReturn(new ArrayList<>(List.of(p1, r2))).when(mTemplateUrlService).getTemplateUrls();
+
+        // 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();
     }
 }
diff --git a/chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapter.java b/chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapter.java
index d013c0b..1bed5d7 100644
--- a/chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapter.java
+++ b/chrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapter.java
@@ -112,10 +112,10 @@
     private LargeIconBridge mLargeIconBridge;
 
     /** The list of prepopulated and default search engines. */
-    private List<TemplateUrl> mPrepopulatedSearchEngines = new ArrayList<>();
+    private List<TemplateUrlSnapshot> mPrepopulatedSearchEngines = new ArrayList<>();
 
     /** The list of recently visited search engines. */
-    private List<TemplateUrl> mRecentSearchEngines = new ArrayList<>();
+    private List<TemplateUrlSnapshot> mRecentSearchEngines = new ArrayList<>();
 
     /** Cache for storing fetched search icon bitmaps. */
     private final Map<GURL, Bitmap> mIconCache = new HashMap<>();
@@ -231,8 +231,8 @@
                 return;
             }
 
-            mPrepopulatedSearchEngines = new ArrayList<>(engines.getPrepopulatedUrls());
-            mRecentSearchEngines = new ArrayList<>(recentlyVisitedUrls);
+            mPrepopulatedSearchEngines = toSnapshots(engines.getPrepopulatedUrls());
+            mRecentSearchEngines = toSnapshots(recentlyVisitedUrls);
         } else {
             RegionalCapabilitiesService regionalCapabilities =
                     RegionalCapabilitiesServiceFactory.getForProfile(mProfile);
@@ -243,7 +243,7 @@
                     defaultSearchEngineTemplateUrl,
                     regionalCapabilities.isInEeaCountry());
 
-            List<TemplateUrl> combinedLists = new ArrayList<>(mPrepopulatedSearchEngines);
+            List<TemplateUrlSnapshot> combinedLists = new ArrayList<>(mPrepopulatedSearchEngines);
             combinedLists.addAll(mRecentSearchEngines);
 
             if (!didSearchEnginesChange(templateUrls, combinedLists)) {
@@ -256,33 +256,38 @@
 
             for (int i = 0; i < templateUrls.size(); i++) {
                 TemplateUrl templateUrl = templateUrls.get(i);
+                TemplateUrlSnapshot snapshot = TemplateUrlSnapshot.from(templateUrl);
                 if (getSearchEngineSourceType(templateUrl, defaultSearchEngineTemplateUrl)
                         == TemplateUrlSourceType.RECENT) {
-                    mRecentSearchEngines.add(templateUrl);
+                    mRecentSearchEngines.add(snapshot);
                 } else {
-                    mPrepopulatedSearchEngines.add(templateUrl);
+                    mPrepopulatedSearchEngines.add(snapshot);
                 }
             }
         }
 
         // Convert the TemplateUrl index into an index of mSearchEngines.
         mSelectedSearchEnginePosition = -1;
-        for (int i = 0; i < mPrepopulatedSearchEngines.size(); ++i) {
-            if (Objects.equals(mPrepopulatedSearchEngines.get(i), defaultSearchEngineTemplateUrl)) {
-                mSelectedSearchEnginePosition = i;
+        if (defaultSearchEngineTemplateUrl != null) {
+            for (int i = 0; i < mPrepopulatedSearchEngines.size(); ++i) {
+                TemplateUrlSnapshot snapshot = mPrepopulatedSearchEngines.get(i);
+                if (snapshot.getId() == defaultSearchEngineTemplateUrl.getId()) {
+                    mSelectedSearchEnginePosition = i;
+                }
             }
-        }
 
-        for (int i = 0; i < mRecentSearchEngines.size(); ++i) {
-            if (Objects.equals(mRecentSearchEngines.get(i), defaultSearchEngineTemplateUrl)) {
-                // Add one to offset the title for the recent search engine list.
-                mSelectedSearchEnginePosition = i + computeStartIndexForRecentSearchEngines();
+            for (int i = 0; i < mRecentSearchEngines.size(); ++i) {
+                TemplateUrlSnapshot snapshot = mRecentSearchEngines.get(i);
+                if (snapshot.getId() == defaultSearchEngineTemplateUrl.getId()) {
+                    // Add one to offset the title for the recent search engine list.
+                    mSelectedSearchEnginePosition = i + computeStartIndexForRecentSearchEngines();
+                }
             }
         }
 
         if (mSelectedSearchEnginePosition == -1) {
             if (defaultSearchEngineTemplateUrl != null) {
-                mRecentSearchEngines.add(defaultSearchEngineTemplateUrl);
+                mRecentSearchEngines.add(TemplateUrlSnapshot.from(defaultSearchEngineTemplateUrl));
                 mSelectedSearchEnginePosition = mRecentSearchEngines.size() - 1;
             }
 
@@ -401,7 +406,7 @@
         if (templateUrl.getIsPrepopulated()) {
             return TemplateUrlSourceType.PREPOPULATED;
         } else if (defaultSearchEngine != null
-                && templateUrl.getNativePtr() == defaultSearchEngine.getNativePtr()) {
+                && templateUrl.getId() == defaultSearchEngine.getId()) {
             return TemplateUrlSourceType.DEFAULT;
         } else {
             return TemplateUrlSourceType.RECENT;
@@ -409,15 +414,10 @@
     }
 
     private static boolean containsTemplateUrl(
-            List<TemplateUrl> templateUrls, TemplateUrl targetTemplateUrl) {
-        for (int i = 0; i < templateUrls.size(); i++) {
-            TemplateUrl templateUrl = templateUrls.get(i);
-            // Explicitly excluding TemplateUrlSourceType and Index as they might change if a search
-            // engine is set as default.
-            if (templateUrl.getIsPrepopulated() == targetTemplateUrl.getIsPrepopulated()
-                    && TextUtils.equals(templateUrl.getKeyword(), targetTemplateUrl.getKeyword())
-                    && TextUtils.equals(
-                            templateUrl.getShortName(), targetTemplateUrl.getShortName())) {
+            List<TemplateUrlSnapshot> stashedUrls, TemplateUrl targetTemplateUrl) {
+        for (int i = 0; i < stashedUrls.size(); i++) {
+            TemplateUrlSnapshot snapshot = stashedUrls.get(i);
+            if (snapshot.getId() == targetTemplateUrl.getId()) {
                 return true;
             }
         }
@@ -425,7 +425,7 @@
     }
 
     private boolean didSearchEnginesChange(
-            List<TemplateUrl> templateUrls, List<TemplateUrl> stashedUrls) {
+            List<TemplateUrl> templateUrls, List<TemplateUrlSnapshot> stashedUrls) {
         if (templateUrls.size() != stashedUrls.size()) {
             return true;
         }
@@ -471,7 +471,7 @@
     }
 
     @Override
-    public @Nullable Object getItem(int pos) {
+    public @Nullable TemplateUrlSnapshot getItem(int pos) {
         if (getItemViewType(pos) == ViewType.SITE_SEARCH_SETTINGS) {
             return null;
         }
@@ -552,13 +552,13 @@
 
         TextView description = view.findViewById(R.id.name);
 
-        TemplateUrl templateUrl = (TemplateUrl) getItem(position);
-        assumeNonNull(templateUrl);
-        description.setText(templateUrl.getShortName());
+        TemplateUrlSnapshot snapshot = getItem(position);
+        assumeNonNull(snapshot);
+        description.setText(snapshot.getShortName());
 
         TextView url = view.findViewById(R.id.url);
-        url.setText(templateUrl.getKeyword());
-        if (TextUtils.isEmpty(templateUrl.getKeyword())) {
+        url.setText(snapshot.getKeyword());
+        if (TextUtils.isEmpty(snapshot.getKeyword())) {
             url.setVisibility(View.GONE);
         }
 
@@ -566,9 +566,9 @@
         GURL faviconUrl =
                 new GURL(
                         templateUrlService.getSearchEngineUrlFromTemplateUrl(
-                                templateUrl.getKeyword()));
+                                snapshot.getKeyword()));
 
-        updateLogo(logoView, templateUrl, faviconUrl);
+        updateLogo(logoView, snapshot, faviconUrl);
 
         radioButton.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
         containerView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
@@ -587,9 +587,14 @@
         return view;
     }
 
-    private void updateLogo(ImageView logoView, TemplateUrl templateUrl, GURL faviconUrl) {
+    private void updateLogo(ImageView logoView, TemplateUrlSnapshot templateUrl, GURL faviconUrl) {
         SearchEngineIconUtils.updateIcon(
-                mContext, logoView, templateUrl, faviconUrl, mLargeIconBridge, mIconCache);
+                mContext,
+                logoView,
+                templateUrl.getBuiltInIcon(),
+                faviconUrl,
+                mLargeIconBridge,
+                mIconCache);
     }
 
     // TemplateUrlService.LoadListener
@@ -649,4 +654,12 @@
     void setDisableAutoSwitchRunnable(Runnable runnable) {
         mDisableAutoSwitchRunnable = runnable;
     }
+
+    private static List<TemplateUrlSnapshot> toSnapshots(List<TemplateUrl> templateUrls) {
... (file diff truncated)
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