diff --git a/src/mvt/android/artifacts/dumpsys_package_activities.py b/src/mvt/android/artifacts/dumpsys_package_activities.py index 31c6bbd..443d18f 100644 --- a/src/mvt/android/artifacts/dumpsys_package_activities.py +++ b/src/mvt/android/artifacts/dumpsys_package_activities.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ from .artifact import AndroidArtifact +from .package_resolvers import parse_resolver_table class DumpsysPackageActivitiesArtifact(AndroidArtifact): @@ -19,67 +20,11 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact): ) continue - def parse(self, content: str): + def parse(self, content: str) -> None: """ Parse the Dumpsys Package section for activities Adds results to self.results :param content: content of the package section (string) """ - self.results = [] - - in_activity_resolver_table = False - in_non_data_actions = False - intent = None - for line in content.splitlines(): - if line.startswith("Activity Resolver Table:"): - in_activity_resolver_table = True - continue - - if not in_activity_resolver_table: - continue - - if line.startswith(" Non-Data Actions:"): - in_non_data_actions = True - continue - - if not in_non_data_actions: - continue - - # If we hit an empty line, the Non-Data Actions section should be - # finished. - if line.strip() == "": - break - - # We detect the action name. - if ( - line.startswith(" " * 6) - and not line.startswith(" " * 8) - and ":" in line - ): - intent = line.strip().replace(":", "") - continue - - # If we are not in an intent block yet, skip. - if not intent: - continue - - # If we are in a block but the line does not start with 8 spaces - # it means the block ended a new one started, so we reset and - # continue. - if not line.startswith(" " * 8): - intent = None - continue - - # If we got this far, we are processing receivers for the - # activities we are interested in. - activity = line.strip().split(" ")[1] - package_name = activity.split("/")[0] - - self.results.append( - { - "intent": intent, - "package_name": package_name, - "activity": activity, - } - ) + self.results = parse_resolver_table(content, "Activity") diff --git a/src/mvt/android/artifacts/dumpsys_receivers.py b/src/mvt/android/artifacts/dumpsys_receivers.py index b437930..7594335 100644 --- a/src/mvt/android/artifacts/dumpsys_receivers.py +++ b/src/mvt/android/artifacts/dumpsys_receivers.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ from .artifact import AndroidArtifact +from .package_resolvers import parse_resolver_table INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS" INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED" @@ -18,115 +19,45 @@ class DumpsysReceiversArtifact(AndroidArtifact): """ def check_indicators(self) -> None: - for intent, receivers in self.results.items(): - for receiver in receivers: - if intent == INTENT_NEW_OUTGOING_SMS: - self.log.info( - 'Found a receiver to intercept outgoing SMS messages: "%s"', - receiver["receiver"], - ) - elif intent == INTENT_SMS_RECEIVED: - self.log.info( - 'Found a receiver to intercept incoming SMS messages: "%s"', - receiver["receiver"], - ) - elif intent == INTENT_DATA_SMS_RECEIVED: - self.log.info( - 'Found a receiver to intercept incoming data SMS message: "%s"', - receiver["receiver"], - ) - elif intent == INTENT_PHONE_STATE: - self.log.info( - "Found a receiver monitoring " - 'telephony state/incoming calls: "%s"', - receiver["receiver"], - ) - elif intent == INTENT_NEW_OUTGOING_CALL: - self.log.info( - 'Found a receiver monitoring outgoing calls: "%s"', - receiver["receiver"], - ) + for receiver in self.results: + intent = receiver["key"] + if intent == INTENT_NEW_OUTGOING_SMS: + self.log.info( + 'Found a receiver to intercept outgoing SMS messages: "%s"', + receiver["component"], + ) + elif intent == INTENT_SMS_RECEIVED: + self.log.info( + 'Found a receiver to intercept incoming SMS messages: "%s"', + receiver["component"], + ) + elif intent == INTENT_DATA_SMS_RECEIVED: + self.log.info( + 'Found a receiver to intercept incoming data SMS message: "%s"', + receiver["component"], + ) + elif intent == INTENT_PHONE_STATE: + self.log.info( + 'Found a receiver monitoring telephony state/incoming calls: "%s"', + receiver["component"], + ) + elif intent == INTENT_NEW_OUTGOING_CALL: + self.log.info( + 'Found a receiver monitoring outgoing calls: "%s"', + receiver["component"], + ) - if not self.indicators: - continue + if not self.indicators: + continue - ioc_match = self.indicators.check_app_id(receiver["package_name"]) - if ioc_match: - self.alertstore.critical( - ioc_match.message, - "", - {intent: receiver}, - matched_indicator=ioc_match.ioc, - ) - continue + ioc_match = self.indicators.check_app_id(receiver["package_name"]) + if ioc_match: + self.alertstore.critical( + ioc_match.message, + "", + receiver, + matched_indicator=ioc_match.ioc, + ) def parse(self, output: str) -> None: - self.results: dict[str, list[dict[str, str]]] = {} - - in_receiver_resolver_table = False - in_non_data_actions = False - intent = None - for line in output.splitlines(): - if line.startswith("Receiver Resolver Table:"): - in_receiver_resolver_table = True - continue - - if not in_receiver_resolver_table: - continue - - if line.startswith(" Non-Data Actions:"): - in_non_data_actions = True - continue - - if not in_non_data_actions: - continue - - # If we hit an empty line, the Non-Data Actions section should be - # finished. - if line.strip() == "": - break - - # We detect the action name. - if ( - line.startswith(" " * 6) - and not line.startswith(" " * 8) - and ":" in line - ): - intent = line.strip().replace(":", "") - self.results[intent] = [] - continue - - parts = line.strip().split(" ") - if len(parts) < 2: - # A single-token line here is not a receiver. Real dumpstate - # output can print an action header mis-indented (observed with - # 15 leading spaces instead of 6), which used to raise - # IndexError and abort the whole module. Treat a trailing-colon - # token as the next action, skip anything else. - if parts[0].endswith(":"): - intent = parts[0][:-1] - self.results.setdefault(intent, []) - continue - - # If we are not in an intent block yet, skip. - if not intent: - continue - - # If we are in a block but the line does not start with 8 spaces - # it means the block ended a new one started, so we reset and - # continue. - if not line.startswith(" " * 8): - intent = None - continue - - # If we got this far, we are processing receivers for the - # activities we are interested in. - receiver = parts[1] - package_name = receiver.split("/")[0] - - self.results[intent].append( - { - "package_name": package_name, - "receiver": receiver, - } - ) + self.results = parse_resolver_table(output, "Receiver") diff --git a/src/mvt/android/artifacts/package_resolvers.py b/src/mvt/android/artifacts/package_resolvers.py new file mode 100644 index 0000000..60c2212 --- /dev/null +++ b/src/mvt/android/artifacts/package_resolvers.py @@ -0,0 +1,70 @@ +# Mobile Verification Toolkit (MVT) +# Copyright (c) 2021-2026 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 re + + +RESOLVER_TYPES = { + "Full MIME Types": "full_mime_type", + "Base MIME Types": "base_mime_type", + "Wild MIME Types": "wild_mime_type", + "Schemes": "scheme", + "Non-Data Actions": "non_data_action", + "MIME Typed Actions": "mime_typed_action", +} + +ENTRY_RE = re.compile( + r"^\s{8,}[0-9a-fA-F]+\s+(?P\S+)" + r"(?:\s+\((?P\d+)\s+filters?\))?\s*$" +) + + +def parse_resolver_table(content: str, table_name: str) -> list[dict]: + """Parse every resolver category from a dumpsys package table.""" + results: list[dict] = [] + in_table = False + resolver_type: str | None = None + key: str | None = None + + for line in content.splitlines(): + if line.startswith(f"{table_name} Resolver Table:"): + in_table = True + continue + if not in_table: + continue + + if line and not line.startswith(" "): + break + + section_match = re.match(r"^ {2}([^ ].*):\s*$", line) + if section_match: + resolver_type = RESOLVER_TYPES.get(section_match.group(1)) + key = None + continue + if resolver_type is None: + continue + + key_match = re.match(r"^ {6,}([^ ].*):\s*$", line) + if key_match: + key = key_match.group(1) + continue + if key is None: + continue + + entry_match = ENTRY_RE.match(line) + if not entry_match: + continue + component = entry_match.group("component") + results.append( + { + "resolver_type": resolver_type, + "key": key, + "package_name": component.split("/", 1)[0], + "component": component, + "filter_count": int(entry_match.group("filters") or 1), + } + ) + + return results diff --git a/src/mvt/android/modules/bugreport/dumpsys_receivers.py b/src/mvt/android/modules/bugreport/dumpsys_receivers.py index 8907d5e..a82f021 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_receivers.py +++ b/src/mvt/android/modules/bugreport/dumpsys_receivers.py @@ -33,7 +33,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule): results=results, ) - self.results = results if results else {} + self.results = results if results else [] def run(self) -> None: content = self._get_dumpstate_file() @@ -49,4 +49,4 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule): ) self.parse(dumpsys_section) - self.log.info("Extracted receivers for %d intents", len(self.results)) + self.log.info("Extracted %d package receivers", len(self.results)) diff --git a/tests/android/test_artifact_dumpsys_package_activities.py b/tests/android/test_artifact_dumpsys_package_activities.py index 5eab63d..66ef790 100644 --- a/tests/android/test_artifact_dumpsys_package_activities.py +++ b/tests/android/test_artifact_dumpsys_package_activities.py @@ -21,12 +21,16 @@ class TestDumpsysPackageActivitiesArtifact: assert len(dpa.results) == 0 dpa.parse(data) - assert len(dpa.results) == 4 - assert dpa.results[0]["package_name"] == "com.samsung.android.app.social" + assert len(dpa.results) == 10 + assert dpa.results[0]["package_name"] == "com.samsung.android.messaging" assert ( - dpa.results[0]["activity"] - == "com.samsung.android.app.social/.feed.FeedsActivity" + dpa.results[0]["component"] + == "com.samsung.android.messaging/.ui.RcsTransferContent" ) + assert {result["resolver_type"] for result in dpa.results} == { + "full_mime_type", + "non_data_action", + } def test_ioc_check(self, indicator_file): dpa = DumpsysPackageActivitiesArtifact() @@ -41,4 +45,4 @@ class TestDumpsysPackageActivitiesArtifact: dpa.indicators = ind assert len(dpa.alertstore.alerts) == 0 dpa.check_indicators() - assert len(dpa.alertstore.alerts) == 1 + assert len(dpa.alertstore.alerts) == 2 diff --git a/tests/android/test_artifact_dumpsys_receivers.py b/tests/android/test_artifact_dumpsys_receivers.py index 7875a52..6e8b92e 100644 --- a/tests/android/test_artifact_dumpsys_receivers.py +++ b/tests/android/test_artifact_dumpsys_receivers.py @@ -19,17 +19,14 @@ class TestDumpsysReceiversArtifact: assert len(dr.results) == 0 dr.parse(data) - assert len(dr.results) == 4 - assert ( - list(dr.results.keys())[0] - == "com.android.storagemanager.automatic.SHOW_NOTIFICATION" - ) - assert ( - dr.results["com.android.storagemanager.automatic.SHOW_NOTIFICATION"][0][ - "package_name" - ] - == "com.android.storagemanager" + assert len(dr.results) == 9 + assert dr.results[0]["resolver_type"] == "full_mime_type" + storage_manager = next( + result + for result in dr.results + if result["key"] == "com.android.storagemanager.automatic.SHOW_NOTIFICATION" ) + assert storage_manager["package_name"] == "com.android.storagemanager" def test_parsing_misindented_action(self): dr = DumpsysReceiversArtifact() @@ -44,12 +41,8 @@ Receiver Resolver Table: dr.parse(data) - assert ( - dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][ - "package_name" - ] - == "com.psycatgames.nhiegame" - ) + assert dr.results[1]["key"] == "android.intent.action.MY_PACKAGE_REPLACED" + assert dr.results[1]["package_name"] == "com.psycatgames.nhiegame" def test_parsing_misindented_first_action(self): dr = DumpsysReceiversArtifact() @@ -62,12 +55,8 @@ Receiver Resolver Table: dr.parse(data) - assert ( - dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][ - "package_name" - ] - == "com.psycatgames.nhiegame" - ) + assert dr.results[0]["key"] == "android.intent.action.MY_PACKAGE_REPLACED" + assert dr.results[0]["package_name"] == "com.psycatgames.nhiegame" def test_ioc_check(self, indicator_file): dr = DumpsysReceiversArtifact()