mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-23 23:58:23 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab7471acd | ||
|
|
8140e350f7 | ||
|
|
f483223e23 | ||
|
|
2dfe3cbcb1 | ||
|
|
3eff0c550d | ||
|
|
797411e1e5 | ||
|
|
84df51c518 | ||
|
|
a6c3a805d8 |
No files matched your search
+5
-3
@@ -82,14 +82,16 @@ class ExampleCustomModule(MVTModule):
|
||||
return None
|
||||
```
|
||||
|
||||
Use `supported_commands` to restrict a module to specific platform/command
|
||||
pairs. Missing or empty `supported_commands` means the module is available to
|
||||
all commands, which keeps older modules compatible. Supported pairs are:
|
||||
Use `supported_commands` to declare the platform/command pairs a module
|
||||
supports. Empty `supported_commands` means the module will not run and MVT logs
|
||||
a warning. This explicit declaration is required for every command. Supported
|
||||
pairs are:
|
||||
|
||||
```python
|
||||
("ios", "check-backup")
|
||||
("ios", "check-fs")
|
||||
("ios", "check-iocs")
|
||||
("ios", "check-sysdiagnose")
|
||||
("android", "check-backup")
|
||||
("android", "check-bugreport")
|
||||
("android", "check-androidqf")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Check an iOS Sysdiagnose
|
||||
|
||||
`mvt-ios check-sysdiagnose` prepares an iOS sysdiagnose archive for analysis by
|
||||
custom MVT modules. MVT does not include built-in sysdiagnose modules. You must
|
||||
load at least one custom module that explicitly supports this command.
|
||||
|
||||
The command accepts either an extracted sysdiagnose directory or the original
|
||||
gzip-compressed tar archive.
|
||||
|
||||
```bash
|
||||
mvt-ios check-sysdiagnose \
|
||||
--load-module ./sysdiagnose_modules.py \
|
||||
--output ./results \
|
||||
./sysdiagnose_2024.01.02_03-04-05+0200.tar.gz
|
||||
```
|
||||
|
||||
Use `--hashes` to include hashes for analyzed files in `info.json`, and
|
||||
`--list-modules` to display the eligible custom modules without running them.
|
||||
|
||||
## Writing a custom module
|
||||
|
||||
Extend `SysdiagnoseExtraction` to access the archive contents consistently for
|
||||
both directory and tar inputs. Each module must declare the command explicitly
|
||||
in `supported_commands`.
|
||||
|
||||
```python
|
||||
from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
|
||||
|
||||
|
||||
class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
|
||||
supported_commands = (("ios", "check-sysdiagnose"),)
|
||||
slug = "example_sysdiagnose"
|
||||
|
||||
def run(self):
|
||||
paths = self._get_files_by_pattern("*/example.log")
|
||||
if paths:
|
||||
content = self._get_file_content(paths[0]).decode("utf-8", "replace")
|
||||
self.results = [{"content": content}]
|
||||
|
||||
def check_indicators(self):
|
||||
pass
|
||||
|
||||
def serialize(self, result):
|
||||
return None
|
||||
```
|
||||
|
||||
The base class provides `from_sysdiagnose_folder()` and
|
||||
`from_sysdiagnose_tar()` setup hooks, as well as protected file lookup, file
|
||||
reading, and timezone extraction helpers. IPS crash-report metadata is exposed
|
||||
on `ips_files`.
|
||||
@@ -3,6 +3,8 @@ repo_url: https://github.com/mvt-project/mvt
|
||||
edit_uri: edit/main/docs/
|
||||
copyright: Copyright © 2021-2023 MVT Project Developers
|
||||
site_description: Mobile Verification Toolkit Documentation
|
||||
not_in_nav: |
|
||||
android/adb.md
|
||||
markdown_extensions:
|
||||
- attr_list
|
||||
- admonition
|
||||
@@ -28,6 +30,7 @@ nav:
|
||||
- Welcome: "index.md"
|
||||
- Introduction: "introduction.md"
|
||||
- Installation: "install.md"
|
||||
- Command Completion: "command_completion.md"
|
||||
- Using Docker: "docker.md"
|
||||
- MVT for iOS:
|
||||
- iOS Forensic Methodology: "ios/methodology.md"
|
||||
@@ -39,6 +42,7 @@ nav:
|
||||
- Check a Filesystem Dump:
|
||||
- Dumping the filesystem: "ios/filesystem/dump.md"
|
||||
- Check a Filesystem Dump with mvt-ios: "ios/filesystem/check.md"
|
||||
- Check a Sysdiagnose: "ios/sysdiagnose.md"
|
||||
- Records extracted by mvt-ios: "ios/records.md"
|
||||
- MVT for Android:
|
||||
- Android Forensic Methodology: "android/methodology.md"
|
||||
|
||||
@@ -31,21 +31,38 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact):
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
time_elapsed = line.strip().split(" ", 1)[0]
|
||||
time_parts = line.strip().split()
|
||||
time_elapsed = time_parts[0]
|
||||
if (
|
||||
len(time_parts) > 1
|
||||
and len(time_parts[0]) == 5
|
||||
and time_parts[0][2] == "-"
|
||||
and ":" in time_parts[1]
|
||||
):
|
||||
time_elapsed = " ".join(time_parts[:2])
|
||||
|
||||
event = ""
|
||||
if line.find("+job") > 0:
|
||||
event = "start_job"
|
||||
uid = line[line.find("+job") + 5 : line.find(":")]
|
||||
service = line[line.find(":") + 1 :].strip('"')
|
||||
payload = line.split("+job=", 1)[1]
|
||||
uid, separator, service = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
service = service.strip().strip('"')
|
||||
package_name = service.split("/")[0]
|
||||
elif line.find("-job") > 0:
|
||||
event = "end_job"
|
||||
uid = line[line.find("-job") + 5 : line.find(":")]
|
||||
service = line[line.find(":") + 1 :].strip('"')
|
||||
payload = line.split("-job=", 1)[1]
|
||||
uid, separator, service = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
service = service.strip().strip('"')
|
||||
package_name = service.split("/")[0]
|
||||
elif line.find("+running +wake_lock=") > 0:
|
||||
uid = line[line.find("+running +wake_lock=") + 21 : line.find(":")]
|
||||
payload = line.split("+running +wake_lock=", 1)[1]
|
||||
uid, separator, _ = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
event = "wake"
|
||||
service = (
|
||||
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
|
||||
|
||||
@@ -29,10 +29,9 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
rxp = re.compile(
|
||||
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\].*\[Pid:\((\d+)\)\](\w+).*sql\=\"(.+?)\""
|
||||
) # pylint: disable=line-too-long
|
||||
rxp_no_pid = re.compile(
|
||||
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\][ ]{1}(\w+).*sql\=\"(.+?)\""
|
||||
r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} "
|
||||
r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*"
|
||||
r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\""
|
||||
) # pylint: disable=line-too-long
|
||||
|
||||
pool = None
|
||||
@@ -56,29 +55,16 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
pool = None
|
||||
continue
|
||||
|
||||
matches = rxp.findall(line)
|
||||
if not matches:
|
||||
matches = rxp_no_pid.findall(line)
|
||||
if not matches:
|
||||
continue
|
||||
match = rxp.match(line)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
match = matches[0]
|
||||
self.results.append(
|
||||
{
|
||||
"isodate": match[0],
|
||||
"action": match[1],
|
||||
"sql": match[2],
|
||||
"path": pool,
|
||||
}
|
||||
)
|
||||
else:
|
||||
match = matches[0]
|
||||
self.results.append(
|
||||
{
|
||||
"isodate": match[0],
|
||||
"pid": match[1],
|
||||
"action": match[2],
|
||||
"sql": match[3],
|
||||
"path": pool,
|
||||
}
|
||||
)
|
||||
result = {
|
||||
"isodate": match.group(1),
|
||||
"action": match.group(3),
|
||||
"sql": match.group(4),
|
||||
"path": pool,
|
||||
}
|
||||
if match.group(2):
|
||||
result["pid"] = match.group(2)
|
||||
self.results.append(result)
|
||||
@@ -96,6 +96,18 @@ class DumpsysReceiversArtifact(AndroidArtifact):
|
||||
self.results[intent] = []
|
||||
continue
|
||||
|
||||
parts = line.strip().split(" ")
|
||||
if len(parts) < 2:
|
||||
# A single-token line here is not a receiver. Real dumpstate
|
||||
# output can print an action header mis-indented (observed with
|
||||
# 15 leading spaces instead of 6), which used to raise
|
||||
# IndexError and abort the whole module. Treat a trailing-colon
|
||||
# token as the next action, skip anything else.
|
||||
if parts[0].endswith(":"):
|
||||
intent = parts[0][:-1]
|
||||
self.results.setdefault(intent, [])
|
||||
continue
|
||||
|
||||
# If we are not in an intent block yet, skip.
|
||||
if not intent:
|
||||
continue
|
||||
@@ -109,7 +121,7 @@ class DumpsysReceiversArtifact(AndroidArtifact):
|
||||
|
||||
# If we got this far, we are processing receivers for the
|
||||
# activities we are interested in.
|
||||
receiver = line.strip().split(" ")[1]
|
||||
receiver = parts[1]
|
||||
package_name = receiver.split("/")[0]
|
||||
|
||||
self.results[intent].append(
|
||||
|
||||
@@ -206,6 +206,11 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
return True
|
||||
|
||||
def _load_pid_line(self, line: str, tombstone: dict) -> bool:
|
||||
# The first pid line identifies the crashing thread. Full text tombstones
|
||||
# contain additional pid lines for the other threads in the process.
|
||||
if "pid" in tombstone:
|
||||
return True
|
||||
|
||||
try:
|
||||
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
|
||||
process_info = parts[0]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from zipfile import BadZipFile
|
||||
|
||||
import click
|
||||
|
||||
@@ -212,7 +213,10 @@ def check_bugreport(
|
||||
|
||||
log.info("Checking Android bug report at path: %s", bugreport_path)
|
||||
|
||||
cmd.run()
|
||||
try:
|
||||
cmd.run()
|
||||
except BadZipFile as exc:
|
||||
raise click.ClickException(f"Invalid bugreport archive: {exc}") from exc
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
|
||||
@@ -296,6 +296,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
|
||||
elif self.__format == "zip" and self.__zip:
|
||||
temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_")
|
||||
temp_root = Path(temp_dir).resolve()
|
||||
for entry in intrusion_log_files:
|
||||
normalized = entry.replace("\\", "/")
|
||||
idx = normalized.find("intrusion_logs/")
|
||||
@@ -303,9 +304,15 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
if not relative or relative.endswith("/"):
|
||||
continue
|
||||
|
||||
target = os.path.join(temp_dir, relative)
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
with self.__zip.open(entry) as src, open(target, "wb") as dst:
|
||||
target = (temp_root / relative).resolve()
|
||||
if not target.is_relative_to(temp_root):
|
||||
self.log.warning(
|
||||
"Skipping unsafe intrusion log archive entry: %s", entry
|
||||
)
|
||||
continue
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.__zip.open(entry) as src, target.open("wb") as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
intrusion_logs_path = temp_dir
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
from .aqf_files import AQFFiles
|
||||
from .aqf_getprop import AQFGetProp
|
||||
from .aqf_log_timestamps import AQFLogTimestamps
|
||||
from .aqf_packages import AQFPackages
|
||||
from .aqf_processes import AQFProcesses
|
||||
from .aqf_settings import AQFSettings
|
||||
@@ -17,6 +18,7 @@ ANDROIDQF_MODULES = [
|
||||
AQFGetProp,
|
||||
AQFSettings,
|
||||
AQFFiles,
|
||||
AQFLogTimestamps,
|
||||
RootBinaries,
|
||||
Mounts,
|
||||
]
|
||||
@@ -131,20 +131,23 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
|
||||
if password is None:
|
||||
raise InvalidBackupPassword()
|
||||
|
||||
[
|
||||
user_salt,
|
||||
checksum_salt,
|
||||
pbkdf2_rounds,
|
||||
user_iv,
|
||||
master_key_blob,
|
||||
encrypted_data,
|
||||
] = encrypted_backup.split(b"\n", 5)
|
||||
try:
|
||||
[
|
||||
user_salt,
|
||||
checksum_salt,
|
||||
pbkdf2_rounds,
|
||||
user_iv,
|
||||
master_key_blob,
|
||||
encrypted_data,
|
||||
] = encrypted_backup.split(b"\n", 5)
|
||||
|
||||
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
|
||||
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
|
||||
pbkdf2_rounds = int(pbkdf2_rounds)
|
||||
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
|
||||
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
|
||||
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
|
||||
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
|
||||
pbkdf2_rounds = int(pbkdf2_rounds)
|
||||
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
|
||||
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise AndroidBackupParsingError("Invalid encrypted backup header") from exc
|
||||
|
||||
# Derive decryption master key from password.
|
||||
master_key, master_iv = decrypt_master_key(
|
||||
@@ -174,10 +177,12 @@ def parse_backup_file(data, password=None):
|
||||
if not data.startswith(b"ANDROID BACKUP"):
|
||||
raise AndroidBackupParsingError("Invalid file header")
|
||||
|
||||
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
|
||||
|
||||
version = int(version)
|
||||
is_compressed = int(is_compressed)
|
||||
try:
|
||||
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
|
||||
version = int(version)
|
||||
is_compressed = int(is_compressed)
|
||||
except ValueError as exc:
|
||||
raise AndroidBackupParsingError("Invalid file header") from exc
|
||||
|
||||
if encryption_algo != b"none":
|
||||
tar_data = decrypt_backup_data(
|
||||
|
||||
@@ -38,6 +38,7 @@ HELP_MSG_BACKUP_KEYFILE = (
|
||||
HELP_MSG_EXTRACT_KEY = "Extract decryption key from an iTunes backup"
|
||||
HELP_MSG_CHECK_IOS_BACKUP = "Extract artifacts from an iTunes backup"
|
||||
HELP_MSG_CHECK_FS = "Extract artifacts from a full filesystem dump"
|
||||
HELP_MSG_CHECK_SYSDIAGNOSE = "Extract artifacts from an iOS sysdiagnose archive"
|
||||
|
||||
# Android Specific
|
||||
HELP_MSG_ANDROID_BACKUP_PASSWORD = "The backup password to use for an Android backup"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -15,6 +16,7 @@ from typing import Iterable, Optional
|
||||
from .module import MVTModule
|
||||
|
||||
MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES"
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomModuleLoadError(Exception):
|
||||
@@ -122,6 +124,11 @@ def module_supports_command(
|
||||
) -> bool:
|
||||
supported_commands = getattr(module_class, "supported_commands", None)
|
||||
if not supported_commands:
|
||||
return True
|
||||
log.warning(
|
||||
"Custom module %s has no supported_commands and will not be run. "
|
||||
"Declare the platform/command pairs it supports.",
|
||||
module_class.__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
return (platform, command) in {tuple(entry) for entry in supported_commands}
|
||||
@@ -3,4 +3,4 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
MVT_VERSION = "2026.5.12"
|
||||
MVT_VERSION = "2026.7.29"
|
||||
@@ -42,6 +42,7 @@ from mvt.common.help import (
|
||||
HELP_MSG_CHECK_IOCS,
|
||||
HELP_MSG_STIX2,
|
||||
HELP_MSG_CHECK_IOS_BACKUP,
|
||||
HELP_MSG_CHECK_SYSDIAGNOSE,
|
||||
HELP_MSG_DISABLE_UPDATE_CHECK,
|
||||
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
HELP_MSG_COMPLETION,
|
||||
@@ -50,6 +51,7 @@ from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
|
||||
from mvt.common.password import prompt_password
|
||||
from .cmd_check_backup import CmdIOSCheckBackup
|
||||
from .cmd_check_fs import CmdIOSCheckFS
|
||||
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
|
||||
from .decrypt import DecryptBackup
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
from .modules.fs import FS_MODULES
|
||||
@@ -398,6 +400,77 @@ def check_fs(
|
||||
cmd.show_support_message()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: check-sysdiagnose
|
||||
# ==============================================================================
|
||||
@cli.command(
|
||||
"check-sysdiagnose",
|
||||
context_settings=CONTEXT_SETTINGS,
|
||||
help=HELP_MSG_CHECK_SYSDIAGNOSE,
|
||||
)
|
||||
@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("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_sysdiagnose(
|
||||
ctx,
|
||||
iocs,
|
||||
output,
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
hashes,
|
||||
verbose,
|
||||
sysdiagnose_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
cmd = CmdIOSCheckSysdiagnose(
|
||||
target_path=sysdiagnose_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 not cmd._available_modules():
|
||||
raise click.ClickException(
|
||||
"No custom modules support mvt-ios check-sysdiagnose. "
|
||||
"Load a module that declares supported_commands = "
|
||||
"((\"ios\", \"check-sysdiagnose\"),)."
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path)
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Command: check-iocs
|
||||
# ==============================================================================
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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 json
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CmdIOSCheckSysdiagnose(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,
|
||||
serial: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
hashes: bool = False,
|
||||
sub_command: 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,
|
||||
serial=serial,
|
||||
module_options=module_options,
|
||||
hashes=hashes,
|
||||
sub_command=sub_command,
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
)
|
||||
self.platform = "ios"
|
||||
self.name = "check-sysdiagnose"
|
||||
self.sysdiagnose_format: Optional[str] = None
|
||||
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
|
||||
self.sysdiagnose_files: list[str] = []
|
||||
self.ips_files: list[dict[str, Any]] = []
|
||||
|
||||
@staticmethod
|
||||
def _parse_bugtype_header(data: bytes) -> Optional[int]:
|
||||
try:
|
||||
header = json.loads(data.split(b"\n", 1)[0].decode("utf-8"))
|
||||
return int(header["bug_type"])
|
||||
except (json.JSONDecodeError, KeyError, UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
def _add_ips_file(self, file_path: str, data: bytes) -> None:
|
||||
bug_type = self._parse_bugtype_header(data)
|
||||
if bug_type is not None:
|
||||
self.ips_files.append({"file_path": file_path, "bug_type": bug_type})
|
||||
|
||||
def init(self) -> None:
|
||||
if not self.target_path:
|
||||
raise ValueError("A sysdiagnose path is required")
|
||||
|
||||
if os.path.isdir(self.target_path):
|
||||
self.sysdiagnose_format = "dir"
|
||||
parent_path = Path(self.target_path).absolute().parent
|
||||
for root, _, filenames in os.walk(self.target_path):
|
||||
for filename in filenames:
|
||||
absolute_path = os.path.join(root, filename)
|
||||
file_path = os.path.relpath(absolute_path, parent_path)
|
||||
self.sysdiagnose_files.append(file_path)
|
||||
if filename.endswith(".ips"):
|
||||
with open(absolute_path, "rb") as handle:
|
||||
self._add_ips_file(absolute_path, handle.read())
|
||||
return
|
||||
|
||||
if not os.path.isfile(self.target_path):
|
||||
raise ValueError(f"Sysdiagnose path does not exist: {self.target_path}")
|
||||
|
||||
self.log.info("Parsing sysdiagnose archive. This might take a while...")
|
||||
self.sysdiagnose_format = "tar"
|
||||
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
|
||||
for member in self.sysdiagnose_archive:
|
||||
self.sysdiagnose_files.append(member.name)
|
||||
if member.isfile() and member.name.endswith(".ips"):
|
||||
archive_handle = self.sysdiagnose_archive.extractfile(member)
|
||||
if archive_handle is not None:
|
||||
with archive_handle:
|
||||
self._add_ips_file(member.name, archive_handle.read())
|
||||
|
||||
def module_init(self, module) -> None:
|
||||
module.ips_files = self.ips_files
|
||||
if self.sysdiagnose_format == "tar":
|
||||
if self.sysdiagnose_archive is None:
|
||||
raise RuntimeError("Sysdiagnose archive has not been initialized")
|
||||
module.from_sysdiagnose_tar(
|
||||
self.sysdiagnose_archive, self.sysdiagnose_files
|
||||
)
|
||||
return
|
||||
if self.sysdiagnose_format == "dir" and self.target_path:
|
||||
module.from_sysdiagnose_folder(self.target_path, self.sysdiagnose_files)
|
||||
return
|
||||
raise RuntimeError("Sysdiagnose input has not been initialized")
|
||||
|
||||
def finish(self) -> None:
|
||||
if self.sysdiagnose_archive is not None:
|
||||
self.sysdiagnose_archive.close()
|
||||
self.sysdiagnose_archive = None
|
||||
+63
-20
@@ -9,6 +9,7 @@ import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional, Union
|
||||
|
||||
@@ -20,6 +21,20 @@ from mvt.common.module import (
|
||||
)
|
||||
|
||||
|
||||
class TemporarySQLiteConnection(sqlite3.Connection):
|
||||
"""SQLite connection that owns a temporary copy of a database."""
|
||||
|
||||
temporary_directory: Optional[tempfile.TemporaryDirectory] = None
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
super().close()
|
||||
finally:
|
||||
if self.temporary_directory:
|
||||
self.temporary_directory.cleanup()
|
||||
self.temporary_directory = None
|
||||
|
||||
|
||||
class IOSExtraction(MVTModule):
|
||||
"""This class provides a base for all iOS filesystem/backup extraction
|
||||
modules."""
|
||||
@@ -44,6 +59,8 @@ class IOSExtraction(MVTModule):
|
||||
|
||||
self.is_backup = False
|
||||
self.is_fs_dump = False
|
||||
self._recovered_sqlite_paths: dict[str, str] = {}
|
||||
self._sqlite_temp_directories: list[tempfile.TemporaryDirectory] = []
|
||||
|
||||
def _recover_sqlite_db_if_needed(
|
||||
self, file_path: str, forced: bool = False
|
||||
@@ -53,17 +70,12 @@ class IOSExtraction(MVTModule):
|
||||
:param file_path: Path to the malformed database file.
|
||||
|
||||
"""
|
||||
# SQLite's immutable mode cannot open databases with active WAL files.
|
||||
if not forced:
|
||||
# If the database is open, do not use immutable
|
||||
if os.path.isfile(file_path + "-shm"):
|
||||
conn = sqlite3.connect(file_path)
|
||||
else:
|
||||
conn = self._open_sqlite_db(file_path)
|
||||
conn = self._open_sqlite_db(file_path)
|
||||
cur = conn.cursor()
|
||||
|
||||
recover = False
|
||||
try:
|
||||
recover = False
|
||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
except sqlite3.DatabaseError as exc:
|
||||
if "database disk image is malformed" in str(exc):
|
||||
@@ -82,27 +94,54 @@ class IOSExtraction(MVTModule):
|
||||
raise DatabaseCorruptedError(
|
||||
"failed to recover without sqlite3 binary: please install sqlite3!"
|
||||
)
|
||||
if '"' in file_path:
|
||||
raise DatabaseCorruptedError(
|
||||
f"database at path '{file_path}' is corrupted. unable to "
|
||||
'recover because it has a quotation mark (") in its name'
|
||||
)
|
||||
|
||||
bak_path = f"{file_path}.bak"
|
||||
shutil.move(file_path, bak_path)
|
||||
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_recover_")
|
||||
temporary_path = Path(temporary_directory.name)
|
||||
source_path = temporary_path / "source.db"
|
||||
recovered_path = temporary_path / "recovered.db"
|
||||
shutil.copy2(file_path, source_path)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = Path(file_path + suffix)
|
||||
if sidecar.is_file():
|
||||
shutil.copy2(sidecar, Path(str(source_path) + suffix))
|
||||
|
||||
ret = subprocess.call(
|
||||
["sqlite3", bak_path, f'.clone "{file_path}"'],
|
||||
["sqlite3", str(source_path), f'.clone "{recovered_path}"'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if ret != 0:
|
||||
temporary_directory.cleanup()
|
||||
raise DatabaseCorruptedError("failed to recover database")
|
||||
|
||||
self._sqlite_temp_directories.append(temporary_directory)
|
||||
self._recovered_sqlite_paths[file_path] = str(recovered_path)
|
||||
self.log.info("Database at path %s recovered successfully!", file_path)
|
||||
|
||||
def _open_sqlite_db(self, file_path: str) -> sqlite3.Connection:
|
||||
return sqlite3.connect(f"file:{file_path}?immutable=1", uri=True)
|
||||
database_path = self._recovered_sqlite_paths.get(file_path, file_path)
|
||||
if not os.path.isfile(database_path + "-wal"):
|
||||
uri = Path(database_path).resolve().as_uri() + "?mode=ro&immutable=1"
|
||||
return sqlite3.connect(uri, uri=True)
|
||||
|
||||
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_")
|
||||
temporary_path = Path(temporary_directory.name) / Path(database_path).name
|
||||
shutil.copy2(database_path, temporary_path)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = Path(database_path + suffix)
|
||||
if sidecar.is_file():
|
||||
shutil.copy2(sidecar, Path(str(temporary_path) + suffix))
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(
|
||||
temporary_path.resolve().as_uri() + "?mode=ro",
|
||||
uri=True,
|
||||
factory=TemporarySQLiteConnection,
|
||||
)
|
||||
except Exception:
|
||||
temporary_directory.cleanup()
|
||||
raise
|
||||
conn.temporary_directory = temporary_directory
|
||||
return conn
|
||||
|
||||
def _get_backup_files_from_manifest(
|
||||
self, relative_path: Optional[str] = None, domain: Optional[str] = None
|
||||
@@ -166,7 +205,11 @@ class IOSExtraction(MVTModule):
|
||||
if not self.target_path:
|
||||
return None
|
||||
file_path = os.path.join(self.target_path, file_id[0:2], file_id)
|
||||
if not Path(file_path).resolve().is_relative_to(Path(self.target_path).resolve()):
|
||||
if (
|
||||
not Path(file_path)
|
||||
.resolve()
|
||||
.is_relative_to(Path(self.target_path).resolve())
|
||||
):
|
||||
return None
|
||||
if os.path.exists(file_path):
|
||||
return file_path
|
||||
@@ -197,9 +240,9 @@ class IOSExtraction(MVTModule):
|
||||
:param backup_ids: Default value = None)
|
||||
|
||||
"""
|
||||
file_path: Optional[str] = None
|
||||
file_path: Optional[str] = self.file_path
|
||||
# First we check if the was an explicit file path specified.
|
||||
if not self.file_path:
|
||||
if not file_path:
|
||||
# Type narrowing: we know self.file_path is None here, work with local file_path
|
||||
# If not, we first try with backups.
|
||||
# We construct the path to the file according to the iTunes backup
|
||||
|
||||
@@ -134,6 +134,8 @@ class Analytics(IOSExtraction):
|
||||
isodate = ""
|
||||
data = plistlib.loads(row[1])
|
||||
data["isodate"] = isodate
|
||||
else:
|
||||
continue
|
||||
|
||||
data["artifact"] = artifact
|
||||
|
||||
|
||||
@@ -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/<UUID>/, 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
|
||||
|
||||
@@ -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/<UUID>/, 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
|
||||
|
||||
@@ -42,7 +42,8 @@ class NetBase(IOSExtraction):
|
||||
)
|
||||
|
||||
def _extract_net_data(self):
|
||||
conn = sqlite3.connect(self.file_path)
|
||||
assert self.file_path is not None
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
@@ -237,9 +238,7 @@ class NetBase(IOSExtraction):
|
||||
"been truncated in the database)"
|
||||
)
|
||||
|
||||
self.alertstore.medium(
|
||||
msg, proc["live_isodate"], proc
|
||||
)
|
||||
self.alertstore.medium(msg, proc["live_isodate"], proc)
|
||||
|
||||
if not proc["live_proc_id"]:
|
||||
self.log.info(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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 .base import SysdiagnoseExtraction
|
||||
@@ -0,0 +1,105 @@
|
||||
# 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 fnmatch
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module import MVTModule, ModuleResults
|
||||
|
||||
|
||||
class SysdiagnoseExtraction(MVTModule):
|
||||
"""Base class for custom modules that analyze an iOS sysdiagnose."""
|
||||
|
||||
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,
|
||||
)
|
||||
self.parent_path: Optional[str] = None
|
||||
self.files: list[str] = []
|
||||
self.tar: Optional[tarfile.TarFile] = None
|
||||
self.tar_files: list[str] = []
|
||||
self.ips_files: list[dict[str, object]] = []
|
||||
|
||||
def from_sysdiagnose_folder(
|
||||
self, target_path: str, sysdiagnose_files: list[str]
|
||||
) -> None:
|
||||
self.parent_path = Path(target_path).absolute().parent.as_posix()
|
||||
self.files = sysdiagnose_files
|
||||
|
||||
def from_sysdiagnose_tar(
|
||||
self, sysdiagnose_archive: tarfile.TarFile, sysdiagnose_files: list[str]
|
||||
) -> None:
|
||||
self.tar = sysdiagnose_archive
|
||||
self.tar_files = sysdiagnose_files
|
||||
|
||||
def _extract_timezone(self):
|
||||
"""Determine the sysdiagnose timezone from its diagnostic log."""
|
||||
file_paths = self._get_files_by_pattern("*/sysdiagnose.log")
|
||||
if not file_paths:
|
||||
self.log.info(
|
||||
"Unable to determine the timezone in which the sysdiagnose was "
|
||||
"generated. Assuming UTC for logs without a timezone."
|
||||
)
|
||||
return timezone.utc
|
||||
|
||||
content = self._get_file_content(file_paths[0]).decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
filenames = re.findall(r"sysdiagnose_\S+?\.tar\.gz", content)
|
||||
if not filenames:
|
||||
self.log.info(
|
||||
"Unable to determine the timezone in which the sysdiagnose was "
|
||||
"generated. Assuming UTC for logs without a timezone."
|
||||
)
|
||||
return timezone.utc
|
||||
|
||||
timestamp = "_".join(
|
||||
filenames[0].removesuffix(".tar.gz").split("_")[1:3]
|
||||
)
|
||||
sysdiagnose_timezone = datetime.strptime(
|
||||
timestamp, "%Y.%m.%d_%H-%M-%S%z"
|
||||
).tzinfo
|
||||
self.log.info(
|
||||
"Based on the sysdiagnose filename, assuming timezone %s for logs "
|
||||
"without a timezone.",
|
||||
sysdiagnose_timezone,
|
||||
)
|
||||
return sysdiagnose_timezone
|
||||
|
||||
def _get_files_by_pattern(self, pattern: str) -> list[str]:
|
||||
file_names = self.tar_files if self.tar else self.files
|
||||
return fnmatch.filter(file_names, pattern)
|
||||
|
||||
def _get_file_content(self, file_path: str) -> bytes:
|
||||
if self.tar:
|
||||
handle = self.tar.extractfile(self.tar.getmember(file_path))
|
||||
else:
|
||||
if self.parent_path is None:
|
||||
raise RuntimeError("Sysdiagnose folder has not been initialized")
|
||||
handle = open(os.path.join(self.parent_path, file_path), "rb")
|
||||
|
||||
if handle is None:
|
||||
return b""
|
||||
with handle:
|
||||
return handle.read()
|
||||
@@ -42,3 +42,22 @@ class TestDumpsysBatteryHistoryArtifact:
|
||||
assert len(dba.alertstore.alerts) == 0
|
||||
dba.check_indicators()
|
||||
assert len(dba.alertstore.alerts) == 2
|
||||
|
||||
def test_parsing_absolute_timestamps(self):
|
||||
dba = DumpsysBatteryHistoryArtifact()
|
||||
dba.parse(
|
||||
"""07-15 20:27:39.431 (2) 100 +job=u0a123:"com.example/.ExampleJob"
|
||||
07-15 20:27:40.431 (2) 100 -job=u0a123:"com.example/.ExampleJob"
|
||||
"""
|
||||
)
|
||||
|
||||
assert len(dba.results) == 2
|
||||
assert dba.results[0] == {
|
||||
"time_elapsed": "07-15 20:27:39.431",
|
||||
"event": "start_job",
|
||||
"uid": "u0a123",
|
||||
"package_name": "com.example",
|
||||
"service": "com.example/.ExampleJob",
|
||||
}
|
||||
assert dba.results[1]["event"] == "end_job"
|
||||
assert dba.results[1]["uid"] == "u0a123"
|
||||
@@ -40,3 +40,22 @@ class TestDumpsysDBinfoArtifact:
|
||||
assert len(dbi.alertstore.alerts) == 0
|
||||
dbi.check_indicators()
|
||||
assert len(dbi.alertstore.alerts) == 5
|
||||
|
||||
def test_parsing_month_day_timestamp_without_pid(self):
|
||||
dbi = DumpsysDBInfoArtifact()
|
||||
dbi.parse(
|
||||
"""
|
||||
Connection pool for /data/user/0/com.example/databases/current.db:
|
||||
Most recently executed operations:
|
||||
0: [07-15 20:27:39.431] executeForCursorWindow took 1ms - succeeded, sql="SELECT 1"
|
||||
"""
|
||||
)
|
||||
|
||||
assert dbi.results == [
|
||||
{
|
||||
"isodate": "07-15 20:27:39.431",
|
||||
"action": "executeForCursorWindow",
|
||||
"sql": "SELECT 1",
|
||||
"path": "/data/user/0/com.example/databases/current.db",
|
||||
}
|
||||
]
|
||||
@@ -31,6 +31,44 @@ class TestDumpsysReceiversArtifact:
|
||||
== "com.android.storagemanager"
|
||||
)
|
||||
|
||||
def test_parsing_misindented_action(self):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
data = """\
|
||||
Receiver Resolver Table:
|
||||
Non-Data Actions:
|
||||
android.app.action.ENTER_CAR_MODE:
|
||||
b5b40f6 com.google.android.projection.gearhead/.CarModeBroadcastReceiver
|
||||
android.intent.action.MY_PACKAGE_REPLACED:
|
||||
e7706c1 com.psycatgames.nhiegame/.ScheduledNotificationBootReceiver
|
||||
"""
|
||||
|
||||
dr.parse(data)
|
||||
|
||||
assert (
|
||||
dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][
|
||||
"package_name"
|
||||
]
|
||||
== "com.psycatgames.nhiegame"
|
||||
)
|
||||
|
||||
def test_parsing_misindented_first_action(self):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
data = """\
|
||||
Receiver Resolver Table:
|
||||
Non-Data Actions:
|
||||
android.intent.action.MY_PACKAGE_REPLACED:
|
||||
e7706c1 com.psycatgames.nhiegame/.ScheduledNotificationBootReceiver
|
||||
"""
|
||||
|
||||
dr.parse(data)
|
||||
|
||||
assert (
|
||||
dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][
|
||||
"package_name"
|
||||
]
|
||||
== "com.psycatgames.nhiegame"
|
||||
)
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
dr = DumpsysReceiversArtifact()
|
||||
file = get_artifact("android_data/dumpsys_packages.txt")
|
||||
|
||||
@@ -42,6 +42,32 @@ class TestTombstoneCrashArtifact:
|
||||
assert len(tombstone_artifact.results) == 1
|
||||
self.validate_tombstone_result(tombstone_artifact.results[0])
|
||||
|
||||
def test_text_tombstone_keeps_crashing_thread(self):
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
artifact_path = "android_data/tombstone_process.txt"
|
||||
file = get_artifact(artifact_path)
|
||||
with open(file, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
data += (
|
||||
b"\npid: 25541, tid: 31896, name: worker-thread"
|
||||
b" >>> /vendor/bin/other <<<\n"
|
||||
)
|
||||
tombstone_artifact.parse(
|
||||
os.path.basename(artifact_path),
|
||||
datetime.datetime(2023, 4, 12, 12, 32, 40, 518290),
|
||||
data,
|
||||
)
|
||||
|
||||
result = tombstone_artifact.results[0]
|
||||
assert result["pid"] == 25541
|
||||
assert result["tid"] == 21307
|
||||
assert result["process_name"] == "mtk.ape.decoder"
|
||||
assert (
|
||||
result["binary_path"]
|
||||
== "/vendor/bin/hw/android.hardware.media.c2@1.2-mediatek"
|
||||
)
|
||||
|
||||
@pytest.mark.skip(reason="Not implemented yet")
|
||||
def test_tombtone_kernel_parsing(self):
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from mvt.android.parsers.backup import (
|
||||
AndroidBackupParsingError,
|
||||
parse_ab_header,
|
||||
parse_backup_file,
|
||||
parse_tar_for_sms,
|
||||
@@ -23,6 +26,14 @@ class TestBackupParsing:
|
||||
"encryption": None,
|
||||
}
|
||||
|
||||
def test_parse_truncated_encrypted_header(self):
|
||||
with pytest.raises(
|
||||
AndroidBackupParsingError, match="Invalid encrypted backup header"
|
||||
):
|
||||
parse_backup_file(
|
||||
b"ANDROID BACKUP\n5\n0\nAES-256\ntruncated", password="password"
|
||||
)
|
||||
|
||||
def test_parsing_noencryption(self):
|
||||
file = get_artifact("android_backup/backup.ab")
|
||||
with open(file, "rb") as f:
|
||||
|
||||
@@ -50,6 +50,10 @@ class CustomIOSFSModule(RecordingModule):
|
||||
supported_commands = (("ios", "check-fs"),)
|
||||
|
||||
|
||||
class UnscopedCustomModule(RecordingModule):
|
||||
pass
|
||||
|
||||
|
||||
class CustomDependsOnBuiltin(RecordingModule):
|
||||
supported_commands = (("ios", "check-backup"),)
|
||||
dependencies = (FirstModule,)
|
||||
@@ -151,7 +155,11 @@ class TestCommand:
|
||||
cmd.platform = "ios"
|
||||
cmd.name = "check-backup"
|
||||
cmd.modules = [FirstModule]
|
||||
cmd.custom_modules = [CustomIOSBackupModule, CustomIOSFSModule]
|
||||
cmd.custom_modules = [
|
||||
CustomIOSBackupModule,
|
||||
CustomIOSFSModule,
|
||||
UnscopedCustomModule,
|
||||
]
|
||||
|
||||
assert [module.__name__ for module in cmd._ordered_modules()] == [
|
||||
"FirstModule",
|
||||
|
||||
@@ -125,12 +125,13 @@ def test_load_custom_modules_loads_env_folder_first(tmp_path, monkeypatch):
|
||||
assert [module.__name__ for module in modules] == ["EnvModule", "CliModule"]
|
||||
|
||||
|
||||
def test_module_supports_command_defaults_to_all_commands(tmp_path):
|
||||
def test_module_supports_command_requires_explicit_declaration(tmp_path, caplog):
|
||||
module_path = _write_module(tmp_path / "custom.py", "DefaultModule")
|
||||
module = load_custom_modules_from_path(str(module_path))[0]
|
||||
|
||||
assert module_supports_command(module, "ios", "check-backup")
|
||||
assert module_supports_command(module, "android", "check-bugreport")
|
||||
assert not module_supports_command(module, "ios", "check-backup")
|
||||
assert not module_supports_command(module, "android", "check-bugreport")
|
||||
assert "DefaultModule has no supported_commands" in caplog.text
|
||||
|
||||
|
||||
def test_module_supports_command_honors_supported_commands(tmp_path):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import run_module
|
||||
@@ -21,6 +22,18 @@ class TestCalendarModule:
|
||||
assert len(m.alertstore.alerts) == 0
|
||||
assert m.results[0]["summary"] == "Super interesting meeting"
|
||||
|
||||
def test_calendar_with_explicit_file_path(self):
|
||||
backup_path = get_ios_backup_folder()
|
||||
database_path = os.path.join(
|
||||
backup_path, "20", "2041457d5fe04d39d0ab481178355df6781e6858"
|
||||
)
|
||||
m = Calendar(file_path=database_path)
|
||||
|
||||
run_module(m)
|
||||
|
||||
assert len(m.results) == 1
|
||||
assert m.results[0]["summary"] == "Super interesting meeting"
|
||||
|
||||
def test_calendar_detection(self, indicator_file):
|
||||
m = Calendar(target_path=get_ios_backup_folder())
|
||||
ind = Indicators(log=logging.getLogger())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,85 @@
|
||||
# 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 hashlib
|
||||
import os
|
||||
import plistlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
|
||||
from mvt.ios.modules.base import IOSExtraction
|
||||
from mvt.ios.modules.fs.analytics import Analytics
|
||||
|
||||
|
||||
def _sha256(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def test_open_sqlite_reads_wal_without_modifying_evidence(tmp_path):
|
||||
live_path = tmp_path / "live.db"
|
||||
evidence_path = tmp_path / "evidence.db"
|
||||
conn = sqlite3.connect(live_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA wal_autocheckpoint=0;")
|
||||
conn.execute("CREATE TABLE records (value TEXT);")
|
||||
conn.commit()
|
||||
conn.execute("INSERT INTO records VALUES ('from wal');")
|
||||
conn.commit()
|
||||
|
||||
shutil.copy2(live_path, evidence_path)
|
||||
shutil.copy2(str(live_path) + "-wal", str(evidence_path) + "-wal")
|
||||
conn.close()
|
||||
|
||||
evidence_hash = _sha256(evidence_path)
|
||||
wal_hash = _sha256(tmp_path / "evidence.db-wal")
|
||||
module = IOSExtraction(file_path=str(evidence_path))
|
||||
read_conn = module._open_sqlite_db(str(evidence_path))
|
||||
rows = read_conn.execute("SELECT value FROM records;").fetchall()
|
||||
read_conn.close()
|
||||
|
||||
assert rows == [("from wal",)]
|
||||
assert _sha256(evidence_path) == evidence_hash
|
||||
assert _sha256(tmp_path / "evidence.db-wal") == wal_hash
|
||||
assert not os.path.exists(str(evidence_path) + "-shm")
|
||||
|
||||
|
||||
def test_recovery_preserves_source_database(tmp_path):
|
||||
database_path = tmp_path / "source.db"
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.execute("CREATE TABLE records (value TEXT);")
|
||||
conn.execute("INSERT INTO records VALUES ('preserved');")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
source_hash = _sha256(database_path)
|
||||
|
||||
module = IOSExtraction(file_path=str(database_path))
|
||||
module._recover_sqlite_db_if_needed(str(database_path), forced=True)
|
||||
recovered_conn = module._open_sqlite_db(str(database_path))
|
||||
rows = recovered_conn.execute("SELECT value FROM records;").fetchall()
|
||||
recovered_conn.close()
|
||||
|
||||
assert rows == [("preserved",)]
|
||||
assert _sha256(database_path) == source_hash
|
||||
assert not os.path.exists(str(database_path) + ".bak")
|
||||
|
||||
|
||||
def test_analytics_skips_empty_rows_and_continues(tmp_path):
|
||||
database_path = tmp_path / "analytics.db"
|
||||
conn = sqlite3.connect(database_path)
|
||||
for table in ("hard_failures", "soft_failures", "all_events"):
|
||||
conn.execute(f"CREATE TABLE {table} (timestamp REAL, data BLOB);")
|
||||
conn.execute("INSERT INTO hard_failures VALUES (NULL, NULL);")
|
||||
conn.execute(
|
||||
"INSERT INTO soft_failures VALUES (?, ?);",
|
||||
(1.0, plistlib.dumps({"event": "valid"})),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
module = Analytics(file_path=str(database_path))
|
||||
module._extract_analytics_data()
|
||||
|
||||
assert len(module.results) == 1
|
||||
assert module.results[0]["event"] == "valid"
|
||||
@@ -6,10 +6,15 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.android.cli import check_androidqf
|
||||
from mvt.android.cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
||||
from mvt.android.modules.androidqf import ANDROIDQF_MODULES
|
||||
from mvt.android.modules.androidqf.aqf_log_timestamps import AQFLogTimestamps
|
||||
from mvt.common.config import settings
|
||||
|
||||
from .utils import get_artifact_folder
|
||||
@@ -18,6 +23,9 @@ TEST_BACKUP_PASSWORD = "123456"
|
||||
|
||||
|
||||
class TestCheckAndroidqfCommand:
|
||||
def test_log_timestamps_module_is_registered(self):
|
||||
assert AQFLogTimestamps in ANDROIDQF_MODULES
|
||||
|
||||
def test_check(self):
|
||||
runner = CliRunner()
|
||||
path = os.path.join(get_artifact_folder(), "androidqf")
|
||||
@@ -89,3 +97,25 @@ class TestCheckAndroidqfCommand:
|
||||
assert not any(
|
||||
record.levelname in {"CRITICAL", "FATAL"} for record in caplog.records
|
||||
)
|
||||
|
||||
def test_intrusion_log_zip_rejects_path_traversal(self, tmp_path, mocker, caplog):
|
||||
escaped_name = f"mvt-escaped-{tmp_path.name}.txt"
|
||||
escaped_path = os.path.join(tempfile.gettempdir(), escaped_name)
|
||||
archive_path = tmp_path / "androidqf.zip"
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr(f"intrusion_logs/../{escaped_name}", "unsafe")
|
||||
archive.writestr("intrusion_logs/safe.txt", "safe")
|
||||
|
||||
nested_command = mocker.patch(
|
||||
"mvt.android.cmd_check_androidqf.CmdAndroidCheckIntrusionLogs"
|
||||
)
|
||||
nested_command.return_value.timeline = []
|
||||
nested_command.return_value.alertstore.alerts = []
|
||||
command = CmdAndroidCheckAndroidQF(target_path=str(archive_path))
|
||||
command.init()
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert command.run_intrusion_logs_cmd() is True
|
||||
|
||||
assert not os.path.exists(escaped_path)
|
||||
assert "Skipping unsafe intrusion log archive entry" in caplog.text
|
||||
@@ -18,3 +18,13 @@ class TestCheckBugreportCommand:
|
||||
path = os.path.join(get_artifact_folder(), "android_data/bugreport/")
|
||||
result = runner.invoke(check_bugreport, [path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_invalid_zip_reports_clean_error(self, tmp_path):
|
||||
path = tmp_path / "invalid.zip"
|
||||
path.write_bytes(b"not a zip archive")
|
||||
|
||||
result = CliRunner().invoke(check_bugreport, [str(path)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid bugreport archive" in result.output
|
||||
assert "Traceback" not in result.output
|
||||
@@ -0,0 +1,57 @@
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.ios.cli import check_sysdiagnose
|
||||
|
||||
|
||||
CUSTOM_MODULE = """
|
||||
from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
|
||||
|
||||
|
||||
class CustomSysdiagnoseModule(SysdiagnoseExtraction):
|
||||
supported_commands = (("ios", "check-sysdiagnose"),)
|
||||
slug = "custom_sysdiagnose_module"
|
||||
|
||||
def run(self):
|
||||
file_path = self._get_files_by_pattern("*/artifact.txt")[0]
|
||||
self.results = [{"content": self._get_file_content(file_path).decode("utf-8")}]
|
||||
|
||||
def check_indicators(self):
|
||||
pass
|
||||
|
||||
def serialize(self, result):
|
||||
return None
|
||||
"""
|
||||
|
||||
|
||||
def _create_sysdiagnose_folder(tmp_path):
|
||||
folder = tmp_path / "sysdiagnose"
|
||||
folder.mkdir()
|
||||
(folder / "artifact.txt").write_text("artifact", encoding="utf-8")
|
||||
return folder
|
||||
|
||||
|
||||
def test_check_sysdiagnose_runs_explicitly_scoped_custom_module(tmp_path):
|
||||
module_path = tmp_path / "custom_sysdiagnose.py"
|
||||
module_path.write_text(CUSTOM_MODULE, encoding="utf-8")
|
||||
output_path = tmp_path / "output"
|
||||
|
||||
result = CliRunner().invoke(
|
||||
check_sysdiagnose,
|
||||
[
|
||||
"--load-module",
|
||||
str(module_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
str(_create_sysdiagnose_folder(tmp_path)),
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
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))])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "No custom modules support mvt-ios check-sysdiagnose" in result.output
|
||||
@@ -0,0 +1,75 @@
|
||||
import tarfile
|
||||
from datetime import timedelta
|
||||
|
||||
from mvt.ios.cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
|
||||
from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
|
||||
|
||||
|
||||
class SysdiagnoseTestModule(SysdiagnoseExtraction):
|
||||
supported_commands = (("ios", "check-sysdiagnose"),)
|
||||
|
||||
def run(self):
|
||||
file_path = self._get_files_by_pattern("*/artifact.txt")[0]
|
||||
self.results = [
|
||||
{
|
||||
"content": self._get_file_content(file_path).decode("utf-8"),
|
||||
"timezone_offset": self._extract_timezone().utcoffset(None).seconds,
|
||||
}
|
||||
]
|
||||
|
||||
def check_indicators(self):
|
||||
pass
|
||||
|
||||
def serialize(self, result):
|
||||
return None
|
||||
|
||||
|
||||
def _create_sysdiagnose_folder(tmp_path):
|
||||
folder = tmp_path / "sysdiagnose"
|
||||
folder.mkdir()
|
||||
(folder / "artifact.txt").write_text("artifact", encoding="utf-8")
|
||||
(folder / "sysdiagnose.log").write_text(
|
||||
"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")
|
||||
return folder
|
||||
|
||||
|
||||
def _create_sysdiagnose_archive(tmp_path, folder):
|
||||
archive_path = tmp_path / "sysdiagnose.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
for path in folder.iterdir():
|
||||
archive.add(path, arcname=f"sysdiagnose/{path.name}")
|
||||
return archive_path
|
||||
|
||||
|
||||
def _run_command(path):
|
||||
command = CmdIOSCheckSysdiagnose(
|
||||
target_path=str(path), custom_modules=[SysdiagnoseTestModule]
|
||||
)
|
||||
command.run()
|
||||
return command
|
||||
|
||||
|
||||
def test_check_sysdiagnose_from_folder(tmp_path):
|
||||
command = _run_command(_create_sysdiagnose_folder(tmp_path))
|
||||
|
||||
assert command.executed[0].results == [
|
||||
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
|
||||
]
|
||||
assert command.executed[0].ips_files == [
|
||||
{"file_path": str(tmp_path / "sysdiagnose" / "report.ips"), "bug_type": 210}
|
||||
]
|
||||
|
||||
|
||||
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 == [
|
||||
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
|
||||
]
|
||||
assert command.executed[0].ips_files == [
|
||||
{"file_path": "sysdiagnose/report.ips", "bug_type": 210}
|
||||
]
|
||||
assert command.sysdiagnose_archive is None
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -37,6 +38,22 @@ 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()
|
||||
# Checkpoint the test change so the fixture does not retain SQLite sidecars.
|
||||
conn.execute("PRAGMA journal_mode=DELETE;")
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_tmp_db_files(file_path):
|
||||
"""
|
||||
Remove Sqlite temporary files that appear on some platforms
|
||||
|
||||
Reference in new issue
Block a user