From 84df51c518d987852700ccc0c080de6db4ac55f4 Mon Sep 17 00:00:00 2001 From: Volodymyr Styran Date: Mon, 27 Jul 2026 16:06:44 +0300 Subject: [PATCH] Scan Safari profile databases for history and browser state (#846) * fix(ios): scan Safari profile databases for history and browser state Safari profiles (iOS 17 and later) keep their own databases under Library/Safari/Profiles//, but SafariHistory and SafariBrowserState only ever looked at the default profile's Library/Safari/History.db and Library/Safari/BrowserState.db. On a device where browsing happens inside a profile, MVT silently skipped that history and still reported no detections, so an indicator only ever visited within a profile went unnoticed. Both modules now also match Library/Safari/Profiles/*/ in backups and in filesystem dumps. No helper changes were needed: the Manifest.db lookup already translates "*" into a SQL LIKE wildcard, and the filesystem lookup already globs. Found while examining an encrypted iOS 26.5.2 backup that contained 14 per-profile History.db files under AppDomain-com.apple.mobilesafari::Library/Safari/Profiles//. Co-Authored-By: Claude Opus 5 * fix(ios): scope Safari redirects to history database --------- Co-authored-by: Claude Opus 5 Co-authored-by: Janik Besendorf --- .../ios/modules/mixed/safari_browserstate.py | 35 ++-- src/mvt/ios/modules/mixed/safari_history.py | 33 +++- tests/ios_backup/test_safari_browserstate.py | 43 ++++- tests/ios_backup/test_safari_history.py | 165 ++++++++++++++++++ tests/utils.py | 18 ++ 5 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 tests/ios_backup/test_safari_history.py diff --git a/src/mvt/ios/modules/mixed/safari_browserstate.py b/src/mvt/ios/modules/mixed/safari_browserstate.py index 48998f1..e67b7d3 100644 --- a/src/mvt/ios/modules/mixed/safari_browserstate.py +++ b/src/mvt/ios/modules/mixed/safari_browserstate.py @@ -19,10 +19,17 @@ from mvt.common.utils import convert_mactime_to_iso, keys_bytes_to_string from ..base import IOSExtraction -SAFARI_BROWSER_STATE_BACKUP_RELPATH = "Library/Safari/BrowserState.db" +# Safari profiles (iOS 17 and later) keep their per-profile databases under +# Library/Safari/Profiles//, separate from the default profile's. +SAFARI_BROWSER_STATE_BACKUP_RELPATHS = [ + "Library/Safari/BrowserState.db", + "Library/Safari/Profiles/*/BrowserState.db", +] SAFARI_BROWSER_STATE_ROOT_PATHS = [ "private/var/mobile/Library/Safari/BrowserState.db", + "private/var/mobile/Library/Safari/Profiles/*/BrowserState.db", "private/var/mobile/Containers/Data/Application/*/Library/Safari/BrowserState.db", + "private/var/mobile/Containers/Data/Application/*/Library/Safari/Profiles/*/BrowserState.db", ] @@ -174,20 +181,22 @@ class SafariBrowserState(IOSExtraction): def run(self) -> None: if self.is_backup: - for backup_file in self._get_backup_files_from_manifest( - relative_path=SAFARI_BROWSER_STATE_BACKUP_RELPATH - ): - browserstate_path = self._get_backup_file_from_id( - backup_file["file_id"] - ) + for relative_path in SAFARI_BROWSER_STATE_BACKUP_RELPATHS: + for backup_file in self._get_backup_files_from_manifest( + relative_path=relative_path + ): + browserstate_path = self._get_backup_file_from_id( + backup_file["file_id"] + ) - if not browserstate_path: - continue + if not browserstate_path: + continue - self.log.info( - "Found Safari browser state database at path: %s", browserstate_path - ) - self._process_browser_state_db(browserstate_path) + self.log.info( + "Found Safari browser state database at path: %s", + browserstate_path, + ) + self._process_browser_state_db(browserstate_path) elif self.is_fs_dump: for browserstate_path in self._get_fs_files_from_patterns( SAFARI_BROWSER_STATE_ROOT_PATHS diff --git a/src/mvt/ios/modules/mixed/safari_history.py b/src/mvt/ios/modules/mixed/safari_history.py index 9ba32ad..4a50f02 100644 --- a/src/mvt/ios/modules/mixed/safari_history.py +++ b/src/mvt/ios/modules/mixed/safari_history.py @@ -17,10 +17,17 @@ from mvt.common.utils import convert_mactime_to_datetime, convert_mactime_to_iso from ..base import IOSExtraction -SAFARI_HISTORY_BACKUP_RELPATH = "Library/Safari/History.db" +# Safari profiles (iOS 17 and later) each keep their own History.db under +# Library/Safari/Profiles//, separate from the default profile's database. +SAFARI_HISTORY_BACKUP_RELPATHS = [ + "Library/Safari/History.db", + "Library/Safari/Profiles/*/History.db", +] SAFARI_HISTORY_ROOT_PATHS = [ "private/var/mobile/Library/Safari/History.db", + "private/var/mobile/Library/Safari/Profiles/*/History.db", "private/var/mobile/Containers/Data/Application/*/Library/Safari/History.db", + "private/var/mobile/Containers/Data/Application/*/Library/Safari/Profiles/*/History.db", ] @@ -75,6 +82,9 @@ class SafariHistory(IOSExtraction): # We loop again through visits in order to find redirect record. for redirect in self.results: + if redirect["safari_history_db"] != result["safari_history_db"]: + continue + if redirect["visit_id"] != result["redirect_destination"]: continue @@ -159,17 +169,22 @@ class SafariHistory(IOSExtraction): def run(self) -> None: if self.is_backup: - for history_file in self._get_backup_files_from_manifest( - relative_path=SAFARI_HISTORY_BACKUP_RELPATH - ): - history_path = self._get_backup_file_from_id(history_file["file_id"]) + for relative_path in SAFARI_HISTORY_BACKUP_RELPATHS: + for history_file in self._get_backup_files_from_manifest( + relative_path=relative_path + ): + history_path = self._get_backup_file_from_id( + history_file["file_id"] + ) - if not history_path: - continue + if not history_path: + continue - self.log.info("Found Safari history database at path: %s", history_path) + self.log.info( + "Found Safari history database at path: %s", history_path + ) - self._process_history_db(history_path) + self._process_history_db(history_path) elif self.is_fs_dump: for history_path in self._get_fs_files_from_patterns( SAFARI_HISTORY_ROOT_PATHS diff --git a/tests/ios_backup/test_safari_browserstate.py b/tests/ios_backup/test_safari_browserstate.py index fd7e62e..fa2149d 100644 --- a/tests/ios_backup/test_safari_browserstate.py +++ b/tests/ios_backup/test_safari_browserstate.py @@ -4,12 +4,44 @@ # https://license.mvt.re/1.1/ import logging +import shutil + +import pytest from mvt.common.indicators import Indicators from mvt.common.module import run_module from mvt.ios.modules.mixed.safari_browserstate import SafariBrowserState -from ..utils import get_ios_backup_folder +from ..utils import add_backup_manifest_entry, get_ios_backup_folder + +# fileID of HomeDomain::Library/Safari/BrowserState.db in the test backup. +DEFAULT_BROWSER_STATE_FILE_ID = "3a47b0981ed7c10f3e2800aa66bac96a3b5db28e" +PROFILE_UUID = "00000000-0000-4000-A000-000000000001" +PROFILE_BROWSER_STATE_FILE_ID = "bb00000000000000000000000000000000000001" + + +@pytest.fixture +def backup_with_safari_profile(tmp_path): + """An iTunes backup where a Safari profile has its own browser state.""" + backup_path = tmp_path / "backup" + shutil.copytree(get_ios_backup_folder(), backup_path) + + profile_db = ( + backup_path / PROFILE_BROWSER_STATE_FILE_ID[:2] / PROFILE_BROWSER_STATE_FILE_ID + ) + profile_db.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile( + backup_path / DEFAULT_BROWSER_STATE_FILE_ID[:2] / DEFAULT_BROWSER_STATE_FILE_ID, + profile_db, + ) + add_backup_manifest_entry( + backup_path, + PROFILE_BROWSER_STATE_FILE_ID, + "AppDomain-com.apple.mobilesafari", + f"Library/Safari/Profiles/{PROFILE_UUID}/BrowserState.db", + ) + + return str(backup_path) class TestSafariBrowserStateModule: @@ -21,6 +53,15 @@ class TestSafariBrowserStateModule: assert len(m.timeline) == 1 assert len(m.alertstore.alerts) == 0 + def test_parsing_backup_with_profile(self, backup_with_safari_profile): + m = SafariBrowserState(target_path=backup_with_safari_profile) + m.is_backup = True + run_module(m) + + # Both the default profile and the named profile are extracted. + assert len(m.results) == 2 + assert len({result["safari_browser_state_db"] for result in m.results}) == 2 + def test_detection(self, indicator_file): m = SafariBrowserState(target_path=get_ios_backup_folder()) m.is_backup = True diff --git a/tests/ios_backup/test_safari_history.py b/tests/ios_backup/test_safari_history.py new file mode 100644 index 0000000..258b352 --- /dev/null +++ b/tests/ios_backup/test_safari_history.py @@ -0,0 +1,165 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 The MVT Authors. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import logging +import shutil +import sqlite3 +from pathlib import Path + +import pytest + +from mvt.common.indicators import Indicators +from mvt.common.module import run_module +from mvt.ios.modules.mixed.safari_history import SafariHistory + +from ..utils import add_backup_manifest_entry, get_ios_backup_folder + +# fileID of HomeDomain::Library/Safari/History.db in the test backup. +DEFAULT_HISTORY_FILE_ID = "1a0e7afc19d307da602ccdcece51af33afe92c53" +PROFILE_UUID = "00000000-0000-4000-A000-000000000001" +PROFILE_HISTORY_FILE_ID = "aa00000000000000000000000000000000000001" + +# example.org is already a test indicator, so use domains that do not match. +DEFAULT_URL = "https://default.example.net/visited-page" +PROFILE_URL = "https://profile.example.net/visited-page" + + +def create_history_db(path, url): + """Create a minimal Safari History.db holding a single visit.""" + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.executescript( + """ + CREATE TABLE history_items (id INTEGER PRIMARY KEY, url TEXT); + CREATE TABLE history_visits ( + id INTEGER PRIMARY KEY, + history_item INTEGER, + visit_time REAL, + redirect_source INTEGER, + redirect_destination INTEGER + ); + """ + ) + conn.execute("INSERT INTO history_items VALUES (1, ?);", (url,)) + conn.execute("INSERT INTO history_visits VALUES (1, 1, 726100000.0, NULL, NULL);") + conn.commit() + conn.close() + + +@pytest.fixture +def backup_with_safari_profile(tmp_path): + """An iTunes backup where Safari has both a default and a named profile.""" + backup_path = tmp_path / "backup" + shutil.copytree(get_ios_backup_folder(), backup_path) + + # The default profile's database ships empty, so give it a visit to make + # sure the profile lookup does not replace the pre-existing one. + create_history_db( + backup_path / DEFAULT_HISTORY_FILE_ID[:2] / DEFAULT_HISTORY_FILE_ID, + DEFAULT_URL, + ) + + create_history_db( + backup_path / PROFILE_HISTORY_FILE_ID[:2] / PROFILE_HISTORY_FILE_ID, + PROFILE_URL, + ) + add_backup_manifest_entry( + backup_path, + PROFILE_HISTORY_FILE_ID, + "AppDomain-com.apple.mobilesafari", + f"Library/Safari/Profiles/{PROFILE_UUID}/History.db", + ) + + return str(backup_path) + + +@pytest.fixture +def fs_dump_with_safari_profile(tmp_path): + """A filesystem dump where Safari has both a default and a named profile.""" + safari_path = tmp_path / "private" / "var" / "mobile" / "Library" / "Safari" + profile_path = safari_path / "Profiles" / PROFILE_UUID + profile_path.mkdir(parents=True) + + create_history_db(safari_path / "History.db", DEFAULT_URL) + create_history_db(profile_path / "History.db", PROFILE_URL) + + return str(tmp_path) + + +class TestSafariHistoryModule: + def test_parsing(self): + m = SafariHistory(target_path=get_ios_backup_folder()) + m.is_backup = True + run_module(m) + assert len(m.results) == 0 + assert len(m.alertstore.alerts) == 0 + + def test_parsing_backup_with_profile(self, backup_with_safari_profile): + m = SafariHistory(target_path=backup_with_safari_profile) + m.is_backup = True + run_module(m) + + # Both the default profile and the named profile are extracted. + assert len(m.results) == 2 + assert {result["url"] for result in m.results} == {DEFAULT_URL, PROFILE_URL} + assert len({result["safari_history_db"] for result in m.results}) == 2 + + def test_parsing_fs_dump_with_profile(self, fs_dump_with_safari_profile): + m = SafariHistory(target_path=fs_dump_with_safari_profile) + m.is_fs_dump = True + run_module(m) + + assert len(m.results) == 2 + assert {result["url"] for result in m.results} == {DEFAULT_URL, PROFILE_URL} + + def test_redirect_ids_are_scoped_to_database(self, fs_dump_with_safari_profile): + safari_path = ( + Path(fs_dump_with_safari_profile) + / "private" + / "var" + / "mobile" + / "Library" + / "Safari" + ) + with sqlite3.connect(safari_path / "History.db") as conn: + conn.execute( + "UPDATE history_items SET url = ? WHERE id = 1;", + ("http://safe.example.com/start",), + ) + conn.execute( + "INSERT INTO history_items VALUES (2, ?);", + ("https://safe.example.com/end",), + ) + conn.execute( + "UPDATE history_visits SET redirect_destination = 2 WHERE id = 1;" + ) + conn.execute( + "INSERT INTO history_visits VALUES (2, 2, 726100000.1, 1, NULL);" + ) + + profile_db = safari_path / "Profiles" / PROFILE_UUID / "History.db" + with sqlite3.connect(profile_db) as conn: + # Visit IDs are local to each database and commonly overlap. + conn.execute("UPDATE history_visits SET id = 2 WHERE id = 1;") + + m = SafariHistory(target_path=fs_dump_with_safari_profile) + m.is_fs_dump = True + run_module(m) + + assert len(m.results) == 3 + assert len(m.alertstore.alerts) == 0 + + def test_detection_in_profile(self, backup_with_safari_profile, indicator_file): + """An indicator only visited inside a Safari profile still alerts.""" + m = SafariHistory(target_path=backup_with_safari_profile) + m.is_backup = True + ind = Indicators(log=logging.getLogger()) + ind.parse_stix2(indicator_file) + ind.ioc_collections[0]["domains"].append("profile.example.net") + m.indicators = ind + run_module(m) + + assert len(m.alertstore.alerts) == 1 + assert m.alertstore.alerts[0].event["url"] == PROFILE_URL diff --git a/tests/utils.py b/tests/utils.py index b29e069..9ae433d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ import os +import sqlite3 from pathlib import Path @@ -37,6 +38,23 @@ def get_indicator_file(): print("PYTEST env", os.getenv("PYTEST_CURRENT_TEST")) +def add_backup_manifest_entry(backup_path, file_id, domain, relative_path): + """ + Register an extra file in a test backup's Manifest.db + """ + conn = sqlite3.connect(os.path.join(backup_path, "Manifest.db")) + conn.execute( + "INSERT INTO Files (fileID, domain, relativePath, flags, file) " + "VALUES (?, ?, ?, 1, ?);", + (file_id, domain, relative_path, b""), + ) + conn.commit() + # MVT opens backup databases with immutable=1, which ignores any -wal file, + # so the new row has to be checkpointed into Manifest.db itself. + conn.execute("PRAGMA journal_mode=DELETE;") + conn.close() + + def delete_tmp_db_files(file_path): """ Remove Sqlite temporary files that appear on some platforms