From 9bcbaac3a2ad0c64b2a848bd2c1266d6adbd9bcd Mon Sep 17 00:00:00 2001 From: Janik Besendorf Date: Sun, 12 Apr 2026 10:16:49 +0200 Subject: [PATCH] Show which accessibility services are enabled Parse both installed and enabled accessibility services, adding an "enabled" field to each result. This lets users see at a glance whether any installed accessibility service is actually active. Fixes #744 --- .../artifacts/dumpsys_accessibility.py | 72 +++++++++++++++---- .../bugreport/dumpsys_accessibility.py | 9 ++- .../test_artifact_dumpsys_accessibility.py | 30 +++++++- .../dumpsys_accessibility_enabled.txt | 16 +++++ 4 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 tests/artifacts/android_data/dumpsys_accessibility_enabled.txt diff --git a/src/mvt/android/artifacts/dumpsys_accessibility.py b/src/mvt/android/artifacts/dumpsys_accessibility.py index fca84df..1dbea63 100644 --- a/src/mvt/android/artifacts/dumpsys_accessibility.py +++ b/src/mvt/android/artifacts/dumpsys_accessibility.py @@ -22,13 +22,13 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): def parse(self, content: str) -> None: """ - Parse the Dumpsys Accessibility section/ - Adds results to self.results (List[Dict[str, str]]) + Parse the Dumpsys Accessibility section. + Adds results to self.results (List[Dict[str, Any]]) :param content: content of the accessibility section (string) """ - # "Old" syntax + # Parse installed services in_services = False for line in content.splitlines(): if line.strip().startswith("installed services:"): @@ -39,7 +39,6 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): continue if line.strip() == "}": - # At end of installed services break service = line.split(":")[1].strip() @@ -48,21 +47,66 @@ class DumpsysAccessibilityArtifact(AndroidArtifact): { "package_name": service.split("/")[0], "service": service, + "enabled": False, } ) - # "New" syntax - AOSP >= 14 (?) - # Looks like: - # Enabled services:{{com.azure.authenticator/com.microsoft.brooklyn.module.accessibility.BrooklynAccessibilityService}, {com.agilebits.onepassword/com.agilebits.onepassword.filling.accessibility.FillingAccessibilityService}} + # Parse enabled services from both old and new formats. + # + # Old format (multi-line block): + # enabled services: { + # 0 : com.example/.MyService + # } + # + # New format (single line, AOSP >= 14): + # Enabled services:{{com.example/com.example.MyService}, {com.other/com.other.Svc}} + enabled_services = set() + in_enabled = False for line in content.splitlines(): - if line.strip().startswith("Enabled services:"): - matches = re.finditer(r"{([^{]+?)}", line) + stripped = line.strip() + if in_enabled: + if stripped == "}": + in_enabled = False + continue + service = line.split(":")[1].strip() + enabled_services.add(service) + continue + + if re.match(r"enabled services:\s*\{\s*$", stripped, re.IGNORECASE): + # Old multi-line format: "enabled services: {" + in_enabled = True + continue + + if re.match(r"enabled services:\s*\{", stripped, re.IGNORECASE): + # New single-line format: "Enabled services:{{pkg/svc}, {pkg2/svc2}}" + matches = re.finditer(r"\{([^{}]+)\}", stripped) for match in matches: - # Each match is in format: / - package_name, _, service = match.group(1).partition("/") + enabled_services.add(match.group(1).strip()) - self.results.append( - {"package_name": package_name, "service": service} - ) + # Mark installed services that are enabled. + # Installed service names may include trailing annotations like + # "(A11yTool)" that are absent from the enabled services list, + # so strip annotations before comparing. + def _strip_annotation(s: str) -> str: + return re.sub(r"\s+\(.*?\)\s*$", "", s) + + installed_stripped = { + _strip_annotation(r["service"]): r for r in self.results + } + for enabled in enabled_services: + if enabled in installed_stripped: + installed_stripped[enabled]["enabled"] = True + + # Add enabled services not found in the installed list + for service in enabled_services: + if service not in installed_stripped: + package_name, _, _ = service.partition("/") + self.results.append( + { + "package_name": package_name, + "service": service, + "enabled": True, + } + ) diff --git a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py index e141b2f..f9e9f7b 100644 --- a/src/mvt/android/modules/bugreport/dumpsys_accessibility.py +++ b/src/mvt/android/modules/bugreport/dumpsys_accessibility.py @@ -49,9 +49,14 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule): for result in self.results: self.log.info( - 'Found installed accessibility service "%s"', result.get("service") + 'Found installed accessibility service "%s" (enabled: %s)', + result.get("service"), + result.get("enabled", False), ) + enabled_count = sum(1 for r in self.results if r.get("enabled")) self.log.info( - "Identified a total of %d accessibility services", len(self.results) + "Identified a total of %d accessibility services, %d enabled", + len(self.results), + enabled_count, ) diff --git a/tests/android/test_artifact_dumpsys_accessibility.py b/tests/android/test_artifact_dumpsys_accessibility.py index 2eca8fa..c3bfa22 100644 --- a/tests/android/test_artifact_dumpsys_accessibility.py +++ b/tests/android/test_artifact_dumpsys_accessibility.py @@ -25,6 +25,9 @@ class TestDumpsysAccessibilityArtifact: da.results[0]["service"] == "com.android.settings/com.samsung.android.settings.development.gpuwatch.GPUWatchInterceptor" ) + # All services are installed but none enabled in this fixture + for result in da.results: + assert result["enabled"] is False def test_parsing_v14_aosp_format(self): da = DumpsysAccessibilityArtifact() @@ -36,7 +39,32 @@ class TestDumpsysAccessibilityArtifact: da.parse(data) assert len(da.results) == 1 assert da.results[0]["package_name"] == "com.malware.accessibility" - assert da.results[0]["service"] == "com.malware.service.malwareservice" + assert ( + da.results[0]["service"] + == "com.malware.accessibility/com.malware.service.malwareservice" + ) + assert da.results[0]["enabled"] is True + + def test_parsing_installed_and_enabled(self): + da = DumpsysAccessibilityArtifact() + file = get_artifact("android_data/dumpsys_accessibility_enabled.txt") + with open(file) as f: + data = f.read() + + assert len(da.results) == 0 + da.parse(data) + assert len(da.results) == 5 + + enabled = [r for r in da.results if r["enabled"]] + assert len(enabled) == 1 + assert enabled[0]["package_name"] == "com.samsung.accessibility" + assert ( + enabled[0]["service"] + == "com.samsung.accessibility/.universalswitch.UniversalSwitchService (A11yTool)" + ) + + not_enabled = [r for r in da.results if not r["enabled"]] + assert len(not_enabled) == 4 def test_ioc_check(self, indicator_file): da = DumpsysAccessibilityArtifact() diff --git a/tests/artifacts/android_data/dumpsys_accessibility_enabled.txt b/tests/artifacts/android_data/dumpsys_accessibility_enabled.txt new file mode 100644 index 0000000..d73f2d8 --- /dev/null +++ b/tests/artifacts/android_data/dumpsys_accessibility_enabled.txt @@ -0,0 +1,16 @@ +ACCESSIBILITY MANAGER (dumpsys accessibility) + +currentUserId=0 +User state[ + attributes:{id=0, touchExplorationEnabled=false, installedServiceCount=5} + installed services: { + 0 : com.google.android.apps.accessibility.voiceaccess/.JustSpeakService (A11yTool) + 1 : com.microsoft.appmanager/com.microsoft.mmx.screenmirroringsrc.accessibility.ScreenMirroringAccessibilityService + 2 : com.samsung.accessibility/.assistantmenu.serviceframework.AssistantMenuService (A11yTool) + 3 : com.samsung.accessibility/.universalswitch.UniversalSwitchService (A11yTool) + 4 : com.samsung.android.accessibility.talkback/com.samsung.android.marvin.talkback.TalkBackService (A11yTool) + } + Bound services:{} + Enabled services:{{com.samsung.accessibility/.universalswitch.UniversalSwitchService}} + Binding services:{} + Crashed services:{}