CVE-2026-78937
Overview
Background
- `TemplateURL` / `TemplateURLService`
- the browser-process native objects and service that hold the profile’s search engines. The service can rebuild or replace its
TemplateURLobjects 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 whenonTemplateURLServiceChanged()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().
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
- Settings list populated
On Android, the user opens the search engine settings screen.
SearchEngineAdapterfetches the engines and caches theirTemplateUrlwrappers. - 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
TemplateURLobjects, such as a recently visited engine (r1in the tests). - Change notification
onTemplateURLServiceChanged()runs the adapter’s refresh routine. Before the fix,didSearchEnginesChange()/containsTemplateUrl()callgetKeyword()/getShortName()/getIsPrepopulated()on the cached, now-stale wrapper. - Use after free in the browser process
Those JNI getters read freed native
TemplateURLmemory. With heap grooming this could become memory corruption in the browser process(inferred).
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
ifchrome/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.javacomponents/search_engines/android/java/src/org/chromium/components/search_engines/TemplateUrl.javacomponents/search_engines/android/template_url_android.ccchrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineIconUtils.javachrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/common/BaseSiteSearchMediator.javachrome/browser/search_engines/android/BUILD.gnchrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/TemplateUrlSnapshot.javacomponents/search_engines/android/java/src/org/chromium/components/search_engines/FakeTemplateUrl.javachrome/browser/search_engines/android/java/src/org/chromium/chrome/browser/search_engines/settings/SearchEngineAdapter.javacomponents/search_engines/android/java/src/org/chromium/components/search_engines/TemplateUrlTestHelpers.java
Audit Directions
- Long-lived JNI wrappers over service-owned native objectsSearch Android Java code for classes that cache
TemplateUrlor similar@CalledByNative/getNativePtr()wrappers in fields or lists beyond a single call, especially acrossonTemplateURLServiceChanged,TemplateUrlService.LoadListeneror observer callbacks.BaseSiteSearchMediatorand other site-search settings code are the first places to check. - Identity by native pointerLook for
getNativePtr() ==comparisons orObjects.equalson JNI wrappers. Both silently depend on the native object still being alive and not reused. - Native list rebuilds behind Java viewsReview 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.
Patch
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)
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.