diff --git a/docs/ios/coruna-darksword.md b/docs/ios/coruna-darksword.md new file mode 100644 index 00000000..a28fcc79 --- /dev/null +++ b/docs/ios/coruna-darksword.md @@ -0,0 +1,16 @@ +# Coruna and DarkSword artifacts + +The Manifest and Filesystem modules flag two scoped Coruna path patterns without requiring an IOC file: + +- `com.apple.<16 hexadecimal characters>.plist` in the mobile user's `Library/Preferences` directory (the `HomeDomain` equivalent in a backup). +- `Documents/.devcache` at the root of an application data container (`AppDomain-` in a backup). + +Path-only matches produce medium-severity alerts. When file contents are available, the modules read at most 1 MiB and check the structures published in [iVerify's September 2026 report](https://www.iverify.com/blog/proliferation-of-coruna-and-darksword). A photo-tracking plist must contain a DCIM path and a positive finite processing timestamp. A device cache must identify `exp` as `plasma` and contain nonempty `ecid`, `gci`, and `unique-id` strings. Matching structures produce high-severity alerts. Matching device identifiers in distinct application containers produce an additional correlation alert. Results retain a fingerprint for correlation instead of raw device identifiers or the tracked photo path. + +File content checks accept binary/XML plists and JSON. Missing, malformed or oversized content does not erase a path finding. A path finding alone is not confirmation of compromise. The checks do not inspect arbitrary plists based solely on field names, or flag `.devcache` outside the documented application-container location. + +Backup domains with known fixed roots are mapped to device paths for IOC matching. Application container UUIDs are not invented from bundle IDs. iOS path comparisons handle `/var` versus `/private/var`, `/tmp` versus `/private/tmp`, and host path separators. File path indicators match the exact path or descendants at a directory boundary, not similarly prefixed sibling names. + +For payload hashes, pass `--hashes` to `check-fs` or `check-backup` (or enable `HASH_FILES` in the configuration). The Filesystem and Manifest modules then store SHA-256 values and compare them with loaded hash IOCs; the saved module results can also be rechecked later. This reads available file contents and increases processing time. Acquisition integrity hashes in `info.json` remain available as before. Reads for artifact inspection and these module hashes are confined to regular files inside the acquisition; symlinks are not followed. + +Decrypt encrypted backups before analysis. Sysdiagnose and filesystem metadata may expose paths without the corresponding file bytes, and absence from a partial acquisition does not establish absence from the device. Changes to payload bytes defeat exact hash matching; source-level sample names and legitimate injected process names are not standalone malware indicators. diff --git a/src/mvt/common/indicators.py b/src/mvt/common/indicators.py index 3886e34f..5d963521 100644 --- a/src/mvt/common/indicators.py +++ b/src/mvt/common/indicators.py @@ -646,14 +646,19 @@ class Indicators: if not file_path: return None - ioc_match = self.check_file_name(os.path.basename(file_path)) + # Device paths use forward slashes even when analysed on Windows. + file_path = file_path.replace("\\", "/") + ioc_match = self.check_file_name(file_path.rsplit("/", 1)[-1]) if ioc_match: return ioc_match for ioc in self.get_iocs("file_paths"): # Strip any trailing slash from indicator paths to match # directories. - if file_path.startswith(ioc.value.rstrip("/")): + indicator_path = ioc.value.replace("\\", "/").rstrip("/") + if file_path == indicator_path or file_path.startswith( + indicator_path + "/" + ): return IndicatorMatch( ioc=ioc, message=f'Found a known suspicious file path "{file_path}" matching indicators form "{ioc.name}"', diff --git a/src/mvt/ios/cmd_check_backup.py b/src/mvt/ios/cmd_check_backup.py index 42b13c56..8f158fd3 100644 --- a/src/mvt/ios/cmd_check_backup.py +++ b/src/mvt/ios/cmd_check_backup.py @@ -8,6 +8,7 @@ import os from typing import Optional from mvt.common.command import Command +from mvt.common.config import settings from mvt.common.indicators import Indicators from mvt.common.module import MVTModule @@ -56,6 +57,8 @@ class CmdIOSCheckBackup(Command): ) self.platform = "ios" + if hashes or settings.HASH_FILES: + self.module_options["check_file_hashes"] = True self.name = "check-backup" self.modules = BACKUP_MODULES + MIXED_MODULES diff --git a/src/mvt/ios/cmd_check_fs.py b/src/mvt/ios/cmd_check_fs.py index 3daed974..015b54c9 100644 --- a/src/mvt/ios/cmd_check_fs.py +++ b/src/mvt/ios/cmd_check_fs.py @@ -7,6 +7,7 @@ import logging from typing import Optional from mvt.common.command import Command +from mvt.common.config import settings from mvt.common.indicators import Indicators from mvt.common.module import MVTModule @@ -48,6 +49,8 @@ class CmdIOSCheckFS(Command): ) self.platform = "ios" + if hashes or settings.HASH_FILES: + self.module_options["check_file_hashes"] = True self.name = "check-fs" self.modules = FS_MODULES + MIXED_MODULES diff --git a/src/mvt/ios/coruna.py b/src/mvt/ios/coruna.py new file mode 100644 index 00000000..18edb824 --- /dev/null +++ b/src/mvt/ios/coruna.py @@ -0,0 +1,168 @@ +"""Coruna artifact checks shared by backups, filesystems and metadata listings. + +Structures documented by iVerify on 2026-09-15: +https://www.iverify.com/blog/proliferation-of-coruna-and-darksword +""" + +import hashlib +import json +import math +import plistlib +import re +import stat +from collections import defaultdict +from pathlib import Path +from typing import Optional +from xml.parsers.expat import ExpatError + +from mvt.common.alerts import AlertStore + +from .paths import backup_device_path, normalize_ios_path + +_PHOTO_STATE = re.compile( + r"/private/var/mobile/Library/Preferences/com\.apple\.[0-9a-fA-F]{16}\.plist" +) +_DEVICE_CACHE = re.compile( + r"/private/var/mobile/Containers/Data/Application/" + r"(?P[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})" + r"/Documents/\.devcache" +) +MAX_ARTIFACT_SIZE = 1024 * 1024 + + +def coruna_path_artifact(path: str, domain: Optional[str] = None) -> Optional[dict]: + """Return a scoped candidate, never a verdict based on a basename alone.""" + if domain is not None: + relative = path.replace("\\", "/") + if relative == "Documents/.devcache" and domain.startswith("AppDomain-"): + return {"kind": "device-cache", "container": domain} + path = backup_device_path(domain, relative) or "" + if not path: + return None + path = normalize_ios_path(path) + if _PHOTO_STATE.fullmatch(path): + return {"kind": "photo-state"} + match = _DEVICE_CACHE.fullmatch(path) + if match: + return {"kind": "device-cache", "container": match["container"].lower()} + return None + + +def inspect_coruna_artifact(file_path: str, artifact: dict) -> dict: + """Enrich a candidate without exporting photo paths or device identifiers.""" + result = dict(artifact) + try: + with open(file_path, "rb") as handle: + raw = handle.read(MAX_ARTIFACT_SIZE + 1) + if len(raw) > MAX_ARTIFACT_SIZE: + return {**result, "content_status": "too-large"} + try: + data = plistlib.loads(raw) + except (ValueError, plistlib.InvalidFileException, ExpatError): + data = json.loads(raw) + except (OSError, ValueError, UnicodeError, OverflowError): + return {**result, "content_status": "unavailable"} + + matched = False + if isinstance(data, dict): + if artifact["kind"] == "photo-state": + photo = data.get("LastProcessedFilePath") + timestamp = data.get("LastProcessedTimestamp") + matched = ( + isinstance(photo, str) + and normalize_ios_path(photo).startswith( + "/private/var/mobile/Media/DCIM/" + ) + and isinstance(timestamp, (int, float)) + and not isinstance(timestamp, bool) + and (isinstance(timestamp, int) or math.isfinite(timestamp)) + and timestamp > 0 + ) + elif artifact["kind"] == "device-cache": + keys = ("ecid", "gci", "unique-id") + matched = data.get("exp") == "plasma" and all( + isinstance(data.get(key), str) and data[key].strip() for key in keys + ) + if matched: + identifiers = {key: data[key] for key in keys} + result["identifier_sha256"] = hashlib.sha256( + json.dumps(identifiers, sort_keys=True).encode() + ).hexdigest() + result["content_status"] = "matched" if matched else "unmatched" + return result + + +def enrich_coruna_record(record: dict, file_path: Optional[str]) -> None: + artifact = coruna_path_artifact( + record.get("relative_path", record.get("path", "")), record.get("domain") + ) + if artifact: + record["coruna_artifact"] = ( + inspect_coruna_artifact(file_path, artifact) if file_path else artifact + ) + + +def alert_coruna_record(record: dict, alertstore: AlertStore) -> None: + if record.get("is_directory") or record.get("flags") == 2: + return + mode = record.get("mode") + if isinstance(mode, int) and not stat.S_ISREG(mode): + return + artifact = record.get("coruna_artifact") + if not artifact: + artifact = coruna_path_artifact( + record.get("relative_path", record.get("path", "")), record.get("domain") + ) + if not artifact: + return + path = record.get("relative_path", record.get("path", "")) + timestamp = record.get("modified", record.get("isodate", "")) + if artifact.get("content_status") == "matched": + alertstore.high( + f"Coruna {artifact['kind']} artifact with matching content structure: {path}", + timestamp, + record, + ) + else: + alertstore.medium( + f"Possible Coruna {artifact['kind']} artifact at {path}; " + "path match without corroborating content", + timestamp, + record, + ) + + +def correlate_coruna_records(records: list, alertstore: AlertStore) -> None: + groups: dict[str, dict[str, dict]] = defaultdict(dict) + for record in records: + artifact = record.get("coruna_artifact", {}) + if ( + artifact.get("kind") != "device-cache" + or artifact.get("content_status") != "matched" + or not artifact.get("container") + ): + continue + fingerprint = artifact.get("identifier_sha256") + if fingerprint: + groups[fingerprint][artifact["container"]] = record + for fingerprint, containers in groups.items(): + if len(containers) < 2: + continue + alertstore.high( + f"Matching Coruna device identifiers in {len(containers)} application containers", + "", + {"identifier_sha256": fingerprint, "containers": sorted(containers)}, + ) + + +def contained_regular_file(root: str, file_path: str) -> bool: + """Do not read symlinks or paths outside the supplied acquisition.""" + path = Path(file_path) + try: + return ( + not path.is_symlink() + and path.is_file() + and path.resolve().is_relative_to(Path(root).resolve()) + ) + except (OSError, RuntimeError, ValueError): + return False diff --git a/src/mvt/ios/modules/backup/manifest.py b/src/mvt/ios/modules/backup/manifest.py index cc74cbb6..784b274a 100644 --- a/src/mvt/ios/modules/backup/manifest.py +++ b/src/mvt/ios/modules/backup/manifest.py @@ -18,7 +18,19 @@ from mvt.common.module_types import ( ModuleSerializedResult, ) from mvt.common.url import URL -from mvt.common.utils import convert_datetime_to_iso, convert_unix_to_iso +from mvt.common.utils import ( + convert_datetime_to_iso, + convert_unix_to_iso, + get_sha256_from_file_path, +) +from mvt.ios.coruna import ( + alert_coruna_record, + contained_regular_file, + correlate_coruna_records, + coruna_path_artifact, + enrich_coruna_record, +) +from mvt.ios.paths import backup_device_path, check_ios_path from ..base import IOSExtraction @@ -94,14 +106,34 @@ class Manifest(IOSExtraction): return records def check_indicators(self) -> None: + correlate_coruna_records(self.results, self.alertstore) for result in self.results: if not result.get("relative_path"): continue + alert_coruna_record(result, self.alertstore) if not self.indicators: continue - ioc_match = self.indicators.check_file_path("/" + result["relative_path"]) + hash_match = self.indicators.check_file_hash(result.get("sha256", "")) + if hash_match: + self.alertstore.critical( + hash_match.message, + result.get("modified", ""), + result, + matched_indicator=hash_match.ioc, + ) + + device_path = backup_device_path( + result.get("domain", ""), result["relative_path"] + ) + ioc_match = ( + check_ios_path(self.indicators, device_path) if device_path else None + ) + if not ioc_match: + ioc_match = self.indicators.check_file_path( + "/" + result["relative_path"] + ) if ioc_match: self.alertstore.high( ioc_match.message, "", result, matched_indicator=ioc_match.ioc @@ -191,6 +223,21 @@ class Manifest(IOSExtraction): file_data["relativePath"], ) + inspect_content = coruna_path_artifact( + cleaned_metadata["relative_path"], cleaned_metadata["domain"] + ) + hash_content = self.module_options.get("check_file_hashes", False) + if inspect_content or hash_content: + source_path = self._get_backup_file_from_id(cleaned_metadata["file_id"]) + if source_path and contained_regular_file( + self.target_path, source_path + ): + if inspect_content: + enrich_coruna_record(cleaned_metadata, source_path) + if hash_content: + cleaned_metadata["sha256"] = get_sha256_from_file_path( + source_path + ) self.results.append(cleaned_metadata) cur.close() diff --git a/src/mvt/ios/modules/fs/filesystem.py b/src/mvt/ios/modules/fs/filesystem.py index 563d0394..c4af8f8b 100644 --- a/src/mvt/ios/modules/fs/filesystem.py +++ b/src/mvt/ios/modules/fs/filesystem.py @@ -7,7 +7,15 @@ import logging import os from typing import Optional -from mvt.common.utils import convert_unix_to_iso +from mvt.common.utils import convert_unix_to_iso, get_sha256_from_file_path +from mvt.ios.coruna import ( + alert_coruna_record, + contained_regular_file, + correlate_coruna_records, + coruna_path_artifact, + enrich_coruna_record, +) +from mvt.ios.paths import check_ios_path, normalize_ios_path from mvt.common.module_types import ( ModuleAtomicResult, ModuleSerializedResult, @@ -49,14 +57,23 @@ class Filesystem(IOSExtraction): } def check_indicators(self) -> None: - if not self.indicators: - return - + correlate_coruna_records(self.results, self.alertstore) for result in self.results: if "path" not in result: continue - ioc_match = self.indicators.check_file_path(result["path"]) + alert_coruna_record(result, self.alertstore) + if not self.indicators: + continue + hash_match = self.indicators.check_file_hash(result.get("sha256", "")) + if hash_match: + self.alertstore.critical( + hash_match.message, + result.get("modified", ""), + result, + matched_indicator=hash_match.ioc, + ) + ioc_match = check_ios_path(self.indicators, result["path"]) if ioc_match: self.alertstore.high( ioc_match.message, "", result, matched_indicator=ioc_match.ioc @@ -66,7 +83,9 @@ class Filesystem(IOSExtraction): if self.module_options.get("fast_mode", None): continue - ioc_match = self.indicators.check_file_path_process(result["path"]) + ioc_match = self.indicators.check_file_path_process( + normalize_ios_path(result["path"]) + ) if ioc_match: self.alertstore.high( ioc_match.message, "", result, matched_indicator=ioc_match.ioc @@ -81,9 +100,10 @@ class Filesystem(IOSExtraction): for dir_name in dirs: try: dir_path = os.path.join(root, dir_name) - result = { + result: ModuleAtomicResult = { "path": os.path.relpath(dir_path, self.target_path), "modified": convert_unix_to_iso(os.stat(dir_path).st_mtime), + "is_directory": True, } except Exception: continue @@ -97,6 +117,14 @@ class Filesystem(IOSExtraction): "path": os.path.relpath(file_path, self.target_path), "modified": convert_unix_to_iso(os.stat(file_path).st_mtime), } + inspect_content = coruna_path_artifact(result["path"]) + hash_content = self.module_options.get("check_file_hashes", False) + if (inspect_content or hash_content) and contained_regular_file( + self.target_path, file_path + ): + enrich_coruna_record(result, file_path) + if hash_content: + result["sha256"] = get_sha256_from_file_path(file_path) except Exception: continue else: diff --git a/src/mvt/ios/paths.py b/src/mvt/ios/paths.py new file mode 100644 index 00000000..b1fbfc86 --- /dev/null +++ b/src/mvt/ios/paths.py @@ -0,0 +1,46 @@ +"""Compare device paths independently of the analysis host and backup layout.""" + +import posixpath +from typing import Optional + +from mvt.common.indicators import IndicatorMatch, Indicators + + +def normalize_ios_path(path: str) -> str: + path = posixpath.normpath("/" + path.replace("\\", "/").lstrip("/")) + if path == "/var" or path.startswith("/var/"): + return "/private" + path + if path == "/tmp" or path.startswith("/tmp/"): + return "/private" + path + return path + + +def ios_path_variants(path: str) -> tuple[str, ...]: + path = normalize_ios_path(path) + if path.startswith(("/private/var/", "/private/tmp/")): + return path, path[len("/private") :] + return (path,) + + +def backup_device_path(domain: str, relative_path: str) -> Optional[str]: + """Map only domains with a known fixed root; app UUIDs are not in Manifest.db.""" + roots = { + "HomeDomain": "/private/var/mobile", + "CameraRollDomain": "/private/var/mobile", + "MediaDomain": "/private/var/mobile/Media", + "RootDomain": "/private/var/root", + "WirelessDomain": "/private/var/wireless", + } + root = roots.get(domain) + relative_path = relative_path.replace("\\", "/") + if not root or relative_path.startswith("/") or ".." in relative_path.split("/"): + return None + return normalize_ios_path(root + "/" + relative_path) + + +def check_ios_path(indicators: Indicators, path: str) -> Optional[IndicatorMatch]: + for candidate in ios_path_variants(path): + match = indicators.check_file_path(candidate) + if match: + return match + return None diff --git a/tests/test_ios_coruna.py b/tests/test_ios_coruna.py new file mode 100644 index 00000000..a2aa9293 --- /dev/null +++ b/tests/test_ios_coruna.py @@ -0,0 +1,280 @@ +import hashlib +import json +import plistlib +import sqlite3 + +import pytest + +from mvt.common.alerts import AlertLevel, AlertStore +from mvt.common.indicators import Indicators +from mvt.ios.coruna import ( + MAX_ARTIFACT_SIZE, + alert_coruna_record, + contained_regular_file, + correlate_coruna_records, + coruna_path_artifact, + inspect_coruna_artifact, +) +from mvt.ios.modules.backup.manifest import Manifest +from mvt.ios.modules.fs.filesystem import Filesystem +from mvt.ios.paths import backup_device_path, check_ios_path + +PHOTO = "/private/var/mobile/Library/Preferences/com.apple.0123456789abcdef.plist" +CONTAINER = "/private/var/mobile/Containers/Data/Application/12345678-1234-1234-1234-123456789abc" +PHOTO_DATA = { + "LastProcessedFilePath": "/var/mobile/Media/DCIM/100APPLE/IMG_0001.PNG", + "LastProcessedTimestamp": 1762537021.5, +} +CACHE_DATA = { + "exp": "plasma", + "ecid": "test-ecid", + "gci": "test-gci", + "unique-id": "test-id", +} + + +def indicators(**values): + result = Indicators() + result.ioc_collections.append( + {"name": "test", "stix2_file_name": "test.stix2", **values} + ) + return result + + +@pytest.mark.parametrize( + "path", [PHOTO, PHOTO[1:], PHOTO.replace("/private", ""), PHOTO.replace("/", "\\")] +) +def test_photo_state_aliases(path): + assert coruna_path_artifact(path) == {"kind": "photo-state"} + + +@pytest.mark.parametrize( + "path", + [ + PHOTO + ".bak", + PHOTO.replace("abcdef", "abcdeg"), + PHOTO.replace("abcdef", "abcde"), + PHOTO.replace("/mobile/", "/root/"), + "/tmp/" + PHOTO.rsplit("/", 1)[-1], + CONTAINER + "/Library/.devcache", + "/Documents/.devcache", + CONTAINER.replace("/Data/", "/Bundle/") + "/Documents/.devcache", + ], +) +def test_artifact_paths_are_scoped(path): + assert coruna_path_artifact(path) is None + + +def test_backup_domain_scoping(): + relative = "Library/Preferences/" + PHOTO.rsplit("/", 1)[-1] + assert coruna_path_artifact(relative, "HomeDomain") + assert coruna_path_artifact(relative, "AppDomain-com.example.app") is None + assert coruna_path_artifact("Documents/.devcache", "AppDomain-com.example.app") + assert coruna_path_artifact("Documents/.devcache", "HomeDomain") is None + assert ( + coruna_path_artifact("Documents/.devcache.bak", "AppDomain-com.example.app") + is None + ) + assert ( + backup_device_path("MediaDomain", "PostLogs.txt") + == "/private/var/mobile/Media/PostLogs.txt" + ) + assert ( + backup_device_path("CameraRollDomain", "Media/DCIM/image.png") + == "/private/var/mobile/Media/DCIM/image.png" + ) + assert backup_device_path("HomeDomain", "../root/file") is None + assert backup_device_path("AppDomain-com.example.app", "Documents/file") is None + + +@pytest.mark.parametrize( + "serializer", + [ + plistlib.dumps, + lambda d: plistlib.dumps(d, fmt=plistlib.FMT_BINARY), + lambda d: json.dumps(d).encode(), + ], +) +@pytest.mark.parametrize( + "kind,data", [("photo-state", PHOTO_DATA), ("device-cache", CACHE_DATA)] +) +def test_content_formats(tmp_path, serializer, kind, data): + path = tmp_path / "artifact" + path.write_bytes(serializer(data)) + result = inspect_coruna_artifact(str(path), {"kind": kind}) + assert result["content_status"] == "matched" + assert "test-ecid" not in json.dumps(result) + assert "IMG_0001.PNG" not in json.dumps(result) + + +@pytest.mark.parametrize( + "data", + [ + {}, + [], + {**PHOTO_DATA, "LastProcessedFilePath": "/tmp/photo"}, + {**PHOTO_DATA, "LastProcessedTimestamp": True}, + {**PHOTO_DATA, "LastProcessedTimestamp": float("inf")}, + {"lastProcessedTimestamp": 123}, + ], +) +def test_photo_content_requires_full_structure(tmp_path, data): + path = tmp_path / "artifact" + path.write_text(json.dumps(data)) + assert ( + inspect_coruna_artifact(str(path), {"kind": "photo-state"})["content_status"] + == "unmatched" + ) + + +@pytest.mark.parametrize( + "raw", + [ + b"not a plist", + b"