CVE-2026-76018
Overview
Background
- ProfileImport utility
- the desktop utility process, driven by
ExternalProcessImporterClientoverchrome.mojom.ProfileImport, that reads another browser’s profile data (bookmarks files, Firefox profiles) and streams it back to the browser through anImporterBridge. - `ParseBookmarksUnsafe()`
- the HTML parser for Netscape-format bookmark files; the commit message says it is documented as needing to run inside the dedicated
BookmarkHtmlParserservice rather than in an arbitrary process. - `user_data_importer::mojom::BookmarkHtmlParser`
- a Mojo service, launched via
content::ServiceProcessHost::Launch(), whoseParse()method takes raw HTML and returnsBookmarkParser::ParsedBookmarksasynchronously, so the untrusted parsing happens in its own process. - `FAVORITES` item
- the
user_data_importer::FAVORITESbit in theitemsmask, which selects bookmark import.
Root Cause Analysis
The ProfileImport utility parsed untrusted bookmark HTML in its own process. The code that does this is in BookmarksFileImporter::StartImport (the raw contents of a user-chosen source_profile.source_path) and in FirefoxImporter::ImportBookmarks (the Firefox install’s defaults/.../bookmarks.html). Both called user_data_importer::ParseBookmarksUnsafe(raw_html) directly, and the removed lines show this. That broke the documented rule that this parser runs only inside the isolated BookmarkHtmlParser service.
As a result, any memory-safety or logic bug in the HTML parser was exposed in the import utility, a process with different privileges (the advisory calls the result “privilege elevation in Import”). The diff does not show the import utility’s sandbox type. The sandbox.mojom import in profile_import.mojom suggests a sandbox annotation exists, but whether that process is weaker than the parser service is (inferred).
The fix makes ExternalProcessImporterClient launch a BookmarkHtmlParser service whenever items_ & FAVORITES is set, and pass its remote through a new optional StartImport parameter. Both importers now CHECK(html_parser_remote_.is_valid()) and send the HTML to Parse() over Mojo. They handle the result in new callbacks (OnBookmarksParsed, OnDefaultBookmarksParsed). Most of the remaining diff restructures FirefoxImporter so it keeps its sql::Database, livemark set and items_ alive across the async reply. The new ImportRemainingItems() then continues with passwords and autofill after bookmarks finish.
BookmarkHtmlParser service process and accepts only the structured ParsedBookmarks result back.Attack Path
- Craft bookmark HTML An attacker prepares a malicious Netscape-format bookmarks HTML file and gets the victim to obtain it, for example by download.
- User-initiated import
The victim chooses to import bookmarks from that HTML file, or from a Firefox install whose default
bookmarks.htmlthe attacker controls. Either way,ExternalProcessImporterClientstarts the ProfileImport utility with theFAVORITESitem. This needs a user action in the browser’s import UI on desktop, not web content. - In-process unsafe parse
Before the fix,
BookmarksFileImporter::StartImportorFirefoxImporter::ImportBookmarksread the file and calledParseBookmarksUnsafe()inside the import utility. - Parser bug in the wrong process A flaw triggered in the HTML parser would then run with the import utility’s privileges rather than inside the isolated parser service. Whether this gives code execution or access beyond the parser’s sandbox is (inferred); the patch does not show a specific parser bug.
Impact Assessment
BookmarkHtmlParser service. The advisory describes this as privilege elevation in Import. Turning it into code execution with the import process’s privileges needs a separate parser bug, and both that bug and the resulting escalation are (inferred). Preconditions are a desktop build, a user-initiated import of bookmarks from an HTML file or Firefox, and an attacker-supplied file. The vendor advisory rates this high severity and does not report in-the-wild exploitation; NVD scores it CVSS 8.8.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/importer/external_process_importer_client.cc |
modified | |
BookmarksFileImporterchrome/utility/importer/bookmarks_file_importer.h |
modified | |
forchrome/utility/importer/firefox_importer.cc |
modified | |
whilechrome/utility/importer/firefox_importer.cc |
modified | |
BookmarkHtmlParserchrome/utility/importer/firefox_importer.h |
modified | |
GURLchrome/utility/importer/firefox_importer.h |
modified |
Files Changed
chrome/utility/importer/bookmarks_file_importer.ccchrome/utility/importer/importer.hchrome/browser/importer/external_process_importer_client.ccchrome/utility/importer/firefox_importer_unittest.ccchrome/utility/services.ccchrome/common/importer/BUILD.gnchrome/utility/importer/profile_import_impl.hchrome/utility/importer/firefox_importer.ccchrome/utility/BUILD.gnchrome/utility/importer/bookmarks_file_importer.hchrome/utility/importer/bookmarks_file_importer_unittest.ccchrome/common/importer/profile_import.mojomchrome/utility/importer/profile_import_impl.ccchrome/utility/importer/importer.ccchrome/utility/importer/firefox_importer.hchrome/browser/importer/DEPS
Audit Directions
- "Unsafe" parsers called outside their serviceGrep for
*Unsafe(helpers and parsers documented as “must run in a sandboxed service”, such as JSON, XML, HTML, image and archive parsers. Check for direct callers in browser or utility code that skip the intendedServiceProcessHostservice. - Utility processes with weak sandboxes handling untrusted filesReview other
chrome/utilityimporters (Safari, Edge, Chromium-profile, CSV password import) and any service markedkNoSandboxor with a relaxed sandbox. Check that parsing of attacker-supplied file contents is delegated rather than done in-process. - Sync-to-async refactorsWhen a synchronous step is converted to a Mojo callback, as in
FirefoxImporter, check object lifetimes (refcounting viaWrapRefCounted, ownership ofsql::Database), cancellation, and that every early-return path still emits the matchingNotifyItemEnded/NotifyEndedcalls.
Patch
From 6c1e51af346f2ec01590fc45ea5b51eade1d2a66 Mon Sep 17 00:00:00 2001
From: Marc Treib <treib@chromium.org>
Date: Tue, 04 Aug 2026 07:37:27 -0700
Subject: [PATCH] ProfileImporter: Use mojom::BookmarkHtmlParser
ParseBookmarksUnsafe() is documented as needing to run inside the
BookmarkHtmlParser service, but the desktop ProfileImport utility still
called it directly from BookmarksFileImporter and from FirefoxImporter's
default-bookmarks loader.
After this CL, ExternalProcessImporterClient launches a
BookmarkHtmlParser service alongside the ProfileImport service itself,
and passes a remote to it through StartImport(). The Importer base class
now has a SetBookmarkHtmlParser() method to facilitate parsing over
Mojo, and both relevant importer implementations are updated to use the
new bookmarks parser and wait for the reply from the async Mojo
callback.
Fixed: 513757918
Change-Id: Ic2268611ccb446f75751d487e6ab59a26a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7864321
Reviewed-by: Filipa Senra <fsenra@google.com>
Reviewed-by: Dominic Farolino <dom@chromium.org>
Commit-Queue: Marc Treib <treib@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1673326}
---
diff --git a/chrome/browser/importer/external_process_importer_client.cc b/chrome/browser/importer/external_process_importer_client.cc
index 412452d7..cf31971 100644
--- a/chrome/browser/importer/external_process_importer_client.cc
+++ b/chrome/browser/importer/external_process_importer_client.cc
@@ -16,6 +16,7 @@
#include "chrome/grit/generated_resources.h"
#include "components/strings/grit/components_strings.h"
#include "components/user_data_importer/common/imported_bookmark_entry.h"
+#include "components/user_data_importer/mojom/bookmark_html_parser.mojom.h"
#include "content/public/browser/service_process_host.h"
#include "ui/base/l10n/l10n_util.h"
@@ -69,10 +70,22 @@
IDS_BOOKMARK_BAR_FOLDER_NAME,
l10n_util::GetStringUTF8(IDS_BOOKMARK_BAR_FOLDER_NAME));
+ mojo::PendingRemote<user_data_importer::mojom::BookmarkHtmlParser>
+ bookmark_html_parser;
+ // Note: `FAVORITES` corresponds to bookmarks.
+ if (items_ & user_data_importer::FAVORITES) {
+ content::ServiceProcessHost::Launch(
+ bookmark_html_parser.InitWithNewPipeAndPassReceiver(),
+ content::ServiceProcessHost::Options()
+ .WithDisplayName(IDS_CONTENT_BOOKMARK_PARSER_SERVICE_DISPLAY_NAME)
+ .Pass());
+ }
+
// If the utility process hasn't started yet the message will queue until it
// does.
profile_import_->StartImport(source_profile_, items_, localized_strings,
- receiver_.BindNewPipeAndPassRemote());
+ receiver_.BindNewPipeAndPassRemote(),
+ std::move(bookmark_html_parser));
}
void ExternalProcessImporterClient::Cancel() {
diff --git a/chrome/common/importer/profile_import.mojom b/chrome/common/importer/profile_import.mojom
index b28b732..815b911 100644
--- a/chrome/common/importer/profile_import.mojom
+++ b/chrome/common/importer/profile_import.mojom
@@ -4,6 +4,7 @@
module chrome.mojom;
+import "components/user_data_importer/mojom/bookmark_html_parser.mojom";
import "mojo/public/mojom/base/string16.mojom";
import "sandbox/policy/mojom/sandbox.mojom";
import "url/mojom/url.mojom";
@@ -93,7 +94,9 @@
SourceProfile source_profile,
uint16 items,
map<uint32, string> localized_strings,
- pending_remote<ProfileImportObserver> observer);
+ pending_remote<ProfileImportObserver> observer,
+ pending_remote<user_data_importer.mojom.BookmarkHtmlParser>?
+ bookmark_html_parser);
// Stop the importer.
CancelImport();
diff --git a/chrome/utility/importer/bookmarks_file_importer.cc b/chrome/utility/importer/bookmarks_file_importer.cc
index 6603970..18840df6 100644
--- a/chrome/utility/importer/bookmarks_file_importer.cc
+++ b/chrome/utility/importer/bookmarks_file_importer.cc
@@ -21,8 +21,10 @@
#include "components/user_data_importer/common/imported_bookmark_entry.h"
#include "components/user_data_importer/common/importer_data_types.h"
#include "components/user_data_importer/content/content_bookmark_parser_utils.h"
+#include "components/user_data_importer/mojom/bookmark_html_parser.mojom.h"
#include "components/user_data_importer/utility/bookmark_parser.h"
#include "content/public/common/url_constants.h"
+#include "mojo/public/cpp/bindings/remote.h"
namespace internal {
@@ -82,6 +84,11 @@
BookmarksFileImporter::~BookmarksFileImporter() = default;
+void BookmarksFileImporter::SetBookmarkHtmlParser(
+ mojo::PendingRemote<user_data_importer::mojom::BookmarkHtmlParser> parser) {
+ html_parser_remote_ = std::move(parser);
+}
+
void BookmarksFileImporter::StartImport(
const user_data_importer::SourceProfile& source_profile,
uint16_t items,
@@ -99,9 +106,24 @@
// ReadFileToString can return false, but still populate something into
// `raw_html`. In that case, try to recover as much data as possible.
base::ReadFileToString(source_profile.source_path, &raw_html);
- user_data_importer::BookmarkParser::ParsedBookmarks parsed_bookmarks =
- user_data_importer::ParseBookmarksUnsafe(raw_html);
+ CHECK(html_parser_remote_.is_valid());
+ auto html_parser = std::make_unique<
+ mojo::Remote<user_data_importer::mojom::BookmarkHtmlParser>>(
+ std::move(html_parser_remote_));
+
+ auto* raw_parser = html_parser.get();
+ (*raw_parser)
+ ->Parse(
+ raw_html,
+ base::BindOnce(&BookmarksFileImporter::OnBookmarksParsed,
+ base::WrapRefCounted(this), std::move(html_parser)));
+}
+
+void BookmarksFileImporter::OnBookmarksParsed(
+ std::unique_ptr<mojo::Remote<user_data_importer::mojom::BookmarkHtmlParser>>
+ html_parser,
+ user_data_importer::BookmarkParser::ParsedBookmarks parsed_bookmarks) {
if (!parsed_bookmarks.bookmarks.empty()) {
std::u16string first_folder_name =
bridge_->GetLocalizedString(IDS_BOOKMARK_GROUP);
diff --git a/chrome/utility/importer/bookmarks_file_importer.h b/chrome/utility/importer/bookmarks_file_importer.h
index 1b0adbe..3371999 100644
--- a/chrome/utility/importer/bookmarks_file_importer.h
+++ b/chrome/utility/importer/bookmarks_file_importer.h
@@ -8,6 +8,10 @@
#include <stdint.h>
#include "chrome/utility/importer/importer.h"
+#include "components/user_data_importer/mojom/bookmark_html_parser.mojom-forward.h"
+#include "components/user_data_importer/utility/bookmark_parser.h"
+#include "mojo/public/cpp/bindings/pending_remote.h"
+#include "mojo/public/cpp/bindings/remote.h"
// Importer for bookmarks files.
class BookmarksFileImporter : public Importer {
@@ -21,8 +25,20 @@
uint16_t items,
ImporterBridge* bridge) override;
+ void SetBookmarkHtmlParser(
+ mojo::PendingRemote<user_data_importer::mojom::BookmarkHtmlParser> parser)
+ override;
+
private:
~BookmarksFileImporter() override;
+
+ void OnBookmarksParsed(
+ std::unique_ptr<mojo::Remote<
+ user_data_importer::mojom::BookmarkHtmlParser>> html_parser,
+ user_data_importer::BookmarkParser::ParsedBookmarks parsed_bookmarks);
+
+ mojo::PendingRemote<user_data_importer::mojom::BookmarkHtmlParser>
+ html_parser_remote_;
};
#endif // CHROME_UTILITY_IMPORTER_BOOKMARKS_FILE_IMPORTER_H_
diff --git a/chrome/utility/importer/firefox_importer.cc b/chrome/utility/importer/firefox_importer.cc
index 9e8a8d5..e2bf3edc 100644
--- a/chrome/utility/importer/firefox_importer.cc
+++ b/chrome/utility/importer/firefox_importer.cc
@@ -25,7 +25,9 @@
#include "components/user_data_importer/common/importer_url_row.h"
#include "components/user_data_importer/content/content_bookmark_parser_utils.h"
#include "components/user_data_importer/content/favicon_reencode.h"
+#include "components/user_data_importer/mojom/bookmark_html_parser.mojom.h"
#include "components/user_data_importer/utility/bookmark_parser.h"
+#include "mojo/public/cpp/bindings/remote.h"
#include "sql/database.h"
#include "sql/statement.h"
#include "url/gurl.h"
@@ -115,6 +117,7 @@
bridge_ = bridge;
source_path_ = source_profile.source_path;
app_path_ = source_profile.app_path;
+ items_ = items;
#if BUILDFLAG(IS_POSIX)
locale_ = source_profile.locale;
@@ -144,17 +147,21 @@
if ((items & user_data_importer::FAVORITES) && !cancelled()) {
bridge_->NotifyItemStarted(user_data_importer::FAVORITES);
ImportBookmarks();
- bridge_->NotifyItemEnded(user_data_importer::FAVORITES);
+ return;
}
+ ImportRemainingItems();
+}
+
+void FirefoxImporter::ImportRemainingItems() {
#if !BUILDFLAG(IS_MAC)
- if ((items & user_data_importer::PASSWORDS) && !cancelled()) {
+ if ((items_ & user_data_importer::PASSWORDS) && !cancelled()) {
bridge_->NotifyItemStarted(user_data_importer::PASSWORDS);
ImportPasswords();
bridge_->NotifyItemEnded(user_data_importer::PASSWORDS);
}
#endif // !BUILDFLAG(IS_MAC)
- if ((items & user_data_importer::AUTOFILL_FORM_DATA) && !cancelled()) {
+ if ((items_ & user_data_importer::AUTOFILL_FORM_DATA) && !cancelled()) {
bridge_->NotifyItemStarted(user_data_importer::AUTOFILL_FORM_DATA);
ImportAutofillFormData();
bridge_->NotifyItemEnded(user_data_importer::AUTOFILL_FORM_DATA);
@@ -213,32 +220,41 @@
user_data_importer::VISIT_SOURCE_FIREFOX_IMPORTED);
}
+void FirefoxImporter::SetBookmarkHtmlParser(
+ mojo::PendingRemote<user_data_importer::mojom::BookmarkHtmlParser> parser) {
+ html_parser_remote_ = std::move(parser);
+}
+
void FirefoxImporter::ImportBookmarks() {
base::FilePath sqlite_file = GetCopiedSourcePath("places.sqlite");
if (!base::PathExists(sqlite_file)) {
+ bridge_->NotifyItemEnded(user_data_importer::FAVORITES);
+ ImportRemainingItems();
return;
}
- sql::Database db(kDatabaseTag);
- if (!db.Open(sqlite_file)) {
+ auto db = std::make_unique<sql::Database>(kDatabaseTag);
+ if (!db->Open(sqlite_file)) {
+ bridge_->NotifyItemEnded(user_data_importer::FAVORITES);
+ ImportRemainingItems();
return;
}
// |moz_favicons| table has been introduced in Firefox 55 and is not available
// in older Firefox profiles.
FaviconsLocation favicons_location =
- db.IsSQLValid("SELECT count(*) FROM moz_favicons")
+ db->IsSQLValid("SELECT count(*) FROM moz_favicons")
? FaviconsLocation::kPlacesDatabase
: FaviconsLocation::kFaviconsDatabase;
// Get the bookmark folders that we are interested in.
- int toolbar_folder_id = LoadNodeIDByGUID(&db, "toolbar_____");
- int menu_folder_id = LoadNodeIDByGUID(&db, "menu________");
- int unsorted_folder_id = LoadNodeIDByGUID(&db, "unfiled_____");
+ int toolbar_folder_id = LoadNodeIDByGUID(db.get(), "toolbar_____");
+ int menu_folder_id = LoadNodeIDByGUID(db.get(), "menu________");
+ int unsorted_folder_id = LoadNodeIDByGUID(db.get(), "unfiled_____");
// Load livemark IDs.
- std::set<int> livemark_id;
- LoadLivemarkIDs(&db, &livemark_id);
+ auto livemark_id = std::make_unique<std::set<int>>();
+ LoadLivemarkIDs(db.get(), livemark_id.get());
// Load the default bookmarks.
base::FilePath bookmarks_file = app_path_.AppendASCII("defaults")
@@ -249,21 +265,44 @@
// ReadFileToString can return false, but still populate something into
// `raw_html`. In that case, try to recover as much data as possible.
base::ReadFileToString(bookmarks_file, &raw_html);
- user_data_importer::BookmarkParser::ParsedBookmarks default_bookmarks =
- user_data_importer::ParseBookmarksUnsafe(raw_html);
+ CHECK(html_parser_remote_.is_valid());
+ auto html_parser = std::make_unique<
+ mojo::Remote<user_data_importer::mojom::BookmarkHtmlParser>>(
+ std::move(html_parser_remote_));
+
+ auto* raw_parser = html_parser.get();
+ (*raw_parser)
+ ->Parse(raw_html,
+ base::BindOnce(&FirefoxImporter::OnDefaultBookmarksParsed,
+ base::WrapRefCounted(this), std::move(db),
+ toolbar_folder_id, menu_folder_id,
+ unsorted_folder_id, std::move(livemark_id),
+ favicons_location, std::move(html_parser)));
+}
+
+void FirefoxImporter::OnDefaultBookmarksParsed(
+ std::unique_ptr<sql::Database> db,
+ int toolbar_folder_id,
+ int menu_folder_id,
+ int unsorted_folder_id,
+ std::unique_ptr<std::set<int>> livemark_id,
+ FaviconsLocation favicons_location,
+ std::unique_ptr<mojo::Remote<user_data_importer::mojom::BookmarkHtmlParser>>
+ html_parser,
+ user_data_importer::BookmarkParser::ParsedBookmarks default_bookmarks) {
std::set<GURL> default_urls;
for (const auto& bookmark : default_bookmarks.bookmarks) {
default_urls.insert(bookmark.url);
}
BookmarkList list;
- GetTopBookmarkFolder(&db, toolbar_folder_id, &list);
- GetTopBookmarkFolder(&db, menu_folder_id, &list);
- GetTopBookmarkFolder(&db, unsorted_folder_id, &list);
+ GetTopBookmarkFolder(db.get(), toolbar_folder_id, &list);
+ GetTopBookmarkFolder(db.get(), menu_folder_id, &list);
+ GetTopBookmarkFolder(db.get(), unsorted_folder_id, &list);
size_t count = list.size();
for (size_t i = 0; i < count; ++i) {
- GetWholeBookmarkFolder(&db, &list, i, favicons_location, nullptr);
+ GetWholeBookmarkFolder(db.get(), &list, i, favicons_location, nullptr);
}
std::vector<user_data_importer::ImportedBookmarkEntry> bookmarks;
@@ -278,9 +317,11 @@
"INNER JOIN moz_items_annos ia ON ia.item_id = b.id "
"INNER JOIN moz_anno_attributes aa ON ia.anno_attribute_id = aa.id "
"WHERE aa.name = 'bookmarkProperties/POSTData'";
- sql::Statement s(db.GetUniqueStatement(query));
+ sql::Statement s(db->GetUniqueStatement(query));
if (!s.is_valid()) {
+ bridge_->NotifyItemEnded(user_data_importer::FAVORITES);
+ ImportRemainingItems();
return;
}
@@ -310,7 +351,7 @@
bool is_in_toolbar = false;
while (child->parent >= 0) {
BookmarkItem* parent = list[child->parent].get();
- if (livemark_id.find(parent->id) != livemark_id.end()) {
+ if (livemark_id->find(parent->id) != livemark_id->end()) {
// Don't import live bookmarks.
break;
}
@@ -394,12 +435,15 @@
DCHECK(favicon_map.empty());
LoadFavicons(bookmarks, &favicons);
} else if (!favicon_map.empty()) {
- LoadFavicons(&db, favicon_map, &favicons);
+ LoadFavicons(db.get(), favicon_map, &favicons);
}
if (!favicons.empty()) {
bridge_->SetFavicons(favicons);
}
}
+
+ bridge_->NotifyItemEnded(user_data_importer::FAVORITES);
+ ImportRemainingItems();
}
#if !BUILDFLAG(IS_MAC)
@@ -577,7 +621,7 @@
item->title = s.ColumnString16(2);
item->type = static_cast<BookmarkItemType>(s.ColumnInt(3));
item->keyword = s.ColumnString(4);
- item->date_added = base::Time::FromTimeT(s.ColumnInt64(5)/1000000);
+ item->date_added = base::Time::FromTimeT(s.ColumnInt64(5) / 1000000);
item->favicon = favicons_location == FaviconsLocation::kPlacesDatabase
? s.ColumnInt64(6)
: 0;
diff --git a/chrome/utility/importer/firefox_importer.h b/chrome/utility/importer/firefox_importer.h
index eabc8359..fea3060 100644
--- a/chrome/utility/importer/firefox_importer.h
+++ b/chrome/utility/importer/firefox_importer.h
@@ -23,6 +23,13 @@
#include "chrome/utility/importer/importer.h"
#include "components/favicon_base/favicon_usage_data.h"
#include "components/user_data_importer/common/imported_bookmark_entry.h"
+#include "components/user_data_importer/utility/bookmark_parser.h"
+#include "mojo/public/cpp/bindings/pending_remote.h"
+#include "mojo/public/cpp/bindings/remote.h"
+
+namespace user_data_importer::mojom {
+class BookmarkHtmlParser;
+} // namespace user_data_importer::mojom
class GURL;
@@ -45,6 +52,10 @@
uint16_t items,
ImporterBridge* bridge) override;
+ void SetBookmarkHtmlParser(
+ mojo::PendingRemote<user_data_importer::mojom::BookmarkHtmlParser> parser)
+ override;
+
private:
// Location of favicons in Firefox profile. It may vary depending on Firefox
// version.
... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/utility/importer/bookmarks_file_importer_unittest.cc b/chrome/utility/importer/bookmarks_file_importer_unittest.cc
index 253407b..cb786262 100644
--- a/chrome/utility/importer/bookmarks_file_importer_unittest.cc
+++ b/chrome/utility/importer/bookmarks_file_importer_unittest.cc
@@ -11,12 +11,16 @@
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/memory/ref_counted.h"
+#include "base/run_loop.h"
#include "base/strings/utf_string_conversions.h"
#include "base/time/time.h"
#include "chrome/common/importer/importer_autofill_form_data_entry.h"
#include "chrome/common/importer/importer_bridge.h"
#include "components/user_data_importer/common/imported_bookmark_entry.h"
#include "components/user_data_importer/common/importer_data_types.h"
+#include "components/user_data_importer/content/fake_bookmark_html_parser.h"
+#include "content/public/test/browser_task_environment.h"
+#include "mojo/public/cpp/bindings/receiver.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
#include "url/gurl.h"
@@ -87,8 +91,15 @@
~MockImporterBridge() override = default;
};
+class BookmarksFileImporterTest : public testing::Test {
+ protected:
+ content::BrowserTaskEnvironment task_environment_;
+ user_data_importer::FakeBookmarkHtmlParser fake_parser_;
+ mojo::Receiver<user_data_importer::mojom::BookmarkHtmlParser> receiver_{
+ &fake_parser_};
+};
-TEST(BookmarksFileImporterTest, CanImportURL) {
+TEST_F(BookmarksFileImporterTest, CanImportURL) {
struct TestCase {
const std::string url;
const bool can_be_imported;
@@ -117,7 +128,7 @@
}
}
-TEST(BookmarksFileImporterTest, ImportBookmarks) {
+TEST_F(BookmarksFileImporterTest, ImportBookmarks) {
base::ScopedTempDir temp_dir;
ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
base::FilePath file_path = temp_dir.GetPath().AppendASCII("bookmarks.html");
@@ -148,15 +159,19 @@
bookmark.creation_time = base::Time::UnixEpoch() + base::Seconds(456);
expected_bookmarks.push_back(bookmark);
+ base::RunLoop run_loop;
EXPECT_CALL(*bridge, NotifyStarted());
- EXPECT_CALL(*bridge, NotifyEnded());
+ EXPECT_CALL(*bridge, NotifyEnded())
+ .WillOnce(testing::InvokeWithoutArgs(&run_loop, &base::RunLoop::Quit));
EXPECT_CALL(*bridge, AddBookmarks(expected_bookmarks, _));
+ importer->SetBookmarkHtmlParser(receiver_.BindNewPipeAndPassRemote());
importer->StartImport(source_profile, user_data_importer::FAVORITES,
bridge.get());
+ run_loop.Run();
}
-TEST(BookmarksFileImporterTest, ImportEmptyFile) {
+TEST_F(BookmarksFileImporterTest, ImportEmptyFile) {
base::ScopedTempDir temp_dir;
ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
base::FilePath file_path = temp_dir.GetPath().AppendASCII("bookmarks.html");
@@ -168,17 +183,21 @@
user_data_importer::SourceProfile source_profile;
source_profile.source_path = file_path;
+ base::RunLoop run_loop;
EXPECT_CALL(*bridge, NotifyStarted());
- EXPECT_CALL(*bridge, NotifyEnded());
+ EXPECT_CALL(*bridge, NotifyEnded())
+ .WillOnce(testing::InvokeWithoutArgs(&run_loop, &base::RunLoop::Quit));
EXPECT_CALL(*bridge, AddBookmarks(_, _)).Times(0);
EXPECT_CALL(*bridge, SetKeywords(_, _)).Times(0);
EXPECT_CALL(*bridge, SetFavicons(_)).Times(0);
+ importer->SetBookmarkHtmlParser(receiver_.BindNewPipeAndPassRemote());
importer->StartImport(source_profile, user_data_importer::FAVORITES,
bridge.get());
+ run_loop.Run();
}
-TEST(BookmarksFileImporterTest, ImportWithInvalidBookmarks) {
+TEST_F(BookmarksFileImporterTest, ImportWithInvalidBookmarks) {
base::ScopedTempDir temp_dir;
ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
base::FilePath file_path = temp_dir.GetPath().AppendASCII("bookmarks.html");
@@ -204,15 +223,19 @@
bookmark.creation_time = base::Time::UnixEpoch() + base::Seconds(123);
expected_bookmarks.push_back(bookmark);
+ base::RunLoop run_loop;
EXPECT_CALL(*bridge, NotifyStarted());
- EXPECT_CALL(*bridge, NotifyEnded());
+ EXPECT_CALL(*bridge, NotifyEnded())
+ .WillOnce(testing::InvokeWithoutArgs(&run_loop, &base::RunLoop::Quit));
EXPECT_CALL(*bridge, AddBookmarks(expected_bookmarks, _));
+ importer->SetBookmarkHtmlParser(receiver_.BindNewPipeAndPassRemote());
importer->StartImport(source_profile, user_data_importer::FAVORITES,
bridge.get());
+ run_loop.Run();
}
-TEST(BookmarksFileImporterTest, ImportSearchEngine) {
+TEST_F(BookmarksFileImporterTest, ImportSearchEngine) {
base::ScopedTempDir temp_dir;
ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
base::FilePath file_path = temp_dir.GetPath().AppendASCII("bookmarks.html");
@@ -235,16 +258,20 @@
search_engine.display_name = u"Google Search";
expected_search_engines.push_back(search_engine);
+ base::RunLoop run_loop;
EXPECT_CALL(*bridge, NotifyStarted());
- EXPECT_CALL(*bridge, NotifyEnded());
+ EXPECT_CALL(*bridge, NotifyEnded())
+ .WillOnce(testing::InvokeWithoutArgs(&run_loop, &base::RunLoop::Quit));
EXPECT_CALL(*bridge, AddBookmarks(_, _)).Times(0);
EXPECT_CALL(*bridge, SetKeywords(expected_search_engines, false));
+ importer->SetBookmarkHtmlParser(receiver_.BindNewPipeAndPassRemote());
importer->StartImport(source_profile, user_data_importer::FAVORITES,
bridge.get());
+ run_loop.Run();
}
-TEST(BookmarksFileImporterTest, ImportWithFavicon) {
+TEST_F(BookmarksFileImporterTest, ImportWithFavicon) {
base::ScopedTempDir temp_dir;
ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
base::FilePath file_path = temp_dir.GetPath().AppendASCII("bookmarks.html");
@@ -261,8 +288,10 @@
user_data_importer::SourceProfile source_profile;
source_profile.source_path = file_path;
+ base::RunLoop run_loop;
EXPECT_CALL(*bridge, NotifyStarted());
- EXPECT_CALL(*bridge, NotifyEnded());
+ EXPECT_CALL(*bridge, NotifyEnded())
+ .WillOnce(testing::InvokeWithoutArgs(&run_loop, &base::RunLoop::Quit));
EXPECT_CALL(*bridge,
AddBookmarks(_, _)); // Expecting a bookmark to be added.
EXPECT_CALL(
@@ -274,7 +303,9 @@
Contains(GURL("http://www.google.com/"))),
Field(&favicon_base::FaviconUsageData::png_data, Not(IsEmpty()))))));
+ importer->SetBookmarkHtmlParser(receiver_.BindNewPipeAndPassRemote());
importer->StartImport(source_profile, user_data_importer::FAVORITES,
bridge.get());
+ run_loop.Run();
}
} // namespace
diff --git a/chrome/utility/importer/firefox_importer_unittest.cc b/chrome/utility/importer/firefox_importer_unittest.cc
index 6e202ee..d5ae875 100644
--- a/chrome/utility/importer/firefox_importer_unittest.cc
+++ b/chrome/utility/importer/firefox_importer_unittest.cc
@@ -12,6 +12,7 @@
#include "base/files/file_util.h"
#include "base/files/scoped_temp_dir.h"
#include "base/path_service.h"
+#include "base/run_loop.h"
#include "base/strings/utf_string_conversions.h"
#include "build/build_config.h"
#include "chrome/common/chrome_paths.h"
@@ -20,7 +21,9 @@
#include "components/user_data_importer/common/imported_bookmark_entry.h"
#include "components/user_data_importer/common/importer_data_types.h"
#include "components/user_data_importer/common/importer_url_row.h"
+#include "components/user_data_importer/content/fake_bookmark_html_parser.h"
#include "content/public/test/browser_task_environment.h"
+#include "mojo/public/cpp/bindings/receiver.h"
#include "sql/database.h"
#include "sql/test/test_helpers.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -45,6 +48,7 @@
user_data_importer::SourceProfile profile;
profile.source_path = places_path;
+ base::RunLoop run_loop;
EXPECT_CALL(*bridge_, NotifyStarted());
EXPECT_CALL(*bridge_, NotifyItemStarted(user_data_importer::FAVORITES));
EXPECT_CALL(*bridge_, AddBookmarks(_, _))
@@ -52,17 +56,24 @@
EXPECT_CALL(*bridge_, SetFavicons(_))
.WillOnce(::testing::SaveArg<0>(favicons));
EXPECT_CALL(*bridge_, NotifyItemEnded(user_data_importer::FAVORITES));
- EXPECT_CALL(*bridge_, NotifyEnded());
+ EXPECT_CALL(*bridge_, NotifyEnded())
+ .WillOnce(testing::InvokeWithoutArgs(&run_loop, &base::RunLoop::Quit));
+
+ importer_->SetBookmarkHtmlParser(receiver_.BindNewPipeAndPassRemote());
importer_->StartImport(profile, user_data_importer::FAVORITES,
bridge_.get());
+ run_loop.Run();
}
- private:
+ protected:
content::BrowserTaskEnvironment task_environment_;
scoped_refptr<MockImporterBridge> bridge_ =
base::MakeRefCounted<MockImporterBridge>();
scoped_refptr<FirefoxImporter> importer_ =
base::MakeRefCounted<FirefoxImporter>();
+ user_data_importer::FakeBookmarkHtmlParser fake_parser_;
+ mojo::Receiver<user_data_importer::mojom::BookmarkHtmlParser> receiver_{
+ &fake_parser_};
};
TEST_F(FirefoxImporterTest, ImportBookmarks_Firefox48) {
@@ -155,14 +166,23 @@
profile.source_path = source_temp_dir.GetPath();
scoped_refptr<MockImporterBridge> bridge =
base::MakeRefCounted<MockImporterBridge>();
+ base::RunLoop run_loop1;
EXPECT_CALL(*bridge, NotifyStarted());
EXPECT_CALL(*bridge, NotifyItemStarted(user_data_importer::FAVORITES));
EXPECT_CALL(*bridge, AddBookmarks(_, _)).Times(0);
EXPECT_CALL(*bridge, SetFavicons(_)).Times(0);
EXPECT_CALL(*bridge, NotifyItemEnded(user_data_importer::FAVORITES));
- EXPECT_CALL(*bridge, NotifyEnded());
+ EXPECT_CALL(*bridge, NotifyEnded())
+ .WillOnce(testing::InvokeWithoutArgs(&run_loop1, &base::RunLoop::Quit));
+
+ user_data_importer::FakeBookmarkHtmlParser fake_parser1;
+ mojo::Receiver<user_data_importer::mojom::BookmarkHtmlParser> receiver1{
+ &fake_parser1};
+ first_importer->SetBookmarkHtmlParser(receiver1.BindNewPipeAndPassRemote());
+
first_importer->StartImport(profile, user_data_importer::FAVORITES,
bridge.get());
+ run_loop1.Run();
// Part 2: Test GetWholeBookmarkFolder validation
base::ScopedTempDir second_source_dir;
@@ -201,14 +221,23 @@
second_profile.source_path = second_source_dir.GetPath();
scoped_refptr<MockImporterBridge> second_bridge =
base::MakeRefCounted<MockImporterBridge>();
+ base::RunLoop run_loop2;
EXPECT_CALL(*second_bridge, NotifyStarted());
EXPECT_CALL(*second_bridge, NotifyItemStarted(user_data_importer::FAVORITES));
EXPECT_CALL(*second_bridge, AddBookmarks(_, _)).Times(0);
EXPECT_CALL(*second_bridge, SetFavicons(_)).Times(0);
EXPECT_CALL(*second_bridge, NotifyItemEnded(user_data_importer::FAVORITES));
- EXPECT_CALL(*second_bridge, NotifyEnded());
+ EXPECT_CALL(*second_bridge, NotifyEnded())
+ .WillOnce(testing::InvokeWithoutArgs(&run_loop2, &base::RunLoop::Quit));
+
+ user_data_importer::FakeBookmarkHtmlParser fake_parser2;
+ mojo::Receiver<user_data_importer::mojom::BookmarkHtmlParser> receiver2{
+ &fake_parser2};
+ second_importer->SetBookmarkHtmlParser(receiver2.BindNewPipeAndPassRemote());
+
second_importer->StartImport(second_profile, user_data_importer::FAVORITES,
second_bridge.get());
+ run_loop2.Run();
}
TEST_F(FirefoxImporterTest, ImportHistorySchema) {
Original Bug Report
RCE in Unsandboxed ProfileImport via Blink Image Codecs
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The ProfileImport utility process runs without a sandbox but processes untrusted bookmark favicons using the complex Blink image-codec stack. This architectural flaw violates the ‘Rule-of-2’ by performing dangerous parsing of untrusted data in a high-privilege process. A malicious bookmarks file could potentially trigger memory corruption in a codec and achieve Remote Code Execution (RCE) at the OS-user privilege level.
Affected files:
chrome/utility/importer/bookmarks_file_importer.ccchrome/utility/importer/firefox_importer.cccomponents/user_data_importer/content/content_bookmark_parser_utils.cccomponents/user_data_importer/content/favicon_reencode.ccchrome/common/importer/profile_import.mojomchrome/utility/importer/profile_import_impl.cc
Estimated timestamp from git blame: 2025-07-30
Summary
A potential “Rule-of-2” security violation exists in the desktop bookmark import system. When a user imports a bookmarks.html file, the ProfileImport utility process—which is explicitly unsandboxed—decodes favicon data-URLs found in the file using the full Blink image-codec stack. This allows the large attack surface of C++ image codecs (WebP, AVIF, PNG, etc.) to be targeted from a process with OS-user privileges.
Technical Details
- Unsandboxed Process: The
ProfileImportutility service is configured with[ServiceSandbox=sandbox.mojom.Sandbox.kNoSandbox]inchrome/common/importer/profile_import.mojom. This is intended to allow the process to access profile directories of other browsers on the local disk. - Dangerous Parsing Path: In
chrome/utility/importer/bookmarks_file_importer.cc, theStartImportmethod invokesuser_data_importer::ParseBookmarksUnsafe(raw_html). This function is documented incomponents/user_data_importer/content/content_bookmark_parser_utils.hwith a contract stating: “This function must be run in a sandboxed process.” - Codec Invocation: Within
ParseBookmarksUnsafe, extractedICONattributes (which aredata:URLs) are processed viaimporter::ReencodeFavicon. This leads tocontent::DecodeImage, which initializes and runs the Blink image-codec stack (blink::ImageDecoder). - Blink Initialization: The utility process explicitly prepares for this by calling
content::UtilityThread::Get()->EnsureBlinkInitialized()inProfileImportImpl::StartImport(chrome/utility/importer/profile_import_impl.cc).
Potential Security Impact
By embedding a crafted image payload within a bookmark’s ICON attribute, an attacker could potentially trigger a memory corruption vulnerability (such as a heap overflow) in one of the Blink image codecs. Because the ProfileImport process is unsandboxed, successful exploitation would grant the attacker Remote Code Execution (RCE) with the full privileges of the OS user, bypassing the Chrome sandbox entirely.
Potential Trigger Path
- An attacker induces a user to download a malicious
bookmarks.htmlfile. - The user navigates to
chrome://settings/importData, selects “Bookmarks HTML File”, and selects the malicious file. - The Browser process launches the unsandboxed
ProfileImportutility process. - The utility process reads the file and invokes the Blink codec stack to decode the malicious
ICONdata, potentially triggering a vulnerability.
Recommended Fix
The legacy desktop importers (BookmarksFileImporter and FirefoxImporter) should be refactored to avoid calling ParseBookmarksUnsafe directly in the unsandboxed process. Instead, they should utilize the existing sandboxed mojom::BookmarkHtmlParser service (defined in components/user_data_importer/mojom/bookmark_html_parser.mojom), which is designed to parse this untrusted data within a restricted environment.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.