Refactor Android backup password handling and add tests

This commit is contained in:
Donncha Ó Cearbhaill 2023-07-22 19:17:27 +02:00
parent 019cfbb84e
commit a2386dbdf7
8 changed files with 214 additions and 56 deletions

View File

@ -4,7 +4,6 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging import logging
import os
import click import click
@ -34,11 +33,11 @@ from .modules.adb import ADB_MODULES
from .modules.adb.packages import Packages from .modules.adb.packages import Packages
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
from .modules.bugreport import BUGREPORT_MODULES from .modules.bugreport import BUGREPORT_MODULES
from .modules.backup.helpers import cli_load_android_backup_password
init_logging() init_logging()
log = logging.getLogger("mvt") log = logging.getLogger("mvt")
MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD"
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) 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.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context @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) 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. # Always generate hashes as backups are generally small.
cmd = CmdAndroidCheckBackup( cmd = CmdAndroidCheckBackup(
target_path=backup_path, target_path=backup_path,
results_path=output, results_path=output,
ioc_files=iocs, ioc_files=iocs,
hashes=True, 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: 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.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_androidqf( 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) 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( cmd = CmdAndroidCheckAndroidQF(
target_path=androidqf_path, target_path=androidqf_path,
results_path=output, results_path=output,
ioc_files=iocs, ioc_files=iocs,
module_name=module, module_name=module,
hashes=hashes, 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: if list_modules:

View File

@ -11,8 +11,6 @@ import tarfile
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from rich.prompt import Prompt
from mvt.android.modules.backup.base import BackupExtraction from mvt.android.modules.backup.base import BackupExtraction
from mvt.android.parsers.backup import ( from mvt.android.parsers.backup import (
AndroidBackupParsingError, AndroidBackupParsingError,
@ -20,6 +18,7 @@ from mvt.android.parsers.backup import (
parse_ab_header, parse_ab_header,
parse_backup_file, parse_backup_file,
) )
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
from mvt.common.command import Command from mvt.common.command import Command
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
@ -72,7 +71,12 @@ class CmdAndroidCheckBackup(Command):
password = None password = None
if header["encryption"] != "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: try:
tardata = parse_backup_file(data, password=password) tardata = parse_backup_file(data, password=password)
except InvalidBackupPassword: except InvalidBackupPassword:

View File

@ -22,7 +22,6 @@ from adb_shell.exceptions import (
UsbDeviceNotFoundError, UsbDeviceNotFoundError,
UsbReadFailedError, UsbReadFailedError,
) )
from rich.prompt import Prompt
from usb1 import USBErrorAccess, USBErrorBusy from usb1 import USBErrorAccess, USBErrorBusy
from mvt.android.parsers.backup import ( from mvt.android.parsers.backup import (
@ -30,6 +29,7 @@ from mvt.android.parsers.backup import (
parse_ab_header, parse_ab_header,
parse_backup_file, parse_backup_file,
) )
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
from mvt.common.module import InsufficientPrivileges, MVTModule from mvt.common.module import InsufficientPrivileges, MVTModule
ADB_KEY_PATH = os.path.expanduser("~/.android/adbkey") ADB_KEY_PATH = os.path.expanduser("~/.android/adbkey")
@ -311,6 +311,12 @@ class AndroidExtraction(MVTModule):
"You may need to set a backup password. \a" "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 # TODO: Base64 encoding as temporary fix to avoid byte-mangling over
# the shell transport... # the shell transport...
cmd = f"/system/bin/bu backup -nocompress '{package_name}' | base64" 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) return parse_backup_file(backup_output, password=None)
for _ in range(0, 3): 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: try:
decrypted_backup_tar = parse_backup_file(backup_output, backup_password) decrypted_backup_tar = parse_backup_file(backup_output, backup_password)
return decrypted_backup_tar return decrypted_backup_tar

View File

@ -4,7 +4,6 @@
# https://license.mvt.re/1.1 # https://license.mvt.re/1.1
import logging import logging
from rich.prompt import Prompt
from typing import Optional from typing import Optional
from mvt.android.parsers.backup import ( from mvt.android.parsers.backup import (
@ -14,6 +13,7 @@ from mvt.android.parsers.backup import (
parse_backup_file, parse_backup_file,
parse_tar_for_sms, parse_tar_for_sms,
) )
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
from .base import AndroidQFModule from .base import AndroidQFModule
@ -56,25 +56,19 @@ class SMS(AndroidQFModule):
self.log.critical("Invalid backup format, backup.ab was not analysed") self.log.critical("Invalid backup format, backup.ab was not analysed")
return return
# the default is to allow interactivity
interactive = "interactive" not in self.module_options or self.module_options["interactive"]
password = None password = None
if header["encryption"] != "none": if header["encryption"] != "none":
if "backup_password" in self.module_options: password = prompt_or_load_android_backup_password(
password = self.module_options["backup_password"] self.log, self.module_options
elif interactive: )
password = Prompt.ask(prompt="Enter backup password", password=True) if not password:
else: self.log.critical("No backup password provided.")
self.log.warning( return
"Cannot decrypt backup, because interactivity"
" was disabled and the password was not"
" supplied"
)
try: try:
tardata = parse_backup_file(data, password=password) tardata = parse_backup_file(data, password=password)
except InvalidBackupPassword: except InvalidBackupPassword:
if "backup_password" in self.module_options or interactive: self.log.critical("Invalid backup password")
self.log.critical("Invalid backup password")
return return
except AndroidBackupParsingError: except AndroidBackupParsingError:
self.log.critical( self.log.critical(

View File

@ -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

View File

@ -11,6 +11,8 @@ from mvt.common.module import run_module
from ..utils import get_artifact_folder from ..utils import get_artifact_folder
TEST_BACKUP_PASSWORD = "123456"
class TestAndroidqfSMSAnalysis: class TestAndroidqfSMSAnalysis:
def test_androidqf_sms(self): def test_androidqf_sms(self):
@ -21,3 +23,50 @@ class TestAndroidqfSMSAnalysis:
assert len(m.results) == 2 assert len(m.results) == 2
assert len(m.timeline) == 0 assert len(m.timeline) == 0
assert len(m.detected) == 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
)

Binary file not shown.

View File

@ -12,9 +12,55 @@ from mvt.android.cli import check_androidqf
from .utils import get_artifact_folder from .utils import get_artifact_folder
TEST_BACKUP_PASSWORD = "123456"
class TestCheckAndroidqfCommand: class TestCheckAndroidqfCommand:
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")
result = runner.invoke(check_androidqf, [path]) result = runner.invoke(check_androidqf, [path])
assert result.exit_code == 0 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"]