Compare commits

..
Author SHA1 Message Date
Janik Besendorf 895307e242 Merge main and resolve upstream integration conflicts 2026-10-11 14:27:41 +02:00
Janik Besendorf e5a9a1f919 Normalize browser history ZIP member paths 2026-10-11 09:49:42 +02:00
besendorf b3aa443819 Merge pull request #970 from WingManOO7/fix/settings-leap-day
Keep settings history timestamps on 29 February
2026-10-09 12:52:44 +02:00
besendorf aac41ec8c2 Test settings leap-day resolution across a skipped century
Ensure a February 29 history entry in a January 2104 dump resolves to 2096, since 2100 is not a leap year.
2026-10-09 03:49:54 -07:00
besendorf 1e05f438d7 Include the eighth prior year when resolving settings leap days
Adjust the year range for leap year calculation to account for century years.
2026-10-09 03:48:37 -07:00
Muhammad Mohid Shafiq 10f739d669 Keep settings history timestamps on 29 February
dumpsys prints the settings change history without a year, so
_resolve_timestamp() parsed the value with strptime("%m-%d ...") and only
then replaced the year. strptime() parses a value without a year in 1900,
which has no 29 February, so every change made on a leap day raised
ValueError and lost its timestamp, dropping it from the timeline and from
the alert of a dangerous setting.

Parse the value together with a year instead, starting from the year the
section was dumped and going back until the date exists and is not later
than the dump. Eight years always reach a leap year.

This also removes the DeprecationWarning Python 3.13 raises for parsing a
day of month without a year (python/cpython#70647).
2026-10-08 19:35:29 +05:00
besendorf 94e9cd3712 Merge pull request #968 from va-resident/fix/adb-state-closing-brace
Fix spurious ADB state parse error on devices without stored keys
2026-10-07 17:27:15 +02:00
besendorf 300cc26112 Merge branch 'main' into fix/adb-state-closing-brace 2026-10-07 17:25:29 +02:00
besendorf 3c12e1a019 Merge pull request #925 from mvt-project/docs/recommend-pymobiledevice3
Recommend pymobiledevice3 for iOS acquisition
2026-10-07 16:12:55 +02:00
va@resident 3a462e1e2c Fix spurious ADB state parse error on devices without stored keys
Since #957 the parsed body keeps the closing brace of debugging_manager
and the blank lines before it. The brace popped two levels, and a blank
line after system_keys raised IndexError. Don't pop when the
indentation drops on a closing brace, and skip blank lines outside
multiline values.

Fixes #967.
2026-10-06 15:29:41 +05:00
Janik Besendorf c5d7f5a1d0 Parse Android browser history databases 2026-08-17 14:12:50 +02:00
14 changed files with 932 additions and 10 deletions

No files matched your search

-1
View File
@@ -31,4 +31,3 @@ clean:
dist:
$(UV) build
+21
View File
@@ -62,6 +62,27 @@ AndroidQF will prompt the user to download, decrypt and collect device intrusion
For cases where intrusion logs were collected outside of an AndroidQF acquisition, the standalone `mvt-android check-intrusion-logs` command can analyse them directly. See [Check Android Intrusion Logs](intrusion_logs.md) for details, and the [feature announcment from Amnesty International's Security Lab](https://securitylab.amnesty.org/latest/2026/05/android-intrusion-logging-as-a-new-source-of-data-for-consensual-forensic-analysis/) for background on the data source.
## Browser history
AndroidQF can optionally collect browser `History` databases when a device
already has working root access. When its acquisition contains the
`browser_history/manifest.json` file, `mvt-android check-androidqf` parses every
listed database, including any collected SQLite WAL and SHM sidecars. Browser
visits are added to the module results, timeline, URL output, and indicator
checks.
MVT can also inspect the supported database locations in a full Android
filesystem dump, or inspect one explicitly supplied Chromium `History` file:
```bash
mvt-android check-fs --output /path/to/results /path/to/filesystem-dump
mvt-android check-fs --output /path/to/results /path/to/History
```
The built-in locations cover Chrome, Brave, Microsoft Edge, and Samsung
Internet. Other Chromium-compatible databases can be supplied explicitly as a
file without assigning an unverified browser identity.
## Android Debug Bridge analysis removed
The ability to analyze Android devices directly over ADB has been removed from MVT. Direct extraction of data from ADB was error-prone and frequently resulted in inconsistent data collection between ADB and AndroidQF acquisitions. Use AndroidQF for device acquisition and `mvt-android check-androidqf` for analysis.
@@ -0,0 +1,162 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 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 sqlite3
import shutil
import tempfile
from pathlib import Path
from typing import Any
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.common.utils import convert_chrometime_to_datetime, convert_datetime_to_iso
from .artifact import AndroidArtifact
class BrowserHistoryArtifact(AndroidArtifact):
"""Shared Chromium History database parsing and result handling."""
def _parse_browser_history(
self,
connection: sqlite3.Connection,
*,
browser: str,
package: str,
profile: str,
source_path: str,
) -> None:
cursor = connection.cursor()
try:
cursor.execute(
"""
SELECT
urls.id,
urls.url,
urls.title,
urls.visit_count,
urls.typed_count,
visits.id,
visits.visit_time,
visits.from_visit,
visits.transition
FROM urls
JOIN visits ON visits.url = urls.id
ORDER BY visits.visit_time;
"""
)
for row in cursor:
timestamp = int(row[6])
self.results.append(
{
"id": row[0],
"url": row[1],
"title": row[2],
"visit_count": row[3],
"typed_count": row[4],
"visit_id": row[5],
"timestamp": timestamp,
"isodate": convert_datetime_to_iso(
convert_chrometime_to_datetime(timestamp)
),
"redirect_source": row[7],
"transition": row[8],
"browser": browser,
"package": package,
"profile": profile,
"source_path": source_path,
}
)
finally:
cursor.close()
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
return {
"timestamp": record["isodate"],
"module": self.__class__.__name__,
"event": "browser_history",
"data": (
f"{record['browser']} visit to {record['url']} "
f"(visit ID: {record['visit_id']}, profile: {record['profile']})"
),
}
def check_indicators(self) -> None:
if not self.indicators:
return
for result, match in zip(
self.results,
self.indicators.check_url_batches(
[[result["url"]] for result in self.results]
),
):
if match:
self.alertstore.critical(
match.message, "", result, matched_indicator=match.ioc
)
def collect_url_results(self) -> None:
for result in self.results:
self.add_url_result(result["url"], result.get("isodate"), "browser_history")
class TemporarySQLiteConnection(sqlite3.Connection):
temporary_directory: tempfile.TemporaryDirectory | None = None
def close(self) -> None:
try:
super().close()
finally:
if self.temporary_directory:
self.temporary_directory.cleanup()
self.temporary_directory = None
def open_browser_history_database(database_path: Path) -> sqlite3.Connection:
"""Open a staged History database without modifying forensic evidence."""
database_uri = database_path.resolve().as_uri()
if not Path(f"{database_path}-wal").is_file():
return sqlite3.connect(f"{database_uri}?mode=ro&immutable=1", uri=True)
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_")
temporary_path = Path(temporary_directory.name) / database_path.name
shutil.copy2(database_path, temporary_path)
for suffix in ("-wal", "-shm"):
sidecar = Path(f"{database_path}{suffix}")
if sidecar.is_file():
shutil.copy2(sidecar, Path(f"{temporary_path}{suffix}"))
try:
connection = sqlite3.connect(
f"{temporary_path.resolve().as_uri()}?mode=ro",
uri=True,
factory=TemporarySQLiteConnection,
)
except Exception:
temporary_directory.cleanup()
raise
connection.temporary_directory = temporary_directory
return connection
def validate_manifest_database(database: Any) -> dict[str, Any]:
if not isinstance(database, dict):
raise ValueError("database entry is not an object")
required = ("browser", "package", "profile", "device_path", "archive_path")
for field in required:
if not isinstance(database.get(field), str) or not database[field]:
raise ValueError(f"database entry has invalid {field}")
archive_path = database["archive_path"]
path = Path(archive_path)
if (
"\\" in archive_path
or path.is_absolute()
or ".." in path.parts
or path.parts[:1] != ("browser_history",)
):
raise ValueError(f"unsafe browser history archive path: {archive_path}")
return database
+9 -1
View File
@@ -33,7 +33,12 @@ class DumpsysADBArtifact(AndroidArtifact):
line = line.removesuffix(b"\r")
# Track the level of indentation
indent = len(line) - len(line.lstrip())
if indent < cur_indent:
# A closing brace is dedented from the block it closes, and the
# brace handler below pops that block. Popping on the dedent as
# well would close two levels for one brace. A blank line has no
# indentation to compare: it ends a multiline value (handled
# below) and is otherwise skipped.
if indent < cur_indent and line.strip() not in (b"", b"}"):
# If the current line is less indented than the previous one, back out
if len(stack) <= 1:
# Dedenting below the outermost level means this is not the
@@ -72,6 +77,9 @@ class DumpsysADBArtifact(AndroidArtifact):
stack.pop()
current_dict = stack[-1]
if not line.strip():
continue
if key == "}":
if len(stack) <= 1:
self.log.error(
+14 -8
View File
@@ -255,19 +255,25 @@ class Settings(AndroidArtifact):
dumpsys prints the change history without a year, so it is resolved
against the time the section was dumped: the most recent matching date
at or before that time.
The year is parsed together with the value, because strptime() would
otherwise parse it in 1900, which has no 29 February. The most recent
matching leap day can be eight years earlier when a century year is
not a leap year and the current year's leap day is still in the future.
"""
if section_end is None:
return None
try:
partial = datetime.strptime(value, "%m-%d %H:%M:%S.%f")
timestamp = partial.replace(year=section_end.year)
if timestamp > section_end:
timestamp = partial.replace(year=section_end.year - 1)
except ValueError:
return None
for year in range(section_end.year, section_end.year - 9, -1):
try:
timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f")
except ValueError:
continue
return convert_datetime_to_iso(timestamp)
if timestamp <= section_end:
return convert_datetime_to_iso(timestamp)
return None
def _parse_history(
self, line: str, section_end: Optional[datetime]
+54
View File
@@ -21,6 +21,7 @@ from mvt.common.help import (
HELP_MSG_CHECK_ANDROID_BACKUP,
HELP_MSG_CHECK_ANDROIDQF,
HELP_MSG_CHECK_BUGREPORT,
HELP_MSG_CHECK_FS,
HELP_MSG_CHECK_IOCS,
HELP_MSG_CHECK_INTRUSION_LOGS,
HELP_MSG_DELAY_CHECKS,
@@ -361,6 +362,59 @@ def check_androidqf(
cmd.show_support_message()
# ==============================================================================
# Command: check-fs
# ==============================================================================
@cli.command("check-fs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_CHECK_FS)
@click.option(
"--iocs",
"-i",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_IOC,
)
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("DUMP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_fs(
ctx, iocs, output, list_modules, module, load_module, hashes, verbose, dump_path
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
from .cmd_check_fs import CmdAndroidCheckFS
cmd = CmdAndroidCheckFS(
target_path=dump_path,
results_path=output,
ioc_files=iocs,
module_name=module,
hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking Android filesystem located at: %s", dump_path)
cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message()
# ==============================================================================
# Command: check-intrusion-logs
# ==============================================================================
+47
View File
@@ -0,0 +1,47 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 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
from typing import Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.fs import FS_MODULES
log = logging.getLogger(__name__)
class CmdAndroidCheckFS(Command):
def __init__(
self,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
ioc_files: Optional[list] = None,
iocs: Optional[Indicators] = None,
module_name: Optional[str] = None,
module_options: Optional[dict] = None,
hashes: bool = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
results_path=results_path,
ioc_files=ioc_files,
iocs=iocs,
module_name=module_name,
module_options=module_options,
hashes=hashes,
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-fs"
self.modules = FS_MODULES
@@ -11,6 +11,7 @@ from .aqf_log_timestamps import AQFLogTimestamps
from .aqf_packages import AQFPackages
from .aqf_processes import AQFProcesses
from .aqf_settings import AQFSettings
from .browser_history import BrowserHistory
from .mounts import Mounts
from .root_binaries import RootBinaries
@@ -23,4 +24,5 @@ ANDROIDQF_MODULES: list[type[MVTModule]] = [
AQFLogTimestamps,
RootBinaries,
Mounts,
BrowserHistory,
]
@@ -0,0 +1,115 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 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 json
import sqlite3
import tempfile
from pathlib import Path, PurePosixPath
from mvt.android.artifacts.browser_history import (
BrowserHistoryArtifact,
open_browser_history_database,
validate_manifest_database,
)
from .base import AndroidQFModule
class BrowserHistory(BrowserHistoryArtifact, AndroidQFModule):
"""Extract browser visits collected by AndroidQF."""
supported_commands = (("android", "check-androidqf"),)
def _find_manifest(self) -> str | None:
manifests = [
file_path
for file_path in self.files
if file_path.replace("\\", "/").endswith("browser_history/manifest.json")
]
if not manifests:
return None
if len(manifests) > 1:
self.log.warning(
"Found multiple browser history manifests; using %s", manifests[0]
)
return manifests[0]
def _stage_database(
self, archive_path: str, prefix: str, temporary_path: Path
) -> Path:
available_files = {
str(PurePosixPath(file_path.replace("\\", "/"))): file_path
for file_path in self.files
}
normalized_path = str(PurePosixPath(prefix, archive_path))
source_path = available_files.get(normalized_path)
if not source_path:
raise FileNotFoundError(archive_path)
staged_path = temporary_path / "History"
staged_path.write_bytes(self._get_file_content(source_path))
for suffix in ("-wal", "-shm"):
sidecar = available_files.get(normalized_path + suffix)
if sidecar:
Path(f"{staged_path}{suffix}").write_bytes(
self._get_file_content(sidecar)
)
return staged_path
def run(self) -> None:
manifest_path = self._find_manifest()
if not manifest_path:
self.log.info("No AndroidQF browser history manifest found")
return
try:
manifest = json.loads(self._get_file_content(manifest_path))
except (json.JSONDecodeError, OSError, TypeError, UnicodeDecodeError) as exc:
self.log.error("Unable to read browser history manifest: %s", exc)
return
if not isinstance(manifest, dict) or manifest.get("schema_version") != 1:
self.log.error("Unsupported AndroidQF browser history manifest")
return
databases = manifest.get("databases", [])
if not isinstance(databases, list):
self.log.error("Invalid AndroidQF browser history database list")
return
normalized_manifest = manifest_path.replace("\\", "/")
marker = "browser_history/manifest.json"
prefix = normalized_manifest[: -len(marker)].rstrip("/")
for raw_database in databases:
try:
database = validate_manifest_database(raw_database)
with tempfile.TemporaryDirectory(prefix="mvt_browser_history_") as temp:
staged_path = self._stage_database(
database["archive_path"], prefix, Path(temp)
)
connection = open_browser_history_database(staged_path)
try:
self._parse_browser_history(
connection,
browser=database["browser"],
package=database["package"],
profile=database["profile"],
source_path=database["device_path"],
)
finally:
connection.close()
except (
FileNotFoundError,
OSError,
OverflowError,
sqlite3.Error,
TypeError,
ValueError,
) as exc:
self.log.error("Unable to parse browser history database: %s", exc)
self.log.info(
"Extracted a total of %d browser history items", len(self.results)
)
+10
View File
@@ -0,0 +1,10 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 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/
from mvt.common.module import MVTModule
from .browser_history import BrowserHistory
FS_MODULES: list[type[MVTModule]] = [BrowserHistory]
@@ -0,0 +1,115 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 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 sqlite3
from pathlib import Path
from typing import Optional
from mvt.android.artifacts.browser_history import (
BrowserHistoryArtifact,
open_browser_history_database,
)
from mvt.common.module import MVTModule
from mvt.common.module_types import ModuleResults
# These locations are deliberately limited to paths backed by public parser
# fixtures or the historical MVT implementation.
BROWSER_HISTORY_PATHS = {
"data/data/com.android.chrome/app_chrome/Default/History": (
"Chrome",
"com.android.chrome",
"Default",
),
"data/data/com.brave.browser/app_chrome/Default/History": (
"Brave",
"com.brave.browser",
"Default",
),
"data/data/com.microsoft.emmx/app_chrome/Default/History": (
"Microsoft Edge",
"com.microsoft.emmx",
"Default",
),
"data/data/com.sec.android.app.sbrowser/app_sbrowser/Default/History": (
"Samsung Internet",
"com.sec.android.app.sbrowser",
"Default",
),
}
class BrowserHistory(BrowserHistoryArtifact, MVTModule):
"""Extract supported Chromium History databases from a filesystem dump."""
supported_commands = (("android", "check-fs"),)
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
def _database_paths(self) -> list[tuple[Path, str, str, str]]:
if not self.target_path:
return []
target = Path(self.target_path)
if target.is_file():
normalized_target = target.resolve().as_posix()
for relative_path, identity in BROWSER_HISTORY_PATHS.items():
if normalized_target.endswith(f"/{relative_path}"):
return [(target, *identity)]
return [(target, "Chromium", "unknown", "unknown")]
databases = []
for relative_path, identity in BROWSER_HISTORY_PATHS.items():
database_path = target / relative_path
if database_path.is_file():
databases.append((database_path, *identity))
return databases
def run(self) -> None:
for database_path, browser, package, profile in self._database_paths():
try:
connection = open_browser_history_database(database_path)
try:
self._parse_browser_history(
connection,
browser=browser,
package=package,
profile=profile,
source_path=str(database_path),
)
finally:
connection.close()
except (
OSError,
OverflowError,
sqlite3.Error,
TypeError,
ValueError,
) as exc:
self.log.error(
"Unable to parse browser history database %s: %s",
database_path,
exc,
)
self.log.info(
"Extracted a total of %d browser history items", len(self.results)
)
@@ -209,6 +209,85 @@ class TestDumpsysADBArtifact:
assert len(da_adb.results) == 1
assert da_adb.results[0]["user_keys"][0]["user"] == user.decode()
def test_state_without_keys_is_parsed_without_error(self, caplog):
# Shape printed by every device with no stored ADB keys (Samsung
# SM-A525F, Android 12). The block's closing brace is dedented from
# the last entry and must close exactly one level.
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=false\n"
b" }\n"
b"}\n"
b"--------- 0.015s was the duration of dumpsys adb\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["connected_to_adb"] is False
assert not [r for r in caplog.records if r.levelname == "ERROR"]
def test_nested_block_as_last_entry_is_parsed_without_error(self, caplog):
# Shape printed by Pixel devices with no stored ADB keys.
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=false\n"
b" adb_wifi={\n"
b" enabled=false\n"
b" tls_port=0\n"
b" }\n"
b" }\n"
b"}\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["connected_to_adb"] is False
assert da_adb.results[0]["adb_wifi"]["tls_port"] == b"0"
assert not [r for r in caplog.records if r.levelname == "ERROR"]
def test_entry_after_nested_block_stays_in_debugging_manager(self):
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" adb_wifi={\n"
b" enabled=false\n"
b" }\n"
b" connected_to_adb=true\n"
b" }\n"
b"}\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["connected_to_adb"] is True
def test_blank_line_after_single_line_value_does_not_raise(self):
# `system_keys` is the content of /adb_keys and ends with a newline,
# so a blank line follows it. It is not a multiline field, so the
# blank line is not a multiline terminator either.
da_adb = DumpsysADBArtifact()
da_adb.parse(
b"ADB MANAGER STATE (dumpsys adb):\n"
b"{\n"
b" debugging_manager={\n"
b" connected_to_adb=true\n"
b" user_keys=QUJDRA== host@example\n"
b"\n"
b" system_keys=RUZHSA== system@example\n"
b"\n"
b" }\n"
b"}\n"
)
assert len(da_adb.results) == 1
assert da_adb.results[0]["user_keys"][0]["user"] == "host@example"
assert da_adb.results[0]["system_keys"] == "RUZHSA== system@example"
def test_unbalanced_state_is_reported_rather_than_raising(self):
da_adb = DumpsysADBArtifact()
da_adb.parse(
+43
View File
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from typing import Optional
from mvt.android.artifacts.settings import Settings
from ..utils import get_artifact
@@ -21,6 +23,21 @@ def find(settings: Settings, name: str) -> list:
return [result for result in settings.results if result["name"] == name]
def resolve_history_time(time: str, section_end: str) -> Optional[str]:
settings = Settings()
settings.parse(
"SECURE SETTINGS (user 0)\n"
"_id:240 name:accessibility_enabled pkg:android value:1\n"
"\tHistory (accessibility_enabled)\n"
f"\t\ttime:{time} mode:update oldValue:0 newValue:1 "
"package:com.example.helper\n"
"\n"
"--------- 0.019s was the duration of dumpsys settings, "
f"ending at: {section_end}\n"
)
return settings.results[0]["history"][0]["timestamp"]
class TestSettingsArtifact:
def test_parsing(self):
settings = parse_bugreport_settings()
@@ -159,6 +176,32 @@ class TestSettingsArtifact:
}
]
def test_leap_day_history_keeps_its_timestamp(self):
assert (
resolve_history_time("02-29 22:41:07.980", "2024-03-05 23:14:28")
== "2024-02-29 22:41:07.980000"
)
def test_leap_day_history_resolved_to_the_last_leap_year(self):
# The most recent 29 February at or before the dump can be several
# years back when the dump was taken in a year without one.
assert (
resolve_history_time("02-29 22:41:07.980", "2025-03-01 10:00:00")
== "2024-02-29 22:41:07.980000"
)
assert (
resolve_history_time("02-29 22:41:07.980", "2024-01-10 10:00:00")
== "2020-02-29 22:41:07.980000"
)
def test_leap_day_history_before_a_leap_day_after_a_skipped_century(self):
# 2100 is not a leap year, so before 29 February 2104 the most
# recent matching date is eight years earlier, in 2096.
assert (
resolve_history_time("02-29 22:41:07.980", "2104-01-10 10:00:00")
== "2096-02-29 22:41:07.980000"
)
def test_dangerous_setting_is_detected_with_the_changing_package(self):
settings = parse_bugreport_settings()
settings.check_indicators()
+261
View File
@@ -0,0 +1,261 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 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 json
import sqlite3
import zipfile
from pathlib import Path
import pytest
from mvt.android.modules.androidqf.browser_history import (
BrowserHistory as AndroidQFBrowserHistory,
)
from mvt.android.modules.fs.browser_history import BrowserHistory as FSBrowserHistory
from mvt.android.cmd_check_fs import CmdAndroidCheckFS
CHROME_TIME = 13_348_540_800_000_000
URL = "https://example.org/path"
def create_history_database(path: Path, *, url: str = URL) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(path) as connection:
connection.executescript(
"""
CREATE TABLE urls (
id INTEGER PRIMARY KEY,
url TEXT,
title TEXT,
visit_count INTEGER,
typed_count INTEGER
);
CREATE TABLE visits (
id INTEGER PRIMARY KEY,
url INTEGER,
visit_time INTEGER,
from_visit INTEGER,
transition INTEGER
);
"""
)
connection.execute("INSERT INTO urls VALUES (1, ?, 'Example', 1, 0)", (url,))
connection.execute(
"INSERT INTO visits VALUES (7, 1, ?, 0, 805306368)",
(CHROME_TIME,),
)
def manifest(database_paths: list[tuple[str, str]]) -> dict:
return {
"schema_version": 1,
"status": "collected",
"databases": [
{
"browser": "Chrome",
"package": package,
"profile": "Default",
"device_path": f"/data/data/{package}/app_chrome/Default/History",
"archive_path": archive_path,
"sidecars": [],
}
for package, archive_path in database_paths
],
}
@pytest.mark.parametrize("use_zip", [False, True])
def test_androidqf_browser_history_directory_and_zip(tmp_path, use_zip):
source_database = tmp_path / "source" / "History"
create_history_database(source_database)
archive_path = "browser_history/com.android.chrome/Default/History"
manifest_data = manifest([("com.android.chrome", archive_path)])
module = AndroidQFBrowserHistory()
if use_zip:
acquisition_path = tmp_path / "acquisition.zip"
with zipfile.ZipFile(acquisition_path, "w") as archive:
archive.write(source_database, archive_path)
archive.writestr("browser_history/manifest.json", json.dumps(manifest_data))
with zipfile.ZipFile(acquisition_path) as archive:
module.from_zip(archive, archive.namelist())
module.run()
else:
acquisition = tmp_path / "acquisition"
database_path = acquisition / archive_path
database_path.parent.mkdir(parents=True)
database_path.write_bytes(source_database.read_bytes())
manifest_path = acquisition / "browser_history" / "manifest.json"
manifest_path.write_text(json.dumps(manifest_data))
files = [
path.relative_to(tmp_path).as_posix()
for path in acquisition.rglob("*")
if path.is_file()
]
module.from_dir(str(tmp_path), files)
module.run()
assert len(module.results) == 1
assert module.results[0]["url"] == URL
assert module.results[0]["browser"] == "Chrome"
assert module.results[0]["source_path"].endswith("/History")
module.collect_url_results()
module.to_timeline()
assert module.url_results[0]["url"] == URL
assert module.timeline[0]["event"] == "browser_history"
@pytest.mark.parametrize("prefix", ["", "./", "acquisition/", "./acquisition/"])
@pytest.mark.parametrize("separator", ["/", "\\"])
def test_androidqf_zip_paths_preserve_wal_visits(tmp_path, prefix, separator):
database = tmp_path / "History"
create_history_database(database)
connection = sqlite3.connect(database)
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
connection.execute(
"INSERT INTO urls VALUES (2, ?, 'WAL', 1, 0)", (URL + "/wal",)
)
connection.execute(
"INSERT INTO visits VALUES (8, 2, ?, 0, 805306368)", (CHROME_TIME,)
)
connection.commit()
archive_path = "browser_history/com.android.chrome/Default/History"
acquisition = tmp_path / "acquisition.zip"
try:
with zipfile.ZipFile(acquisition, "w") as archive:
for suffix in ("", "-wal", "-shm"):
archive.writestr(
(prefix + archive_path + suffix).replace("/", separator),
Path(f"{database}{suffix}").read_bytes(),
)
archive.writestr(
(prefix + "browser_history/manifest.json").replace("/", separator),
json.dumps(manifest([("com.android.chrome", archive_path)])),
)
module = AndroidQFBrowserHistory()
with zipfile.ZipFile(acquisition) as archive:
module.from_zip(archive, archive.namelist())
module.run()
finally:
connection.close()
assert {result["url"] for result in module.results} == {URL, URL + "/wal"}
def test_androidqf_browser_history_isolates_malformed_database(tmp_path, caplog):
acquisition = tmp_path / "acquisition"
good_archive_path = "browser_history/com.android.chrome/Default/History"
bad_archive_path = "browser_history/com.brave.browser/Default/History"
create_history_database(acquisition / good_archive_path)
bad_path = acquisition / bad_archive_path
bad_path.parent.mkdir(parents=True)
bad_path.write_bytes(b"not sqlite")
manifest_path = acquisition / "browser_history" / "manifest.json"
manifest_path.write_text(
json.dumps(
manifest(
[
("com.brave.browser", bad_archive_path),
("com.android.chrome", good_archive_path),
]
)
)
)
module = AndroidQFBrowserHistory()
files = [
path.relative_to(tmp_path).as_posix()
for path in acquisition.rglob("*")
if path.is_file()
]
module.from_dir(str(tmp_path), files)
module.run()
assert [result["url"] for result in module.results] == [URL]
assert "Unable to parse browser history database" in caplog.text
def test_androidqf_browser_history_rejects_unsafe_manifest_path(tmp_path, caplog):
acquisition = tmp_path / "acquisition"
manifest_path = acquisition / "browser_history" / "manifest.json"
manifest_path.parent.mkdir(parents=True)
manifest_path.write_text(
json.dumps(manifest([("com.android.chrome", "browser_history/../secret")]))
)
module = AndroidQFBrowserHistory()
module.from_dir(str(tmp_path), [manifest_path.relative_to(tmp_path).as_posix()])
module.run()
assert module.results == []
assert "unsafe browser history archive path" in caplog.text
def test_filesystem_browser_history_reads_wal_only_visit(tmp_path):
database_path = tmp_path / "History"
connection = sqlite3.connect(database_path)
connection.execute("PRAGMA journal_mode=WAL")
connection.executescript(
"""
CREATE TABLE urls (
id INTEGER PRIMARY KEY,
url TEXT,
title TEXT,
visit_count INTEGER,
typed_count INTEGER
);
CREATE TABLE visits (
id INTEGER PRIMARY KEY,
url INTEGER,
visit_time INTEGER,
from_visit INTEGER,
transition INTEGER
);
"""
)
connection.commit()
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
connection.execute("INSERT INTO urls VALUES (1, ?, 'WAL', 1, 0)", (URL,))
connection.execute(
"INSERT INTO visits VALUES (8, 1, ?, 0, 805306368)", (CHROME_TIME,)
)
connection.commit()
try:
assert Path(f"{database_path}-wal").stat().st_size > 0
module = FSBrowserHistory(target_path=str(database_path))
module.run()
finally:
connection.close()
assert [result["url"] for result in module.results] == [URL]
assert module.results[0]["browser"] == "Chromium"
def test_android_check_fs_finds_all_supported_browser_paths(tmp_path):
for index, relative_path in enumerate(
(
"data/data/com.android.chrome/app_chrome/Default/History",
"data/data/com.brave.browser/app_chrome/Default/History",
"data/data/com.microsoft.emmx/app_chrome/Default/History",
"data/data/com.sec.android.app.sbrowser/app_sbrowser/Default/History",
)
):
create_history_database(
tmp_path / relative_path, url=f"https://example.org/{index}"
)
command = CmdAndroidCheckFS(target_path=str(tmp_path), module_name="BrowserHistory")
command.run()
assert len(command.executed) == 1
assert {result["package"] for result in command.executed[0].results} == {
"com.android.chrome",
"com.brave.browser",
"com.microsoft.emmx",
"com.sec.android.app.sbrowser",
}
assert len(command.url_results) == 4
assert len(command.timeline) == 4