diff --git a/mvt/android/cli.py b/mvt/android/cli.py index 8492ed3..f065c48 100644 --- a/mvt/android/cli.py +++ b/mvt/android/cli.py @@ -4,7 +4,6 @@ # https://license.mvt.re/1.1/ import logging -import os import click @@ -34,11 +33,11 @@ from .modules.adb import ADB_MODULES from .modules.adb.packages import Packages from .modules.backup import BACKUP_MODULES from .modules.bugreport import BUGREPORT_MODULES +from .modules.backup.helpers import cli_load_android_backup_password init_logging() log = logging.getLogger("mvt") -MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD" CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @@ -244,31 +243,28 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_ @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.pass_context -def check_backup(ctx, iocs, output, list_modules, non_interactive, backup_password, verbose, backup_path): +def check_backup( + ctx, + iocs, + output, + list_modules, + non_interactive, + backup_password, + verbose, + backup_path, +): set_verbose_logging(verbose) - if backup_password: - log.info( - "Your password may be visible in the process table because it " - "was supplied on the command line!" - ) - - if MVT_ANDROID_BACKUP_PASSWORD in os.environ: - log.info( - "Ignoring %s environment variable, using --backup-password argument instead", - MVT_ANDROID_BACKUP_PASSWORD, - ) - elif MVT_ANDROID_BACKUP_PASSWORD in os.environ: - log.info("Using backup password from %s environment variable", MVT_ANDROID_BACKUP_PASSWORD) - backup_password = os.environ[MVT_ANDROID_BACKUP_PASSWORD] - # Always generate hashes as backups are generally small. cmd = CmdAndroidCheckBackup( target_path=backup_path, results_path=output, ioc_files=iocs, hashes=True, - module_options={"interactive": not non_interactive, "backup_password": backup_password}, + module_options={ + "interactive": not non_interactive, + "backup_password": cli_load_android_backup_password(log, backup_password), + }, ) if list_modules: @@ -312,32 +308,29 @@ def check_backup(ctx, iocs, output, list_modules, non_interactive, backup_passwo @click.argument("ANDROIDQF_PATH", type=click.Path(exists=True)) @click.pass_context def check_androidqf( - ctx, iocs, output, list_modules, module, hashes, non_interactive, backup_password, verbose, androidqf_path + ctx, + iocs, + output, + list_modules, + module, + hashes, + non_interactive, + backup_password, + verbose, + androidqf_path, ): set_verbose_logging(verbose) - if backup_password: - log.info( - "Your password may be visible in the process table because it " - "was supplied on the command line!" - ) - - if MVT_ANDROID_BACKUP_PASSWORD in os.environ: - log.info( - "Ignoring %s environment variable, using --backup-password argument instead", - MVT_ANDROID_BACKUP_PASSWORD, - ) - elif MVT_ANDROID_BACKUP_PASSWORD in os.environ: - log.info("Using backup password from %s environment variable", MVT_ANDROID_BACKUP_PASSWORD) - backup_password = os.environ[MVT_ANDROID_BACKUP_PASSWORD] - cmd = CmdAndroidCheckAndroidQF( target_path=androidqf_path, results_path=output, ioc_files=iocs, module_name=module, hashes=hashes, - module_options={"interactive": not non_interactive, "backup_password": backup_password}, + module_options={ + "interactive": not non_interactive, + "backup_password": cli_load_android_backup_password(log, backup_password), + }, ) if list_modules: diff --git a/mvt/android/cmd_check_backup.py b/mvt/android/cmd_check_backup.py index 1949311..8d3a95d 100644 --- a/mvt/android/cmd_check_backup.py +++ b/mvt/android/cmd_check_backup.py @@ -11,8 +11,6 @@ import tarfile from pathlib import Path from typing import List, Optional -from rich.prompt import Prompt - from mvt.android.modules.backup.base import BackupExtraction from mvt.android.parsers.backup import ( AndroidBackupParsingError, @@ -20,6 +18,7 @@ from mvt.android.parsers.backup import ( parse_ab_header, parse_backup_file, ) +from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password from mvt.common.command import Command from .modules.backup import BACKUP_MODULES @@ -72,7 +71,12 @@ class CmdAndroidCheckBackup(Command): password = None if header["encryption"] != "none": - password = Prompt.ask("Enter backup password", password=True) + password = prompt_or_load_android_backup_password( + log, self.module_options + ) + if not password: + log.critical("No backup password provided.") + return try: tardata = parse_backup_file(data, password=password) except InvalidBackupPassword: diff --git a/mvt/android/modules/adb/base.py b/mvt/android/modules/adb/base.py index 933cdba..13640c7 100644 --- a/mvt/android/modules/adb/base.py +++ b/mvt/android/modules/adb/base.py @@ -22,7 +22,6 @@ from adb_shell.exceptions import ( UsbDeviceNotFoundError, UsbReadFailedError, ) -from rich.prompt import Prompt from usb1 import USBErrorAccess, USBErrorBusy from mvt.android.parsers.backup import ( @@ -30,6 +29,7 @@ from mvt.android.parsers.backup import ( parse_ab_header, parse_backup_file, ) +from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password from mvt.common.module import InsufficientPrivileges, MVTModule ADB_KEY_PATH = os.path.expanduser("~/.android/adbkey") @@ -311,6 +311,12 @@ class AndroidExtraction(MVTModule): "You may need to set a backup password. \a" ) + if self.module_options.get("backup_password", None): + self.log.notice( + "Backup password already set from command line or environment " + "variable. You should use the same password if enabling encryption!" + ) + # TODO: Base64 encoding as temporary fix to avoid byte-mangling over # the shell transport... cmd = f"/system/bin/bu backup -nocompress '{package_name}' | base64" @@ -329,7 +335,12 @@ class AndroidExtraction(MVTModule): return parse_backup_file(backup_output, password=None) for _ in range(0, 3): - backup_password = Prompt.ask("Enter backup password", password=True) + backup_password = prompt_or_load_android_backup_password( + self.log, self.module_options + ) + if not backup_password: + # Fail as no backup password loaded for this encrypted backup + self.log.critical("No backup password provided.") try: decrypted_backup_tar = parse_backup_file(backup_output, backup_password) return decrypted_backup_tar diff --git a/mvt/android/modules/androidqf/sms.py b/mvt/android/modules/androidqf/sms.py index 07ad75d..a8ac512 100644 --- a/mvt/android/modules/androidqf/sms.py +++ b/mvt/android/modules/androidqf/sms.py @@ -4,7 +4,6 @@ # https://license.mvt.re/1.1 import logging -from rich.prompt import Prompt from typing import Optional from mvt.android.parsers.backup import ( @@ -14,6 +13,7 @@ from mvt.android.parsers.backup import ( parse_backup_file, parse_tar_for_sms, ) +from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password from .base import AndroidQFModule @@ -56,25 +56,19 @@ class SMS(AndroidQFModule): self.log.critical("Invalid backup format, backup.ab was not analysed") return - # the default is to allow interactivity - interactive = "interactive" not in self.module_options or self.module_options["interactive"] password = None if header["encryption"] != "none": - if "backup_password" in self.module_options: - password = self.module_options["backup_password"] - elif interactive: - password = Prompt.ask(prompt="Enter backup password", password=True) - else: - self.log.warning( - "Cannot decrypt backup, because interactivity" - " was disabled and the password was not" - " supplied" - ) + password = prompt_or_load_android_backup_password( + self.log, self.module_options + ) + if not password: + self.log.critical("No backup password provided.") + return + try: tardata = parse_backup_file(data, password=password) except InvalidBackupPassword: - if "backup_password" in self.module_options or interactive: - self.log.critical("Invalid backup password") + self.log.critical("Invalid backup password") return except AndroidBackupParsingError: self.log.critical( diff --git a/mvt/android/modules/backup/helpers.py b/mvt/android/modules/backup/helpers.py new file mode 100644 index 0000000..ee98823 --- /dev/null +++ b/mvt/android/modules/backup/helpers.py @@ -0,0 +1,61 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2023 Claudio Guarnieri. +# Use of this software is governed by the MVT License 1.1 that can be found at +# https://license.mvt.re/1.1/ + +import os + +from rich.prompt import Prompt + + +MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD" + + +def cli_load_android_backup_password(log, backup_password): + """ + Helper to load a backup password from CLI argument or environment variable + + Used in MVT CLI command parsers. + """ + password_from_env = os.environ.get(MVT_ANDROID_BACKUP_PASSWORD, None) + if backup_password: + log.info( + "Your password may be visible in the process table because it " + "was supplied on the command line!" + ) + if password_from_env: + log.info( + "Ignoring %s environment variable, using --backup-password argument instead", + MVT_ANDROID_BACKUP_PASSWORD, + ) + return backup_password + elif password_from_env: + log.info( + "Using backup password from %s environment variable", + MVT_ANDROID_BACKUP_PASSWORD, + ) + return password_from_env + + +def prompt_or_load_android_backup_password(log, module_options): + """ + Used in modules to either prompt or load backup password to use for encryption and decryption. + """ + if module_options.get("backup_password", None): + backup_password = module_options["backup_password"] + log.info( + "Using backup password passed from command line or environment variable." + ) + + # The default is to allow interactivity + elif module_options.get("interactive", True): + backup_password = Prompt.ask(prompt="Enter backup password", password=True) + else: + log.critical( + "Cannot decrypt backup because interactivity" + " was disabled and the password was not" + " supplied" + ) + return None + + return backup_password diff --git a/tests/android_androidqf/test_sms.py b/tests/android_androidqf/test_sms.py index 6c684b7..df69f0f 100644 --- a/tests/android_androidqf/test_sms.py +++ b/tests/android_androidqf/test_sms.py @@ -11,6 +11,8 @@ from mvt.common.module import run_module from ..utils import get_artifact_folder +TEST_BACKUP_PASSWORD = "123456" + class TestAndroidqfSMSAnalysis: def test_androidqf_sms(self): @@ -21,3 +23,50 @@ class TestAndroidqfSMSAnalysis: assert len(m.results) == 2 assert len(m.timeline) == 0 assert len(m.detected) == 0 + + def test_androidqf_sms_encrypted_password_valid(self): + m = SMS( + target_path=os.path.join(get_artifact_folder(), "androidqf_encrypted"), + log=logging, + module_options={"backup_password": TEST_BACKUP_PASSWORD}, + ) + run_module(m) + assert len(m.results) == 1 + + def test_androidqf_sms_encrypted_password_prompt(self, mocker): + prompt_mock = mocker.patch( + "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + ) + m = SMS( + target_path=os.path.join(get_artifact_folder(), "androidqf_encrypted"), + log=logging, + module_options={}, + ) + run_module(m) + assert prompt_mock.call_count == 1 + assert len(m.results) == 1 + + def test_androidqf_sms_encrypted_password_invalid(self, caplog): + with caplog.at_level(logging.CRITICAL): + m = SMS( + target_path=os.path.join(get_artifact_folder(), "androidqf_encrypted"), + log=logging, + module_options={"backup_password": "invalid_password"}, + ) + run_module(m) + assert len(m.results) == 0 + assert "Invalid backup password" in caplog.text + + def test_androidqf_sms_encrypted_no_interactive(self, caplog): + with caplog.at_level(logging.CRITICAL): + m = SMS( + target_path=os.path.join(get_artifact_folder(), "androidqf_encrypted"), + log=logging, + module_options={"interactive": False}, + ) + run_module(m) + assert len(m.results) == 0 + assert ( + "Cannot decrypt backup because interactivity was disabled and the password was not supplied" + in caplog.text + ) diff --git a/tests/artifacts/androidqf_encrypted/backup.ab b/tests/artifacts/androidqf_encrypted/backup.ab new file mode 100644 index 0000000..3a2a90d Binary files /dev/null and b/tests/artifacts/androidqf_encrypted/backup.ab differ diff --git a/tests/test_check_android_androidqf.py b/tests/test_check_android_androidqf.py index dbdf9d8..83a2fa8 100644 --- a/tests/test_check_android_androidqf.py +++ b/tests/test_check_android_androidqf.py @@ -12,9 +12,55 @@ from mvt.android.cli import check_androidqf from .utils import get_artifact_folder +TEST_BACKUP_PASSWORD = "123456" + + class TestCheckAndroidqfCommand: def test_check(self): runner = CliRunner() path = os.path.join(get_artifact_folder(), "androidqf") result = runner.invoke(check_androidqf, [path]) assert result.exit_code == 0 + + def test_check_encrypted_backup_prompt_valid(self, mocker): + """Prompt for password on CLI""" + prompt_mock = mocker.patch( + "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + ) + + runner = CliRunner() + path = os.path.join(get_artifact_folder(), "androidqf_encrypted") + result = runner.invoke(check_androidqf, [path]) + + assert prompt_mock.call_count == 1 + assert result.exit_code == 0 + + def test_check_encrypted_backup_cli(self, mocker): + """Provide password as CLI argument""" + prompt_mock = mocker.patch( + "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + ) + + runner = CliRunner() + path = os.path.join(get_artifact_folder(), "androidqf_encrypted") + result = runner.invoke( + check_androidqf, ["--backup-password", TEST_BACKUP_PASSWORD, path] + ) + + assert prompt_mock.call_count == 0 + assert result.exit_code == 0 + + def test_check_encrypted_backup_env(self, mocker): + """Provide password as environment variable""" + prompt_mock = mocker.patch( + "rich.prompt.Prompt.ask", return_value=TEST_BACKUP_PASSWORD + ) + + os.environ["MVT_ANDROID_BACKUP_PASSWORD"] = TEST_BACKUP_PASSWORD + runner = CliRunner() + path = os.path.join(get_artifact_folder(), "androidqf_encrypted") + result = runner.invoke(check_androidqf, [path]) + + assert prompt_mock.call_count == 0 + assert result.exit_code == 0 + del os.environ["MVT_ANDROID_BACKUP_PASSWORD"]