mirror of
https://github.com/mvt-project/mvt.git
synced 2026-09-26 19:21:46 +02:00
Fix module audit findings (#850)
* Fix module audit findings * Always parse paired tombstones
This commit is contained in:
@@ -31,21 +31,38 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact):
|
|||||||
if line.strip() == "":
|
if line.strip() == "":
|
||||||
break
|
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 = ""
|
event = ""
|
||||||
if line.find("+job") > 0:
|
if line.find("+job") > 0:
|
||||||
event = "start_job"
|
event = "start_job"
|
||||||
uid = line[line.find("+job") + 5 : line.find(":")]
|
payload = line.split("+job=", 1)[1]
|
||||||
service = line[line.find(":") + 1 :].strip('"')
|
uid, separator, service = payload.partition(":")
|
||||||
|
if not separator:
|
||||||
|
continue
|
||||||
|
service = service.strip().strip('"')
|
||||||
package_name = service.split("/")[0]
|
package_name = service.split("/")[0]
|
||||||
elif line.find("-job") > 0:
|
elif line.find("-job") > 0:
|
||||||
event = "end_job"
|
event = "end_job"
|
||||||
uid = line[line.find("-job") + 5 : line.find(":")]
|
payload = line.split("-job=", 1)[1]
|
||||||
service = line[line.find(":") + 1 :].strip('"')
|
uid, separator, service = payload.partition(":")
|
||||||
|
if not separator:
|
||||||
|
continue
|
||||||
|
service = service.strip().strip('"')
|
||||||
package_name = service.split("/")[0]
|
package_name = service.split("/")[0]
|
||||||
elif line.find("+running +wake_lock=") > 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"
|
event = "wake"
|
||||||
service = (
|
service = (
|
||||||
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
|
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
|
||||||
|
|||||||
@@ -29,10 +29,9 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
|||||||
|
|
||||||
def parse(self, output: str) -> None:
|
def parse(self, output: str) -> None:
|
||||||
rxp = re.compile(
|
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\=\"(.+?)\""
|
r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} "
|
||||||
) # pylint: disable=line-too-long
|
r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*"
|
||||||
rxp_no_pid = re.compile(
|
r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\""
|
||||||
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\=\"(.+?)\""
|
|
||||||
) # pylint: disable=line-too-long
|
) # pylint: disable=line-too-long
|
||||||
|
|
||||||
pool = None
|
pool = None
|
||||||
@@ -56,29 +55,16 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
|||||||
pool = None
|
pool = None
|
||||||
continue
|
continue
|
||||||
|
|
||||||
matches = rxp.findall(line)
|
match = rxp.match(line)
|
||||||
if not matches:
|
if not match:
|
||||||
matches = rxp_no_pid.findall(line)
|
continue
|
||||||
if not matches:
|
|
||||||
continue
|
|
||||||
|
|
||||||
match = matches[0]
|
result = {
|
||||||
self.results.append(
|
"isodate": match.group(1),
|
||||||
{
|
"action": match.group(3),
|
||||||
"isodate": match[0],
|
"sql": match.group(4),
|
||||||
"action": match[1],
|
"path": pool,
|
||||||
"sql": match[2],
|
}
|
||||||
"path": pool,
|
if match.group(2):
|
||||||
}
|
result["pid"] = match.group(2)
|
||||||
)
|
self.results.append(result)
|
||||||
else:
|
|
||||||
match = matches[0]
|
|
||||||
self.results.append(
|
|
||||||
{
|
|
||||||
"isodate": match[0],
|
|
||||||
"pid": match[1],
|
|
||||||
"action": match[2],
|
|
||||||
"sql": match[3],
|
|
||||||
"path": pool,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# https://license.mvt.re/1.1/
|
# https://license.mvt.re/1.1/
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from zipfile import BadZipFile
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -212,7 +213,10 @@ def check_bugreport(
|
|||||||
|
|
||||||
log.info("Checking Android bug report at path: %s", bugreport_path)
|
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_alerts_brief()
|
||||||
cmd.show_support_message()
|
cmd.show_support_message()
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
|||||||
|
|
||||||
elif self.__format == "zip" and self.__zip:
|
elif self.__format == "zip" and self.__zip:
|
||||||
temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_")
|
temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_")
|
||||||
|
temp_root = Path(temp_dir).resolve()
|
||||||
for entry in intrusion_log_files:
|
for entry in intrusion_log_files:
|
||||||
normalized = entry.replace("\\", "/")
|
normalized = entry.replace("\\", "/")
|
||||||
idx = normalized.find("intrusion_logs/")
|
idx = normalized.find("intrusion_logs/")
|
||||||
@@ -303,9 +304,15 @@ class CmdAndroidCheckAndroidQF(Command):
|
|||||||
if not relative or relative.endswith("/"):
|
if not relative or relative.endswith("/"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
target = os.path.join(temp_dir, relative)
|
target = (temp_root / relative).resolve()
|
||||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
if not target.is_relative_to(temp_root):
|
||||||
with self.__zip.open(entry) as src, open(target, "wb") as dst:
|
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())
|
dst.write(src.read())
|
||||||
|
|
||||||
intrusion_logs_path = temp_dir
|
intrusion_logs_path = temp_dir
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
from .aqf_files import AQFFiles
|
from .aqf_files import AQFFiles
|
||||||
from .aqf_getprop import AQFGetProp
|
from .aqf_getprop import AQFGetProp
|
||||||
|
from .aqf_log_timestamps import AQFLogTimestamps
|
||||||
from .aqf_packages import AQFPackages
|
from .aqf_packages import AQFPackages
|
||||||
from .aqf_processes import AQFProcesses
|
from .aqf_processes import AQFProcesses
|
||||||
from .aqf_settings import AQFSettings
|
from .aqf_settings import AQFSettings
|
||||||
@@ -17,6 +18,7 @@ ANDROIDQF_MODULES = [
|
|||||||
AQFGetProp,
|
AQFGetProp,
|
||||||
AQFSettings,
|
AQFSettings,
|
||||||
AQFFiles,
|
AQFFiles,
|
||||||
|
AQFLogTimestamps,
|
||||||
RootBinaries,
|
RootBinaries,
|
||||||
Mounts,
|
Mounts,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -131,20 +131,23 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
|
|||||||
if password is None:
|
if password is None:
|
||||||
raise InvalidBackupPassword()
|
raise InvalidBackupPassword()
|
||||||
|
|
||||||
[
|
try:
|
||||||
user_salt,
|
[
|
||||||
checksum_salt,
|
user_salt,
|
||||||
pbkdf2_rounds,
|
checksum_salt,
|
||||||
user_iv,
|
pbkdf2_rounds,
|
||||||
master_key_blob,
|
user_iv,
|
||||||
encrypted_data,
|
master_key_blob,
|
||||||
] = encrypted_backup.split(b"\n", 5)
|
encrypted_data,
|
||||||
|
] = encrypted_backup.split(b"\n", 5)
|
||||||
|
|
||||||
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
|
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
|
||||||
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
|
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
|
||||||
pbkdf2_rounds = int(pbkdf2_rounds)
|
pbkdf2_rounds = int(pbkdf2_rounds)
|
||||||
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
|
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
|
||||||
master_key_blob = bytes.fromhex(master_key_blob.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.
|
# Derive decryption master key from password.
|
||||||
master_key, master_iv = decrypt_master_key(
|
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"):
|
if not data.startswith(b"ANDROID BACKUP"):
|
||||||
raise AndroidBackupParsingError("Invalid file header")
|
raise AndroidBackupParsingError("Invalid file header")
|
||||||
|
|
||||||
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
|
try:
|
||||||
|
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
|
||||||
version = int(version)
|
version = int(version)
|
||||||
is_compressed = int(is_compressed)
|
is_compressed = int(is_compressed)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AndroidBackupParsingError("Invalid file header") from exc
|
||||||
|
|
||||||
if encryption_algo != b"none":
|
if encryption_algo != b"none":
|
||||||
tar_data = decrypt_backup_data(
|
tar_data = decrypt_backup_data(
|
||||||
|
|||||||
+63
-20
@@ -9,6 +9,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator, Optional, Union
|
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):
|
class IOSExtraction(MVTModule):
|
||||||
"""This class provides a base for all iOS filesystem/backup extraction
|
"""This class provides a base for all iOS filesystem/backup extraction
|
||||||
modules."""
|
modules."""
|
||||||
@@ -44,6 +59,8 @@ class IOSExtraction(MVTModule):
|
|||||||
|
|
||||||
self.is_backup = False
|
self.is_backup = False
|
||||||
self.is_fs_dump = 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(
|
def _recover_sqlite_db_if_needed(
|
||||||
self, file_path: str, forced: bool = False
|
self, file_path: str, forced: bool = False
|
||||||
@@ -53,17 +70,12 @@ class IOSExtraction(MVTModule):
|
|||||||
:param file_path: Path to the malformed database file.
|
:param file_path: Path to the malformed database file.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# SQLite's immutable mode cannot open databases with active WAL files.
|
|
||||||
if not forced:
|
if not forced:
|
||||||
# If the database is open, do not use immutable
|
conn = self._open_sqlite_db(file_path)
|
||||||
if os.path.isfile(file_path + "-shm"):
|
|
||||||
conn = sqlite3.connect(file_path)
|
|
||||||
else:
|
|
||||||
conn = self._open_sqlite_db(file_path)
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
recover = False
|
||||||
try:
|
try:
|
||||||
recover = False
|
|
||||||
cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
||||||
except sqlite3.DatabaseError as exc:
|
except sqlite3.DatabaseError as exc:
|
||||||
if "database disk image is malformed" in str(exc):
|
if "database disk image is malformed" in str(exc):
|
||||||
@@ -82,27 +94,54 @@ class IOSExtraction(MVTModule):
|
|||||||
raise DatabaseCorruptedError(
|
raise DatabaseCorruptedError(
|
||||||
"failed to recover without sqlite3 binary: please install sqlite3!"
|
"failed to recover without sqlite3 binary: please install sqlite3!"
|
||||||
)
|
)
|
||||||
if '"' in file_path:
|
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_recover_")
|
||||||
raise DatabaseCorruptedError(
|
temporary_path = Path(temporary_directory.name)
|
||||||
f"database at path '{file_path}' is corrupted. unable to "
|
source_path = temporary_path / "source.db"
|
||||||
'recover because it has a quotation mark (") in its name'
|
recovered_path = temporary_path / "recovered.db"
|
||||||
)
|
shutil.copy2(file_path, source_path)
|
||||||
|
for suffix in ("-wal", "-shm"):
|
||||||
bak_path = f"{file_path}.bak"
|
sidecar = Path(file_path + suffix)
|
||||||
shutil.move(file_path, bak_path)
|
if sidecar.is_file():
|
||||||
|
shutil.copy2(sidecar, Path(str(source_path) + suffix))
|
||||||
|
|
||||||
ret = subprocess.call(
|
ret = subprocess.call(
|
||||||
["sqlite3", bak_path, f'.clone "{file_path}"'],
|
["sqlite3", str(source_path), f'.clone "{recovered_path}"'],
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
|
temporary_directory.cleanup()
|
||||||
raise DatabaseCorruptedError("failed to recover database")
|
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)
|
self.log.info("Database at path %s recovered successfully!", file_path)
|
||||||
|
|
||||||
def _open_sqlite_db(self, file_path: str) -> sqlite3.Connection:
|
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(
|
def _get_backup_files_from_manifest(
|
||||||
self, relative_path: Optional[str] = None, domain: Optional[str] = None
|
self, relative_path: Optional[str] = None, domain: Optional[str] = None
|
||||||
@@ -166,7 +205,11 @@ class IOSExtraction(MVTModule):
|
|||||||
if not self.target_path:
|
if not self.target_path:
|
||||||
return None
|
return None
|
||||||
file_path = os.path.join(self.target_path, file_id[0:2], file_id)
|
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
|
return None
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
return file_path
|
return file_path
|
||||||
@@ -197,9 +240,9 @@ class IOSExtraction(MVTModule):
|
|||||||
:param backup_ids: Default value = None)
|
: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.
|
# 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
|
# Type narrowing: we know self.file_path is None here, work with local file_path
|
||||||
# If not, we first try with backups.
|
# If not, we first try with backups.
|
||||||
# We construct the path to the file according to the iTunes backup
|
# We construct the path to the file according to the iTunes backup
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ class Analytics(IOSExtraction):
|
|||||||
isodate = ""
|
isodate = ""
|
||||||
data = plistlib.loads(row[1])
|
data = plistlib.loads(row[1])
|
||||||
data["isodate"] = isodate
|
data["isodate"] = isodate
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
data["artifact"] = artifact
|
data["artifact"] = artifact
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class NetBase(IOSExtraction):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _extract_net_data(self):
|
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
|
conn.row_factory = sqlite3.Row
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
@@ -237,9 +238,7 @@ class NetBase(IOSExtraction):
|
|||||||
"been truncated in the database)"
|
"been truncated in the database)"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.alertstore.medium(
|
self.alertstore.medium(msg, proc["live_isodate"], proc)
|
||||||
msg, proc["live_isodate"], proc
|
|
||||||
)
|
|
||||||
|
|
||||||
if not proc["live_proc_id"]:
|
if not proc["live_proc_id"]:
|
||||||
self.log.info(
|
self.log.info(
|
||||||
|
|||||||
@@ -42,3 +42,22 @@ class TestDumpsysBatteryHistoryArtifact:
|
|||||||
assert len(dba.alertstore.alerts) == 0
|
assert len(dba.alertstore.alerts) == 0
|
||||||
dba.check_indicators()
|
dba.check_indicators()
|
||||||
assert len(dba.alertstore.alerts) == 2
|
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
|
assert len(dbi.alertstore.alerts) == 0
|
||||||
dbi.check_indicators()
|
dbi.check_indicators()
|
||||||
assert len(dbi.alertstore.alerts) == 5
|
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",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from mvt.android.parsers.backup import (
|
from mvt.android.parsers.backup import (
|
||||||
|
AndroidBackupParsingError,
|
||||||
parse_ab_header,
|
parse_ab_header,
|
||||||
parse_backup_file,
|
parse_backup_file,
|
||||||
parse_tar_for_sms,
|
parse_tar_for_sms,
|
||||||
@@ -23,6 +26,14 @@ class TestBackupParsing:
|
|||||||
"encryption": None,
|
"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):
|
def test_parsing_noencryption(self):
|
||||||
file = get_artifact("android_backup/backup.ab")
|
file = get_artifact("android_backup/backup.ab")
|
||||||
with open(file, "rb") as f:
|
with open(file, "rb") as f:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# https://license.mvt.re/1.1/
|
# https://license.mvt.re/1.1/
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from mvt.common.indicators import Indicators
|
from mvt.common.indicators import Indicators
|
||||||
from mvt.common.module import run_module
|
from mvt.common.module import run_module
|
||||||
@@ -21,6 +22,18 @@ class TestCalendarModule:
|
|||||||
assert len(m.alertstore.alerts) == 0
|
assert len(m.alertstore.alerts) == 0
|
||||||
assert m.results[0]["summary"] == "Super interesting meeting"
|
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):
|
def test_calendar_detection(self, indicator_file):
|
||||||
m = Calendar(target_path=get_ios_backup_folder())
|
m = Calendar(target_path=get_ios_backup_folder())
|
||||||
ind = Indicators(log=logging.getLogger())
|
ind = Indicators(log=logging.getLogger())
|
||||||
|
|||||||
@@ -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 logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from mvt.android.cli import check_androidqf
|
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 mvt.common.config import settings
|
||||||
|
|
||||||
from .utils import get_artifact_folder
|
from .utils import get_artifact_folder
|
||||||
@@ -18,6 +23,9 @@ TEST_BACKUP_PASSWORD = "123456"
|
|||||||
|
|
||||||
|
|
||||||
class TestCheckAndroidqfCommand:
|
class TestCheckAndroidqfCommand:
|
||||||
|
def test_log_timestamps_module_is_registered(self):
|
||||||
|
assert AQFLogTimestamps in ANDROIDQF_MODULES
|
||||||
|
|
||||||
def test_check(self):
|
def test_check(self):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
path = os.path.join(get_artifact_folder(), "androidqf")
|
path = os.path.join(get_artifact_folder(), "androidqf")
|
||||||
@@ -89,3 +97,25 @@ class TestCheckAndroidqfCommand:
|
|||||||
assert not any(
|
assert not any(
|
||||||
record.levelname in {"CRITICAL", "FATAL"} for record in caplog.records
|
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/")
|
path = os.path.join(get_artifact_folder(), "android_data/bugreport/")
|
||||||
result = runner.invoke(check_bugreport, [path])
|
result = runner.invoke(check_bugreport, [path])
|
||||||
assert result.exit_code == 0
|
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
|
||||||
|
|||||||
+1
-2
@@ -49,8 +49,7 @@ def add_backup_manifest_entry(backup_path, file_id, domain, relative_path):
|
|||||||
(file_id, domain, relative_path, b""),
|
(file_id, domain, relative_path, b""),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
# MVT opens backup databases with immutable=1, which ignores any -wal file,
|
# Checkpoint the test change so the fixture does not retain SQLite sidecars.
|
||||||
# so the new row has to be checkpointed into Manifest.db itself.
|
|
||||||
conn.execute("PRAGMA journal_mode=DELETE;")
|
conn.execute("PRAGMA journal_mode=DELETE;")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user