Merge branch 'main' into fix/alert-timestamps

This commit is contained in:
besendorf
2026-09-08 19:53:30 +01:00
committed by GitHub
50 changed files with 1999 additions and 299 deletions
+170
View File
@@ -0,0 +1,170 @@
# 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/
from mvt.android.artifacts.settings import Settings
from ..utils import get_artifact
def parse_bugreport_settings() -> Settings:
settings = Settings()
with open(get_artifact("android_data/bugreport/dumpstate.txt")) as handle:
data = handle.read()
settings.parse(settings.extract_dumpsys_section(data, "DUMP OF SERVICE settings:"))
return settings
def find(settings: Settings, name: str) -> list:
return [result for result in settings.results if result["name"] == name]
class TestSettingsArtifact:
def test_parsing(self):
settings = parse_bugreport_settings()
assert len(settings.results) == 12
assert {result["namespace"] for result in settings.results} == {
"config",
"global",
"secure",
}
assert settings.results[0] == {
"namespace": "config",
"user": "0",
"_id": "682",
"name": "namespace_one/blocked_components",
"pkg": "com.example.services",
"value": (
"com.android.settings,com.android.vending,\n"
"com.example.dialer,\n"
"com.example.camera"
),
"default": (
"com.android.settings,\n"
" com.android.vending,\n"
" com.example.dialer"
),
"defaultSystemSet": "false",
"history": [],
}
def test_multiline_values_are_kept_whole(self):
settings = parse_bugreport_settings()
assert find(settings, "namespace_one/allowed_packages")[0]["value"] == (
"com.example.messaging,\ncom.example.chat"
)
assert find(settings, "widget_instance_data")[0]["value"] == (
'{\n "version": 1,\n "data": [\n {\n "number": 10000,\n'
' "package_name": "com.example.widget"\n }\n ]\n}'
)
def test_trailing_default_is_not_part_of_the_value(self):
settings = parse_bugreport_settings()
record = find(settings, "namespace_one/streaming_blocked_components")[0]
assert record["value"] == "com.example.dialer,com.example.camera"
assert record["default"] == "com.android.settings,\n com.android.vending"
def test_trailing_metadata_is_not_part_of_the_value(self):
settings = parse_bugreport_settings()
record = find(settings, "lock_screen_show_notifications")[0]
assert record["value"] == "1"
assert record["defaultSystemSet"] == "true"
assert record["isValuePreservedInRestore"] == "true"
# Without a default, the tag or the restore token follows the value.
record = find(settings, "accessibility_enabled")[1]
assert record["value"] == "0"
assert record["tag"] == "null"
assert "default" not in record
record = find(settings, "send_action_app_error")[0]
assert record["value"] == "1"
assert record["isValuePreservedInRestore"] == "false"
def test_dumps_after_the_last_block_are_not_part_of_the_last_row(self):
settings = parse_bugreport_settings()
assert settings.results[-1] == {
"namespace": "secure",
"user": "10",
"_id": "311",
"name": "accessibility_enabled",
"pkg": "android",
"value": "0",
"tag": "null",
"history": [],
}
def test_repeated_names_are_kept_as_separate_records(self):
settings = parse_bugreport_settings()
widgets = find(settings, "widget_instance_data")
assert [record["_id"] for record in widgets] == ["771", "41654"]
accessibility = find(settings, "accessibility_enabled")
assert [(record["user"], record["value"]) for record in accessibility] == [
("0", "1"),
("10", "0"),
]
def test_setting_without_recording_package(self):
settings = parse_bugreport_settings()
record = find(settings, "hidden_api_blacklist_exemptions")[0]
assert "pkg" not in record
assert record["value"] == "{null}"
def test_history_timestamps_resolved_against_section_end(self):
settings = parse_bugreport_settings()
# The section was dumped on 2022-03-29, so an 11-02 entry belongs to
# the previous year and an 03-14 entry to the same year.
assert find(settings, "development_settings_enabled")[0]["history"] == [
{
"timestamp": "2021-11-02 11:21:22.212000",
"oldValue": "null",
"newValue": "1",
"pkg": "com.android.settings",
},
{
"timestamp": "2022-03-14 09:02:11.100000",
"oldValue": "1",
"newValue": "0",
"pkg": "com.example.updater",
},
]
def test_history_without_a_section_end_has_no_timestamp(self):
settings = Settings()
settings.parse(
"SECURE SETTINGS (user 0)\n"
"_id:240 name:accessibility_enabled pkg:android value:1\n"
"\tHistory (accessibility_enabled)\n"
"\t\ttime:03-28 22:41:07.980 mode:update oldValue:0 newValue:1 "
"package:com.example.helper\n"
)
assert settings.results[0]["history"] == [
{
"timestamp": None,
"oldValue": "0",
"newValue": "1",
"pkg": "com.example.helper",
}
]
def test_dangerous_setting_is_detected_with_the_changing_package(self):
settings = parse_bugreport_settings()
settings.check_indicators()
assert len(settings.alertstore.alerts) == 1
alert = settings.alertstore.alerts[0]
assert "accessibility_enabled = 1" in alert.message
assert alert.event_time == "2022-03-28 22:41:07.980000"
assert alert.event["history"][0]["pkg"] == "com.example.helper"
+8 -17
View File
@@ -6,27 +6,12 @@
from pathlib import Path
from mvt.android.modules.androidqf.aqf_settings import AQFSettings
from mvt.android.artifacts.settings import Settings
from mvt.common.module import run_module
from ..utils import get_android_androidqf, list_files
class TestSettingsModule:
def test_bugreport_settings_format(self):
settings = Settings()
settings.parse(
"GLOBAL SETTINGS (user 0)\n"
"_id:1 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true\n"
"SECURE SETTINGS (user 10)\n"
"_id:2 name:accessibility_enabled pkg:android value:1\n"
)
assert settings.results == {
"global:user_0": {"adb_wifi_enabled": "0"},
"secure:user_10": {"accessibility_enabled": "1"},
}
def test_parsing(self):
data_path = get_android_androidqf()
m = AQFSettings(target_path=data_path)
@@ -34,7 +19,13 @@ class TestSettingsModule:
parent_path = Path(data_path).absolute().parent.as_posix()
m.from_dir(parent_path, files)
run_module(m)
assert len(m.results) == 1
assert "random" in m.results.keys()
assert len(m.results) == 9
assert {result["namespace"] for result in m.results} == {"random"}
assert m.results[0] == {
"namespace": "random",
"user": None,
"name": "samsung_errorlog_agree",
"value": "0",
}
assert len(m.alertstore.alerts) == 1
assert "samsung_errorlog_agree" in m.alertstore.alerts[0].message
+20
View File
@@ -10,6 +10,7 @@ from mvt.android.modules.bugreport.dumpsys_appops import DumpsysAppops
from mvt.android.modules.bugreport.dumpsys_getprop import DumpsysGetProp
from mvt.android.modules.bugreport.dumpsys_packages import DumpsysPackages
from mvt.android.modules.bugreport.dumpsys_receivers import DumpsysReceivers
from mvt.android.modules.bugreport.settings import Settings
from mvt.android.modules.bugreport.tombstones import Tombstones
from mvt.common.module import run_module
@@ -93,6 +94,25 @@ class TestBugreportAnalysis:
assert alert.event == malicious_receiver
assert alert.matched_indicator.value == "com.android.services"
def test_settings_module(self):
m = self.launch_bug_report_module(Settings)
assert len(m.results) == 12
assert len(m.alertstore.alerts) == 1
assert "accessibility_enabled = 1" in m.alertstore.alerts[0].message
assert len(m.timeline) == 3
change = [
entry
for entry in m.timeline
if entry["timestamp"] == "2022-03-28 22:41:07.980000"
][0]
assert change["event"] == "settings_change"
assert change["data"] == (
'secure setting "accessibility_enabled" changed from "0" to "1" '
"by com.example.helper"
)
def test_tombstones_modules(self):
m = self.launch_bug_report_module(Tombstones)
assert len(m.results) == 2
@@ -264,5 +264,53 @@ ChangeId(143539591; name=SELINUX_LATEST_CHANGES; disabled)
ChangeId(247079863; name=DISALLOW_INVALID_GROUP_REFERENCE; enableSinceTargetSdk=34)
ChangeId(174227820; name=FORCE_DISABLE_HEVC_SUPPORT; disabled)
ChangeId(168419799; name=DOWNSCALED; disabled; packageOverrides={com.google.android.apps.tachyon=false, org.torproject.torbrowser=false}; rawOverrides={org.torproject.torbrowser=false, org.article19.circulo.next=false}; overridable)
-------------------------------------------------------------------------------
DUMP OF SERVICE settings:
CONFIG SETTINGS (user 0)
_id:682 name:namespace_one/blocked_components pkg:com.example.services value:com.android.settings,com.android.vending,
com.example.dialer,
com.example.camera default:com.android.settings,
com.android.vending,
com.example.dialer defaultSystemSet:false
_id:684 name:namespace_one/streaming_blocked_components pkg:com.example.services value:com.example.dialer,com.example.camera default:com.android.settings,
com.android.vending defaultSystemSet:false
_id:680 name:namespace_one/allowed_packages pkg:com.example.services value:com.example.messaging,
com.example.chat
GLOBAL SETTINGS (user 0)
_id:2070 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true
_id:778 name:hidden_api_blacklist_exemptions value:{null}
_id:9640 name:send_action_app_error pkg:android value:1 notPreservedInRestore
_id:9631 name:development_settings_enabled pkg:com.android.settings value:1 default:1 defaultSystemSet:true
History (development_settings_enabled)
time:11-02 11:21:22.212 mode:update oldValue:null newValue:1 package:com.android.settings
time:03-14 09:02:11.100 mode:update oldValue:1 newValue:0 package:com.example.updater
_id:771 name:widget_instance_data pkg:com.android.systemui value:{
"version": 1,
"data": [
{
"number": 10000,
"package_name": "com.example.widget"
}
]
} defaultSystemSet:true
_id:41654 name:widget_instance_data pkg:com.android.systemui value:{
"version": 3,
"data": []
} defaultSystemSet:true
SECURE SETTINGS (user 0)
_id:907 name:lock_screen_show_notifications pkg:com.android.settings value:1 default:1 defaultSystemSet:true isValuePreservedInRestore:true
_id:240 name:accessibility_enabled pkg:android value:1 default:0 defaultSystemSet:true
History (accessibility_enabled)
time:03-28 22:41:07.980 mode:update oldValue:0 newValue:1 package:com.example.helper
SECURE SETTINGS (user 10)
_id:311 name:accessibility_enabled pkg:android value:0 tag:null
GENERATION REGISTRY
Maximum number of backing stores:8
Number of backing stores:1
_Backing store for type:SETTINGS_SECURE user:10 size:1024 cachedEntries:1
--------- 0.019s was the duration of dumpsys settings, ending at: 2022-03-29 23:14:28
+85
View File
@@ -5,8 +5,13 @@
import json
import logging
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
@@ -197,6 +202,86 @@ class RecordingCommand(Command):
class TestCommand:
def test_listing_modules_does_not_load_indicators(self):
with patch("mvt.common.command.Indicators.load_indicators_files") as load:
cmd = RecordingCommand()
cmd.list_modules()
load.assert_not_called()
def test_output_folder_is_created_by_a_run_not_by_listing(self, tmp_path):
output_path = tmp_path / "out"
with patch("mvt.common.command.Indicators.load_indicators_files"):
cmd = RecordingCommand(results_path=str(output_path))
cmd.list_modules()
assert not output_path.exists()
cmd.run()
assert (output_path / "command.log").is_file()
def test_indicators_load_once_and_are_shared(self, indicator_file, monkeypatch):
from mvt.common.config import settings
monkeypatch.setattr(settings, "STIX2", "")
monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None)
original = Indicators.load_indicators_files
with patch.object(
Indicators, "load_indicators_files", autospec=True, side_effect=original
) as load:
cmd = RecordingCommand(ioc_files=[indicator_file])
load.assert_not_called()
indicators = cmd.iocs
assert indicators.total_ioc_count == 9
assert len(indicators.ioc_collections) == 1
assert cmd.iocs is indicators
child = RecordingCommand(iocs=indicators)
assert child.iocs is indicators
load.assert_called_once_with(indicators, [indicator_file])
@pytest.mark.parametrize("assign", [False, True])
def test_supplied_empty_indicators_are_not_loaded(self, assign):
indicators = Indicators(logging.getLogger(__name__))
with patch.object(Indicators, "load_indicators_files") as load:
cmd = RecordingCommand(iocs=None if assign else indicators)
if assign:
cmd.iocs = indicators
assert cmd.iocs is indicators
assert cmd.iocs.total_ioc_count == 0
load.assert_not_called()
@pytest.mark.parametrize("list_modules", [False, True])
def test_backup_cli_does_not_load_indicators_before_analysis(
self, tmp_path, list_modules
):
from mvt.ios.cli import check_backup
args = [str(tmp_path)]
if list_modules:
args.insert(0, "--list-modules")
with patch.object(Indicators, "load_indicators_files") as load:
result = CliRunner().invoke(check_backup, args)
assert result.exit_code == (0 if list_modules else 1)
load.assert_not_called()
def test_run_checks_synthetic_indicators(self, indicator_file, monkeypatch):
from mvt.common.config import settings
monkeypatch.setattr(settings, "STIX2", "")
monkeypatch.setattr(Indicators, "_load_downloaded_indicators", lambda self: None)
class MatchingModule(RecordingModule):
def run(self):
self.results = ["https://example.org/test"]
def check_indicators(self):
self.detected = [
url for url in self.results if self.indicators.check_domain(url)
]
cmd = RecordingCommand(ioc_files=[indicator_file])
cmd.modules = [MatchingModule]
cmd.run()
assert cmd.executed[0].detected == ["https://example.org/test"]
assert cmd.executed[0].indicators is cmd.iocs
def setup_method(self):
RecordingModule.run_order = []
+4 -1
View File
@@ -12,13 +12,16 @@ from mvt.ios.command_modules import IOS_CHECK_IOCS_MODULES
from mvt.ios.modules.backup import BACKUP_MODULES as IOS_BACKUP_MODULES
from mvt.ios.modules.fs import FS_MODULES
from mvt.ios.modules.mixed import MIXED_MODULES
from mvt.ios.modules.sysdiagnose import SYSDIAGNOSE_MODULES
def test_the_check_iocs_lists_are_the_families_of_their_platform():
# The CLI reads these same lists, so nothing composing one elsewhere can
# drift from what the command runs. This pins what the lists are composed
# of.
assert IOS_CHECK_IOCS_MODULES == IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES
assert IOS_CHECK_IOCS_MODULES == (
IOS_BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES
)
assert ANDROID_CHECK_IOCS_MODULES == (
ANDROID_BACKUP_MODULES
+ BUGREPORT_MODULES
+30
View File
@@ -0,0 +1,30 @@
# 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 os
import yaml
from mvt.common import config
from mvt.common.config import MVTSettings
def test_env_variables_are_not_persisted_to_config_file(tmp_path, monkeypatch):
config_path = tmp_path / "config.yaml"
monkeypatch.setattr(config, "MVT_CONFIG_FOLDER", str(tmp_path))
monkeypatch.setattr(config, "MVT_CONFIG_PATH", str(config_path))
monkeypatch.setenv("MVT_NETWORK_ACCESS_ALLOWED", "false")
monkeypatch.setenv("MVT_IOS_BACKUP_PASSWORD", "env-only-password")
settings = MVTSettings.initialise()
assert os.path.isfile(config_path)
saved = yaml.safe_load(config_path.read_text()) or {}
assert "NETWORK_ACCESS_ALLOWED" not in saved
assert "IOS_BACKUP_PASSWORD" not in saved
# The environment must still apply to the settings in use.
assert settings.NETWORK_ACCESS_ALLOWED is False
assert settings.IOS_BACKUP_PASSWORD == "env-only-password"
+20 -7
View File
@@ -3,20 +3,26 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import atexit
import logging
import os
import shutil
import tempfile
import pytest
from mvt.common.cli_plugins import (
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
MVT_CUSTOM_COMMANDS_ENV,
MVT_IOS_CUSTOM_COMMANDS_ENV,
)
from mvt.common.indicators import Indicators
from .artifacts.generate_stix import generate_test_stix_file
# The suite must neither read nor write the developer's own MVT settings,
# downloaded indicators or update-check state, and mvt.common.config saves
# the settings file as soon as it is imported. Both folders are redirected
# before any mvt module is imported, which is why this file imports none at
# the top; the subprocesses the tests start inherit the variables.
MVT_TEST_HOME = tempfile.mkdtemp(prefix="mvt-tests-")
atexit.register(shutil.rmtree, MVT_TEST_HOME, ignore_errors=True)
os.environ["MVT_CONFIG_FOLDER"] = os.path.join(MVT_TEST_HOME, "config")
os.environ["MVT_DATA_FOLDER"] = os.path.join(MVT_TEST_HOME, "data")
@pytest.fixture(scope="session", autouse=True)
def indicator_file(request, tmp_path_factory):
@@ -47,6 +53,8 @@ def indicators_factory(indicator_file):
android_property_names=[],
files_sha256=[],
):
from mvt.common.indicators import Indicators
ind = Indicators(log=logging.getLogger())
ind.parse_stix2(indicator_file)
@@ -77,6 +85,11 @@ def restore_cli_commands(monkeypatch):
"""
from mvt.android.cli import cli as android_cli
from mvt.cli import cli as neutral_cli
from mvt.common.cli_plugins import (
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
MVT_CUSTOM_COMMANDS_ENV,
MVT_IOS_CUSTOM_COMMANDS_ENV,
)
from mvt.ios.cli import cli as ios_cli
groups = (neutral_cli, ios_cli, android_cli)
+191
View File
@@ -0,0 +1,191 @@
# 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 threading
from pathlib import Path
from Crypto.Cipher import AES
from mvt.ios.decrypt import DecryptBackup, MVTEncryptedBackup
def _encrypted_file(backup_path, file_id, key, plaintext):
padding_length = AES.block_size - (len(plaintext) % AES.block_size)
padded = plaintext + bytes([padding_length]) * padding_length
encrypted = AES.new(key, AES.MODE_CBC, iv=b"\x00" * AES.block_size).encrypt(
padded
)
source_path = backup_path / file_id[:2] / file_id
source_path.parent.mkdir(parents=True)
source_path.write_bytes(encrypted)
def test_extract_file_by_id_preserves_bytes_with_wrong_manifest_size(
mocker, tmp_path
):
file_id = "ab" + "1" * 38
plaintext = b"complete decrypted content"
inner_key = b"k" * 32
_encrypted_file(tmp_path, file_id, inner_key, plaintext)
file_plist = mocker.Mock(
encryption_key=b"wrapped-key",
protection_class=1,
filesize=1,
mtime=None,
)
mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist)
backup = MVTEncryptedBackup(
backup_directory=str(tmp_path), derived_key=b"d" * 32
)
mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True)
backup._keybag = mocker.Mock()
backup._keybag.unwrapKeyForClass.return_value = inner_key
streaming_decrypt = mocker.spy(backup, "_decrypt_file_to_disk")
output_path = tmp_path / "output"
backup.extract_file_by_id(
file_id=file_id,
file_bplist=b"plist",
output_filename=str(output_path),
)
assert output_path.read_bytes() == plaintext
streaming_decrypt.assert_called_once()
def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path):
file_id = "cd" + "2" * 38
source_path = tmp_path / file_id[:2] / file_id
source_path.parent.mkdir(parents=True)
source_path.write_bytes(b"plain content")
file_plist = mocker.Mock(encryption_key=None)
mocker.patch("mvt.ios.decrypt.FilePlist", return_value=file_plist)
backup = MVTEncryptedBackup(
backup_directory=str(tmp_path), derived_key=b"d" * 32
)
mocker.patch.object(backup, "_read_and_unlock_keybag", return_value=True)
output_path = tmp_path / "output"
backup.extract_file_by_id(
file_id=file_id,
file_bplist=b"plist",
output_filename=str(output_path),
)
assert output_path.read_bytes() == b"plain content"
def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_path):
backup_path = tmp_path / "backup"
destination = tmp_path / "destination"
outside = tmp_path / "outside"
backup_path.mkdir()
destination.mkdir()
outside.mkdir()
safe_file_id = "ef" + "3" * 38
unsafe_file_id = "../../outside-file"
symlink_file_id = "ab" + "4" * 38
for file_id in (safe_file_id, symlink_file_id):
source_path = backup_path / file_id[:2] / file_id
source_path.parent.mkdir(parents=True, exist_ok=True)
source_path.write_bytes(b"encrypted")
(destination / "ab").symlink_to(outside, target_is_directory=True)
cursor = mocker.MagicMock()
cursor.__iter__.return_value = iter(
[
(safe_file_id, "Domain", "safe", b"plist"),
(unsafe_file_id, "Domain", "unsafe", b"plist"),
(symlink_file_id, "Domain", "symlink", b"plist"),
]
)
cursor_context = mocker.MagicMock()
cursor_context.__enter__.return_value = cursor
backup = mocker.MagicMock()
backup.manifest_db_cursor.return_value = cursor_context
def extract_file_by_id(*, output_filename, **kwargs):
Path(output_filename).write_bytes(b"decrypted")
backup.extract_file_by_id.side_effect = extract_file_by_id
decryptor = DecryptBackup(
str(backup_path), str(destination), max_workers=1
)
decryptor._backup = backup
decryptor.process_backup()
assert (destination / safe_file_id[:2] / safe_file_id).read_bytes() == b"decrypted"
assert not (outside / symlink_file_id).exists()
backup.extract_file_by_id.assert_called_once()
assert backup.extract_file_by_id.call_args.kwargs["file_id"] == safe_file_id
def test_process_backup_decrypts_files_concurrently(mocker, tmp_path):
backup_path = tmp_path / "backup"
destination = tmp_path / "destination"
backup_path.mkdir()
file_ids = ["ab" + "1" * 38, "cd" + "2" * 38]
for file_id in file_ids:
source_path = backup_path / file_id[:2] / file_id
source_path.parent.mkdir()
source_path.write_bytes(b"encrypted")
cursor = mocker.MagicMock()
cursor.__iter__.return_value = iter(
(file_id, "Domain", file_id, b"plist") for file_id in file_ids
)
cursor_context = mocker.MagicMock()
cursor_context.__enter__.return_value = cursor
barrier = threading.Barrier(2)
backup = mocker.MagicMock()
backup.manifest_db_cursor.return_value = cursor_context
def extract_file_by_id(*, file_id, output_filename, **kwargs):
barrier.wait(timeout=5)
Path(output_filename).write_bytes(file_id.encode())
backup.extract_file_by_id.side_effect = extract_file_by_id
decryptor = DecryptBackup(str(backup_path), str(destination), max_workers=2)
decryptor._backup = backup
decryptor.process_backup()
for file_id in file_ids:
assert (destination / file_id[:2] / file_id).read_bytes() == file_id.encode()
def test_process_backup_logs_worker_errors(mocker, tmp_path, caplog):
backup_path = tmp_path / "backup"
destination = tmp_path / "destination"
backup_path.mkdir()
file_id = "ef" + "3" * 38
source_path = backup_path / file_id[:2] / file_id
source_path.parent.mkdir()
source_path.write_bytes(b"encrypted")
cursor = mocker.MagicMock()
cursor.__iter__.return_value = iter([(file_id, "Domain", "failing-file", b"plist")])
cursor_context = mocker.MagicMock()
cursor_context.__enter__.return_value = cursor
backup = mocker.MagicMock()
backup.manifest_db_cursor.return_value = cursor_context
backup.extract_file_by_id.side_effect = ValueError("broken file")
decryptor = DecryptBackup(str(backup_path), str(destination))
decryptor._backup = backup
with caplog.at_level(logging.ERROR, logger="mvt.ios.decrypt"):
decryptor.process_backup()
assert "Failed to decrypt file failing-file: broken file" in caplog.text
+4
View File
@@ -0,0 +1,4 @@
# 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/
@@ -0,0 +1,161 @@
# 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 plistlib
import sqlite3
import tarfile
from mvt.common.module import run_module
from mvt.ios.cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
from mvt.ios.modules.sysdiagnose.sysdiagnose_info import SysdiagnoseInfo
from mvt.ios.versions import get_device_desc_from_id
# The name sysdiagnose gives its archive: the time it ran, then the OS and build.
ARCHIVE_NAME = "sysdiagnose_2024.01.02_03-04-05+0200_iPhone-OS_iPhone_21C62"
DUMPSTATE = (
"Found device: ...\n"
"\tProperties: {\n"
"\t\tProductType => iPhone12,1\n"
"\t\tOSVersion => 17.2\n"
"\t\tSerialNumber => C0FFEE000000\n"
"\t\tRegionCode => LL\n"
"\t}\n"
"\tServices: {\n"
"\t\tcom.apple.example => ignored\n"
"\t}\n"
)
ACTIVATION_BODY = {
"serial-number": "C0FFEE000000",
"productType": "iPhone12,1",
"productName": "iPhone OS",
"imei": "000000000000000",
"os-version": "17.2",
"os-build": "21C62",
"udid": "00000000-0000000000000000",
"meid": "00000000000000",
}
def make_sysdiagnose(tmp_path, activation_body=None):
folder = tmp_path / ARCHIVE_NAME
folder.mkdir()
(folder / "sysdiagnose.log").write_text(
f"Output available at '/private/var/tmp/{ARCHIVE_NAME}.tar.gz'\n",
encoding="utf-8",
)
(folder / "remotectl_dumpstate.txt").write_text(DUMPSTATE, encoding="utf-8")
activation = folder / "logs" / "MobileActivation"
activation.mkdir(parents=True)
body = json.dumps(
activation_body if activation_body is not None else ACTIVATION_BODY
)
(activation / "collection_oob_request.txt").write_text(
f"HEADERS: {{}}\nBODY: {body}\nEND\n", encoding="utf-8"
)
appinstallation = folder / "logs" / "appinstallation"
appinstallation.mkdir(parents=True)
conn = sqlite3.connect(appinstallation / "appstored.sqlitedb")
conn.execute("CREATE TABLE asset (sinfs_data BLOB)")
conn.execute(
"INSERT INTO asset VALUES (?)",
(plistlib.dumps([{"sinf": b"\x00\x10nameExample Person\x00\x00rest"}]),),
)
conn.execute("CREATE TABLE job_software (store_account_name TEXT)")
conn.execute("INSERT INTO job_software VALUES (NULL)")
conn.execute("INSERT INTO job_software VALUES ('person@example.com')")
conn.commit()
conn.close()
return folder
def run_command(target, results_path=None):
command = CmdIOSCheckSysdiagnose(target_path=str(target), results_path=results_path)
command.run()
(module,) = [m for m in command.executed if isinstance(m, SysdiagnoseInfo)]
return module
def test_device_details_from_a_sysdiagnose_folder(tmp_path):
results_path = tmp_path / "results"
results_path.mkdir()
module = run_command(make_sysdiagnose(tmp_path), str(results_path))
assert module.results["SerialNumber"] == "C0FFEE000000"
assert module.results["ProductType"] == "iPhone12,1"
assert module.results["ProductName"] == get_device_desc_from_id("iPhone12,1")
assert module.results["ProductName"] != "iPhone OS"
assert module.results["OSVersion"] == "17.2"
assert module.results["BuildVersion"] == "21C62"
assert module.results["UniqueIdentifier"] == "00000000-0000000000000000"
assert module.results["RegionCode"] == "LL"
assert "com.apple.example" not in module.results
assert module.results["Account Name"] == "Example Person"
assert module.results["Email Address"] == "person@example.com"
assert module.results["OriginalFilename"] == f"{ARCHIVE_NAME}.tar.gz"
assert module.results["CreatedTimestamp"] == "2024-01-02 01:04:05.000000"
assert (results_path / "sysdiagnose_info.json").exists()
def test_device_details_from_a_sysdiagnose_archive(tmp_path):
folder = make_sysdiagnose(tmp_path)
archive_path = tmp_path / f"{ARCHIVE_NAME}.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(folder, arcname=ARCHIVE_NAME)
module = run_command(archive_path)
assert module.results["SerialNumber"] == "C0FFEE000000"
assert module.results["Account Name"] == "Example Person"
assert module.results["OriginalFilename"] == f"{ARCHIVE_NAME}.tar.gz"
def test_wal_sidecars_are_copied_beside_the_database(tmp_path):
folder = tmp_path / ARCHIVE_NAME
(folder / "logs").mkdir(parents=True)
(folder / "logs" / "db.sqlite").write_bytes(b"main")
(folder / "logs" / "db.sqlite-wal").write_bytes(b"wal")
module = SysdiagnoseInfo()
module.from_sysdiagnose_folder(
str(folder),
[f"{ARCHIVE_NAME}/logs/db.sqlite", f"{ARCHIVE_NAME}/logs/db.sqlite-wal"],
)
copies = tmp_path / "copies"
copies.mkdir()
db_path = module._copy_sqlite_db(f"{ARCHIVE_NAME}/logs/db.sqlite", str(copies))
assert db_path == str(copies / "db.sqlite")
assert (copies / "db.sqlite").read_bytes() == b"main"
assert (copies / "db.sqlite-wal").read_bytes() == b"wal"
assert not (copies / "db.sqlite-shm").exists()
def test_a_sysdiagnose_without_the_files_yields_nothing(tmp_path):
folder = tmp_path / ARCHIVE_NAME
folder.mkdir()
(folder / "other.txt").write_text("nothing here", encoding="utf-8")
module = SysdiagnoseInfo()
module.from_sysdiagnose_folder(str(folder), [f"{ARCHIVE_NAME}/other.txt"])
run_module(module)
assert module.results == {}
def test_a_malformed_activation_request_is_skipped(tmp_path):
folder = make_sysdiagnose(tmp_path)
(folder / "logs" / "MobileActivation" / "collection_oob_request.txt").write_text(
"BODY: {not json}\n", encoding="utf-8"
)
module = run_command(folder)
assert "IMEI" not in module.results
assert module.results["SerialNumber"] == "C0FFEE000000"
+92
View File
@@ -3,11 +3,16 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import datetime
import logging
import os
import shutil
import zipfile
from click.testing import CliRunner
from mvt.android.cli import check_bugreport
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from .utils import get_artifact_folder
@@ -28,3 +33,90 @@ class TestCheckBugreportCommand:
assert result.exit_code == 1
assert "Invalid bugreport archive" in result.output
assert "Traceback" not in result.output
PROPERTIES = (
"------ SYSTEM PROPERTIES (getprop) ------\n"
"[persist.sys.timezone]: [Africa/Nairobi]\n"
"------ 0.01s was the duration of 'SYSTEM PROPERTIES' ------\n"
)
TOMBSTONE = "android_data/bugreport/FS/data/tombstones/tombstone_00"
def _bugreport_zip(tmp_path, dumpstate=PROPERTIES):
"""A bugreport zip holding one tombstone written at 11:38:10 device time.
An even second: zip entry times have a two-second resolution.
"""
path = tmp_path / "bugreport.zip"
with open(os.path.join(get_artifact_folder(), TOMBSTONE), "rb") as handle:
tombstone = handle.read()
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("main_entry.txt", "dumpstate.txt")
archive.writestr("dumpstate.txt", dumpstate)
entry = zipfile.ZipInfo(
"FS/data/tombstones/tombstone_00", date_time=(2023, 3, 10, 11, 38, 10)
)
archive.writestr(entry, tombstone)
return str(path)
def _tombstone_timestamp(target, **options):
cmd = CmdAndroidCheckBugreport(
target_path=target,
module_name="Tombstones",
disable_version_check=True,
disable_indicator_check=True,
**options,
)
cmd.run()
return cmd, cmd.executed[0].results[0]["file_timestamp"]
class TestCheckBugreportTimezone:
def test_zip_entry_times_are_read_in_the_device_timezone(self, tmp_path):
cmd, file_timestamp = _tombstone_timestamp(_bugreport_zip(tmp_path))
assert cmd.module_options["device_timezone"] == "Africa/Nairobi"
# 11:38:10 in Nairobi is 08:38:10 UTC.
assert file_timestamp == "2023-03-10 08:38:10.000000"
def test_timezone_option_wins_over_the_bugreport(self, tmp_path):
_, file_timestamp = _tombstone_timestamp(
_bugreport_zip(tmp_path), module_options={"device_timezone": "Europe/Paris"}
)
assert file_timestamp == "2023-03-10 10:38:10.000000"
result = CliRunner().invoke(
check_bugreport,
["-t", "Europe/Paris", "-m", "Tombstones", _bugreport_zip(tmp_path)],
)
assert result.exit_code == 0, result.output
def test_without_a_timezone_the_wall_clock_is_kept_and_a_warning_given(
self, tmp_path, caplog
):
with caplog.at_level(logging.WARNING, logger="mvt"):
_, file_timestamp = _tombstone_timestamp(_bugreport_zip(tmp_path, ""))
assert file_timestamp == "2023-03-10 11:38:10.000000"
assert "persist.sys.timezone not found" in caplog.text
def test_unpacked_bugreport_warns_and_reads_mtimes_as_utc(self, tmp_path, caplog):
unpacked = tmp_path / "bugreport"
shutil.copytree(
os.path.join(get_artifact_folder(), "android_data/bugreport"), unpacked
)
instant = datetime.datetime(
2023, 3, 10, 8, 38, 11, tzinfo=datetime.timezone.utc
).timestamp()
for name in ("tombstone_00", "tombstone_01"):
os.utime(unpacked / "FS" / "data" / "tombstones" / name, (instant, instant))
with caplog.at_level(logging.WARNING, logger="mvt"):
_, file_timestamp = _tombstone_timestamp(str(unpacked))
assert "unpacked bugreport" in caplog.text
# Whatever the zone of the machine running the analysis.
assert file_timestamp == "2023-03-10 08:38:11.000000"
+8
View File
@@ -19,6 +19,14 @@ class TestCheckBackupCommand:
result = runner.invoke(check_backup, [path])
assert result.exit_code == 0
def test_check_logs_the_backup_path_to_the_command_log(self, tmp_path):
path = get_ios_backup_folder()
output_path = tmp_path / "out"
result = CliRunner().invoke(check_backup, ["--output", str(output_path), path])
assert result.exit_code == 0
command_log = (output_path / "command.log").read_text(encoding="utf-8")
assert f"Checking iTunes backup located at: {path}" in command_log
def test_check_finds_backup_in_subfolder(self, tmp_path, caplog):
runner = CliRunner()
backup_path = tmp_path / "MobileSync" / "Backup" / "device-id"
+39 -4
View File
@@ -1,3 +1,7 @@
import logging
import os
import tarfile
from click.testing import CliRunner
from mvt.ios.cli import check_sysdiagnose
@@ -50,8 +54,39 @@ def test_check_sysdiagnose_runs_explicitly_scoped_custom_module(tmp_path):
assert (output_path / "custom_sysdiagnose_module.json").exists()
def test_check_sysdiagnose_requires_an_explicitly_scoped_module(tmp_path):
result = CliRunner().invoke(check_sysdiagnose, [str(_create_sysdiagnose_folder(tmp_path))])
def test_check_sysdiagnose_warns_without_a_custom_module(tmp_path, caplog):
# The built-in SysdiagnoseInfo alone performs no check, so the run goes
# ahead but says so.
with caplog.at_level(logging.WARNING, logger="mvt"):
result = CliRunner().invoke(
check_sysdiagnose, [str(_create_sysdiagnose_folder(tmp_path))]
)
assert result.exit_code != 0
assert "No custom modules support mvt-ios check-sysdiagnose" in result.output
assert result.exit_code == 0
assert "No forensic sysdiagnose modules have been loaded" in caplog.text
def _create_truncated_sysdiagnose_archive(tmp_path):
folder = tmp_path / "sysdiagnose_2026.01.01_00-00-00+0000_iPhone-OS_iPhone_23A000"
folder.mkdir()
(folder / "sysdiagnose.log").write_bytes(os.urandom(200_000))
archive = tmp_path / "sysdiagnose.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
tar.add(folder, arcname=folder.name)
data = archive.read_bytes()
archive.write_bytes(data[: len(data) // 2])
return archive
def test_check_sysdiagnose_reports_a_truncated_archive(tmp_path, caplog):
# A download that stopped halfway ends in EOFError from gzip, which Click
# would otherwise turn into a bare "Aborted!" with no reason given.
archive = _create_truncated_sysdiagnose_archive(tmp_path)
with caplog.at_level(logging.CRITICAL, logger="mvt"):
result = CliRunner().invoke(check_sysdiagnose, [str(archive)])
assert result.exit_code == 1
assert "Unable to read the sysdiagnose archive" in caplog.text
assert "truncated" in caplog.text
assert "Aborted!" not in result.output
+13 -7
View File
@@ -34,6 +34,7 @@ def _create_sysdiagnose_folder(tmp_path):
"sysdiagnose_2024.01.02_03-04-05+0200.tar.gz", encoding="utf-8"
)
(folder / "report.ips").write_text('{"bug_type": 210}\nbody', encoding="utf-8")
(folder / "._artifact.txt").write_bytes(b"\x00\x05\x16\x07AppleDouble")
return folder
@@ -45,6 +46,11 @@ def _create_sysdiagnose_archive(tmp_path, folder):
return archive_path
def _test_module(command):
(module,) = [m for m in command.executed if isinstance(m, SysdiagnoseTestModule)]
return module
def _run_command(path):
command = CmdIOSCheckSysdiagnose(
target_path=str(path), custom_modules=[SysdiagnoseTestModule]
@@ -56,30 +62,30 @@ def _run_command(path):
def test_check_sysdiagnose_from_folder(tmp_path):
command = _run_command(_create_sysdiagnose_folder(tmp_path))
assert command.executed[0].results == [
assert _test_module(command).results == [
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
]
assert command.executed[0].ips_files == [
assert _test_module(command).ips_files == [
{"file_path": str(tmp_path / "sysdiagnose" / "report.ips"), "bug_type": 210}
]
assert "sysdiagnose/._artifact.txt" not in command.sysdiagnose_files
def test_check_sysdiagnose_from_archive_closes_archive(tmp_path):
folder = _create_sysdiagnose_folder(tmp_path)
command = _run_command(_create_sysdiagnose_archive(tmp_path, folder))
assert command.executed[0].results == [
assert _test_module(command).results == [
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
]
assert command.executed[0].ips_files == [
assert _test_module(command).ips_files == [
{
"file_path": str(
Path(command.extracted_sysdiagnose_path) / "report.ips"
),
"file_path": str(Path(command.extracted_sysdiagnose_path) / "report.ips"),
"bug_type": 210,
}
]
assert command.sysdiagnose_archive is None
assert "sysdiagnose/._artifact.txt" not in command.sysdiagnose_files
def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path):