From a2386dbdf7dbf05361c7a4b09e67fb6a2a2274cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Sat, 22 Jul 2023 19:17:27 +0200 Subject: [PATCH] Refactor Android backup password handling and add tests --- mvt/android/cli.py | 65 ++++++++---------- mvt/android/cmd_check_backup.py | 10 ++- mvt/android/modules/adb/base.py | 15 +++- mvt/android/modules/androidqf/sms.py | 24 +++---- mvt/android/modules/backup/helpers.py | 61 ++++++++++++++++ tests/android_androidqf/test_sms.py | 49 +++++++++++++ tests/artifacts/androidqf_encrypted/backup.ab | Bin 0 -> 5653 bytes tests/test_check_android_androidqf.py | 46 +++++++++++++ 8 files changed, 214 insertions(+), 56 deletions(-) create mode 100644 mvt/android/modules/backup/helpers.py create mode 100644 tests/artifacts/androidqf_encrypted/backup.ab 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 0000000000000000000000000000000000000000..3a2a90d36ee62739b6de0e00d5310cc2cd0dde92 GIT binary patch literal 5653 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 vw1zVpAE8iN_mC!tp4FrXE;pxi