diff --git a/src/mvt/android/artifacts/artifact.py b/src/mvt/android/artifacts/artifact.py index 1fb3e24..a5df7b7 100644 --- a/src/mvt/android/artifacts/artifact.py +++ b/src/mvt/android/artifacts/artifact.py @@ -2,22 +2,30 @@ # 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 typing import AnyStr from mvt.common.artifact import Artifact class AndroidArtifact(Artifact): @staticmethod - def extract_dumpsys_section(dumpsys: str, separator: str) -> str: + def extract_dumpsys_section( + dumpsys: AnyStr, separator: AnyStr, binary=False + ) -> AnyStr: """ Extract a section from a full dumpsys file. - :param dumpsys: content of the full dumpsys file (string) - :param separator: content of the first line separator (string) - :return: section extracted (string) + :param dumpsys: content of the full dumpsys file (AnyStr) + :param separator: content of the first line separator (AnyStr) + :param binary: whether the dumpsys should be pared as binary or not (bool) + :return: section extracted (string or bytes) """ lines = [] in_section = False + delimiter = "------------------------------------------------------------------------------" + if binary: + delimiter = delimiter.encode("utf-8") + for line in dumpsys.splitlines(): if line.strip() == separator: in_section = True @@ -26,11 +34,9 @@ class AndroidArtifact(Artifact): if not in_section: continue - if line.strip().startswith( - "------------------------------------------------------------------------------" - ): + if line.strip().startswith(delimiter): break lines.append(line) - return "\n".join(lines) + return b"\n".join(lines) if binary else "\n".join(lines) diff --git a/src/mvt/android/artifacts/dumpsys_adb.py b/src/mvt/android/artifacts/dumpsys_adb.py new file mode 100644 index 0000000..e6a2244 --- /dev/null +++ b/src/mvt/android/artifacts/dumpsys_adb.py @@ -0,0 +1,128 @@ +# 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 base64 +import hashlib + +from .artifact import AndroidArtifact + + +class DumpsysADBArtifact(AndroidArtifact): + multiline_fields = ["user_keys"] + + def indented_dump_parser(self, dump_data): + """ + Parse the indented dumpsys output, generated by DualDumpOutputStream in Android. + """ + res = {} + stack = [res] + cur_indent = 0 + in_multiline = False + for line in dump_data.strip(b"\n").split(b"\n"): + # Track the level of indentation + indent = len(line) - len(line.lstrip()) + if indent < cur_indent: + # If the current line is less indented than the previous one, back out + stack.pop() + cur_indent = indent + else: + cur_indent = indent + + # Split key and value by '=' + vals = line.lstrip().split(b"=", 1) + key = vals[0].decode("utf-8") + current_dict = stack[-1] + + # Annoyingly, some values are multiline and don't have a key on each line + if in_multiline: + if key == "": + # If the line is empty, it's the terminator for the multiline value + in_multiline = False + stack.pop() + else: + current_dict.append(line.lstrip()) + continue + + if key == "}": + stack.pop() + continue + + if vals[1] == b"{": + # If the value is a new dictionary, add it to the stack + current_dict[key] = {} + stack.append(current_dict[key]) + + # Handle continue multiline values + elif key in self.multiline_fields: + current_dict[key] = [] + current_dict[key].append(vals[1]) + + in_multiline = True + stack.append(current_dict[key]) + else: + # If the value something else, store it in the current dictionary + current_dict[key] = vals[1] + + return res + + @staticmethod + def calculate_key_info(user_key: bytes) -> str: + key_base64, user = user_key.split(b" ", 1) + key_raw = base64.b64decode(key_base64) + key_fingerprint = hashlib.md5(key_raw).hexdigest().upper() + key_fingerprint_colon = ":".join( + [key_fingerprint[i : i + 2] for i in range(0, len(key_fingerprint), 2)] + ) + return { + "user": user.decode("utf-8"), + "fingerprint": key_fingerprint_colon, + "key": key_base64, + } + + def check_indicators(self) -> None: + if not self.results: + return + + for entry in self.results: + for user_key in entry.get("user_keys", []): + self.log.debug( + f"Found trusted ADB key for user '{user_key['user']}' with fingerprint " + f"'{user_key['fingerprint']}'" + ) + + def parse(self, content: bytes) -> None: + """ + Parse the Dumpsys ADB section + Adds results to self.results (List[Dict[str, str]]) + + :param content: content of the ADB section (string) + """ + if not content or b"Can't find service: adb" in content: + self.log.error( + "Could not load ADB data from dumpsys. " + "It may not be supported on this device." + ) + return + + # TODO: Parse AdbDebuggingManager line in output. + start_of_json = content.find(b"\n{") + 2 + end_of_json = content.rfind(b"}\n") - 2 + json_content = content[start_of_json:end_of_json].rstrip() + + parsed = self.indented_dump_parser(json_content) + if parsed.get("debugging_manager") is None: + self.log.error("Unable to find expected ADB entries in dumpsys output") # noqa + return + else: + parsed = parsed["debugging_manager"] + + # Calculate key fingerprints for better readability + key_info = [] + for user_key in parsed.get("user_keys"): + user_info = self.calculate_key_info(user_key) + key_info.append(user_info) + + parsed["user_keys"] = key_info + self.results = [parsed] diff --git a/src/mvt/android/modules/adb/__init__.py b/src/mvt/android/modules/adb/__init__.py index 284ed48..1832134 100644 --- a/src/mvt/android/modules/adb/__init__.py +++ b/src/mvt/android/modules/adb/__init__.py @@ -10,6 +10,7 @@ from .dumpsys_appops import DumpsysAppOps from .dumpsys_battery_daily import DumpsysBatteryDaily from .dumpsys_battery_history import DumpsysBatteryHistory from .dumpsys_dbinfo import DumpsysDBInfo +from .dumpsys_adbstate import DumpsysADBState from .dumpsys_full import DumpsysFull from .dumpsys_receivers import DumpsysReceivers from .files import Files @@ -37,6 +38,7 @@ ADB_MODULES = [ DumpsysActivities, DumpsysAccessibility, DumpsysDBInfo, + DumpsysADBState, DumpsysFull, DumpsysAppOps, Packages, diff --git a/src/mvt/android/modules/adb/base.py b/src/mvt/android/modules/adb/base.py index 1386e5d..bdc2685 100644 --- a/src/mvt/android/modules/adb/base.py +++ b/src/mvt/android/modules/adb/base.py @@ -147,14 +147,14 @@ class AndroidExtraction(MVTModule): self._adb_disconnect() self._adb_connect() - def _adb_command(self, command: str) -> str: + def _adb_command(self, command: str, decode: bool = True) -> str: """Execute an adb shell command. :param command: Shell command to execute :returns: Output of command """ - return self.device.shell(command, read_timeout_s=200.0) + return self.device.shell(command, read_timeout_s=200.0, decode=decode) def _adb_check_if_root(self) -> bool: """Check if we have a `su` binary on the Android device. diff --git a/src/mvt/android/modules/adb/dumpsys_adbstate.py b/src/mvt/android/modules/adb/dumpsys_adbstate.py new file mode 100644 index 0000000..0bcd8fd --- /dev/null +++ b/src/mvt/android/modules/adb/dumpsys_adbstate.py @@ -0,0 +1,45 @@ +# 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 +from typing import Optional + +from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact + +from .base import AndroidExtraction + + +class DumpsysADBState(DumpsysADBArtifact, AndroidExtraction): + """This module extracts ADB keystore state.""" + + 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[list] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + + def run(self) -> None: + self._adb_connect() + output = self._adb_command("dumpsys adb", decode=False) + self._adb_disconnect() + + self.parse(output) + if self.results: + self.log.info( + "Identified a total of %d trusted ADB keys", + len(self.results[0].get("user_keys", [])), + ) diff --git a/src/mvt/android/modules/androidqf/__init__.py b/src/mvt/android/modules/androidqf/__init__.py index 8d0f593..c1c7548 100644 --- a/src/mvt/android/modules/androidqf/__init__.py +++ b/src/mvt/android/modules/androidqf/__init__.py @@ -11,6 +11,7 @@ from .dumpsys_battery_history import DumpsysBatteryHistory from .dumpsys_dbinfo import DumpsysDBInfo from .dumpsys_packages import DumpsysPackages from .dumpsys_receivers import DumpsysReceivers +from .dumpsys_adb import DumpsysADBState from .getprop import Getprop from .packages import Packages from .processes import Processes @@ -26,6 +27,7 @@ ANDROIDQF_MODULES = [ DumpsysDBInfo, DumpsysBatteryDaily, DumpsysBatteryHistory, + DumpsysADBState, Packages, Processes, Getprop, diff --git a/src/mvt/android/modules/androidqf/dumpsys_adb.py b/src/mvt/android/modules/androidqf/dumpsys_adb.py new file mode 100644 index 0000000..10d8a4d --- /dev/null +++ b/src/mvt/android/modules/androidqf/dumpsys_adb.py @@ -0,0 +1,51 @@ +# 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 +from typing import Optional + +from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact + +from .base import AndroidQFModule + + +class DumpsysADBState(DumpsysADBArtifact, AndroidQFModule): + """This module extracts ADB keystore state.""" + + 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[list] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + + def run(self) -> None: + dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt") + if not dumpsys_file: + return + + full_dumpsys = self._get_file_content(dumpsys_file[0]) + content = self.extract_dumpsys_section( + full_dumpsys, + b"DUMP OF SERVICE adb:", + binary=True, + ) + self.parse(content) + if self.results: + self.log.info( + "Identified a total of %d trusted ADB keys", + len(self.results[0].get("user_keys", [])), + ) diff --git a/src/mvt/android/modules/bugreport/__init__.py b/src/mvt/android/modules/bugreport/__init__.py index 02add94..119c958 100644 --- a/src/mvt/android/modules/bugreport/__init__.py +++ b/src/mvt/android/modules/bugreport/__init__.py @@ -12,6 +12,7 @@ from .dbinfo import DBInfo from .getprop import Getprop from .packages import Packages from .receivers import Receivers +from .adb_state import DumpsysADBState BUGREPORT_MODULES = [ Accessibility, @@ -23,4 +24,5 @@ BUGREPORT_MODULES = [ Getprop, Packages, Receivers, + DumpsysADBState, ] diff --git a/src/mvt/android/modules/bugreport/adb_state.py b/src/mvt/android/modules/bugreport/adb_state.py new file mode 100644 index 0000000..ff74368 --- /dev/null +++ b/src/mvt/android/modules/bugreport/adb_state.py @@ -0,0 +1,54 @@ +# 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 +from typing import Optional + +from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact + +from .base import BugReportModule + + +class DumpsysADBState(DumpsysADBArtifact, BugReportModule): + """This module extracts ADB key info.""" + + 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[list] = None, + ) -> None: + super().__init__( + file_path=file_path, + target_path=target_path, + results_path=results_path, + module_options=module_options, + log=log, + results=results, + ) + + def run(self) -> None: + full_dumpsys = self._get_dumpstate_file() + if not full_dumpsys: + self.log.error( + "Unable to find dumpstate file. " + "Did you provide a valid bug report archive?" + ) + return + + content = self.extract_dumpsys_section( + full_dumpsys, + b"DUMP OF SERVICE adb:", + binary=True, + ) + self.parse(content) + if self.results: + self.log.info( + "Identified a total of %d trusted ADB keys", + len(self.results[0].get("user_keys", [])), + ) diff --git a/tests/android/test_artifact_dumpsys_adb.py b/tests/android/test_artifact_dumpsys_adb.py new file mode 100644 index 0000000..610cc7b --- /dev/null +++ b/tests/android/test_artifact_dumpsys_adb.py @@ -0,0 +1,31 @@ +# 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 mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact + +from ..utils import get_artifact + + +class TestDumpsysADBArtifact: + def test_parsing(self): + da_adb = DumpsysADBArtifact() + file = get_artifact("android_data/dumpsys_adb.txt") + with open(file, "rb") as f: + data = f.read() + + assert len(da_adb.results) == 0 + da_adb.parse(data) + + assert len(da_adb.results) == 1 + adb_data = da_adb.results[0] + assert "user_keys" in adb_data + assert len(adb_data["user_keys"]) == 1 + + # Check key and fingerprint parsed successfully. + user_key = adb_data["user_keys"][0] + assert ( + user_key["fingerprint"] == "F0:A1:3D:8C:B3:F4:7B:09:9F:EE:8B:D8:38:2E:BD:C6" + ) + assert user_key["user"] == "user@linux" diff --git a/tests/android_androidqf/test_dumpsys_adbstate.py b/tests/android_androidqf/test_dumpsys_adbstate.py new file mode 100644 index 0000000..c94fe34 --- /dev/null +++ b/tests/android_androidqf/test_dumpsys_adbstate.py @@ -0,0 +1,27 @@ +# 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 pathlib import Path + +from mvt.android.modules.androidqf.dumpsys_adb import DumpsysADBState +from mvt.common.module import run_module + +from ..utils import get_android_androidqf, list_files + + +class TestDumpsysADBModule: + def test_parsing(self): + data_path = get_android_androidqf() + m = DumpsysADBState(target_path=data_path) + files = list_files(data_path) + parent_path = Path(data_path).absolute().parent.as_posix() + m.from_folder(parent_path, files) + run_module(m) + assert len(m.results) == 1 + assert len(m.detected) == 0 + + adb_statedump = m.results[0] + assert "user_keys" in adb_statedump + assert len(adb_statedump["user_keys"]) == 1 diff --git a/tests/artifacts/android_data/dumpsys_adb.txt b/tests/artifacts/android_data/dumpsys_adb.txt new file mode 100644 index 0000000..4860164 Binary files /dev/null and b/tests/artifacts/android_data/dumpsys_adb.txt differ diff --git a/tests/artifacts/androidqf/dumpsys.txt b/tests/artifacts/androidqf/dumpsys.txt index be5587d..2f21ffa 100644 --- a/tests/artifacts/androidqf/dumpsys.txt +++ b/tests/artifacts/androidqf/dumpsys.txt @@ -249,7 +249,19 @@ Current AppOps Service state: READ_EXTERNAL_STORAGE (allow): WRITE_EXTERNAL_STORAGE (allow): +------------------------------------------------------------------------------- +DUMP OF SERVICE adb: +ADB MANAGER STATE (dumpsys adb): +{ + debugging_manager={ + connected_to_adb=true + last_key_received=F0:A1:3D:8C:B3:F4:7B:09:9F:EE:8B:D8:38:2E:BD:C6 + user_keys=QAAAAPUBsVijHeceeK3bETuudn5I+S+ndOZDsxQDvkjKqSZrM+35YWU+U1d7mayQoh5fghnSIChrg0UxNOjcCxMY5oabt5lWIY4hJ+i1viqcg0UcJFAvW4/9j2wEpYKlIbyBlg5WGqp/wkOXeVn6fLKHETBOZzWG+CMh382OOmiKi5+4b2jLd2WLmKd8fBDfyaONVDhTrpkbwt3ArRqQSRQRr9ufJCCwkLOnKWmRYoyuN+AA7DYAGn+9TuId6edoiu/uAc+f3e8t8t7Rav9ha6ZzwcUuU9k+HwqECnVHdTkwrQvdtxnguiOKuyN+RrjGB+ZUO6acDeGRrB22rvAj2QiT/ldN0wbXiaw22HET99G1id4NiNJwjTKylH3nu3UxvyevUt2s2QbmH4j5CTWKghKstcCSUn72eu7xarfyx49r++FU8TojNzMEZe3H6Z4C/qfU2nQ1DBaBqq9TEgj2eLDSzB8ob9TbvE481sSebS3SiFaS+6pj/wBoA2R+JSWxfdJi/T3jyhs4VcXTFBrHYGop4TrWUqw+FxPSMC0dXXVUcMvpSni0hxLgA8l5GJT0Vu8DjyXOXJpgQ9n4ldfHAM8yXx7NTVklZilAdAbwTryMuSlENcVTo1IVURs3+p3lvOm7kUSEEn4WD39mguRv8Q5Y6R5hLfHKO94oH6Hvbge059/4WVUoGgEAAQA= user@linux + keystore=ABX2��keyStoreo��version2��adbKey/��key�QAAAAPUBsVijHeceeK3bETuudn5I+S+ndOZDsxQDvkjKqSZrM+35YWU+U1d7mayQoh5fghnSIChrg0UxNOjcCxMY5oabt5lWIY4hJ+i1viqcg0UcJFAvW4/9j2wEpYKlIbyBlg5WGqp/wkOXeVn6fLKHETBOZzWG+CMh382OOmiKi5+4b2jLd2WLmKd8fBDfyaONVDhTrpkbwt3ArRqQSRQRr9ufJCCwkLOnKWmRYoyuN+AA7DYAGn+9TuId6edoiu/uAc+f3e8t8t7Rav9ha6ZzwcUuU9k+HwqECnVHdTkwrQvdtxnguiOKuyN+RrjGB+ZUO6acDeGRrB22rvAj2QiT/ldN0wbXiaw22HET99G1id4NiNJwjTKylH3nu3UxvyevUt2s2QbmH4j5CTWKghKstcCSUn72eu7xarfyx49r++FU8TojNzMEZe3H6Z4C/qfU2nQ1DBaBqq9TEgj2eLDSzB8ob9TbvE481sSebS3SiFaS+6pj/wBoA2R+JSWxfdJi/T3jyhs4VcXTFBrHYGop4TrWUqw+FxPSMC0dXXVUcMvpSni0hxLgA8l5GJT0Vu8DjyXOXJpgQ9n4ldfHAM8yXx7NTVklZilAdAbwTryMuSlENcVTo1IVURs3+p3lvOm7kUSEEn4WD39mguRv8Q5Y6R5hLfHKO94oH6Hvbge059/4WVUoGgEAAQA= user@linux���lastConnection�`xY]33 + } +} +--------- 0.001s was the duration of dumpsys adb, ending at: 2024-03-21 11:07:22 ------------------------------------------------------------------------------- DUMP OF SERVICE dbinfo: Applications Database Info: diff --git a/tests/common/test_utils.py b/tests/common/test_utils.py index 8bc0d50..c97b9c0 100644 --- a/tests/common/test_utils.py +++ b/tests/common/test_utils.py @@ -71,9 +71,11 @@ class TestHashes: == "f0e32fe8a7fd5ac0e2de19636d123c0072e979396986139ba2bc49ec385dc325" ) assert hashes[1]["file_path"] == os.path.join(path, "dumpsys.txt") + + # This needs to be updated when we add or edit files in AndroidQF folder assert ( hashes[1]["sha256"] - == "cfae0e04ef139b5a2ae1e2b3d400ce67eb98e67ff66f56ba2a580fe41bc120d0" + == "1bd255f656a7f9d5647a730f0f0cc47053115576f11532d41bf28c16635b193d" )