Compare commits

..
14 changed files with 628 additions and 106 deletions

No files matched your search

+9 -88
View File
@@ -14,10 +14,10 @@ env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Build each architecture natively, then publish the combined image manifests.
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ${{ matrix.architecture.runner }}
runs-on: ubuntu-latest
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
@@ -26,15 +26,7 @@ jobs:
id-token: write
#
strategy:
fail-fast: false
matrix:
architecture:
- platform: linux/amd64
runner: ubuntu-24.04
name: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
name: arm64
platform:
- dockerfile: "Dockerfile"
tag-suffix: ""
@@ -69,95 +61,24 @@ jobs:
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
type=sha,suffix=${{ matrix.platform.tag-suffix }}
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
# Register emulators so Buildx can build the ARM64 images on the AMD64 runner.
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
# This step sets up some additional capabilities to generate the provenance and sbom attestations
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# Push by digest so architecture builds do not overwrite shared image tags.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v7
with:
file: ${{ matrix.platform.dockerfile }}
context: .
platforms: ${{ matrix.architecture.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max
sbom: true
- name: Export image digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
mkdir -p "${RUNNER_TEMP}/digests"
touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}"
- name: Upload image digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.platform.dockerfile }}-${{ matrix.architecture.name }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
publish-manifests:
needs: build-and-push-image
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
strategy:
matrix:
platform:
- dockerfile: "Dockerfile"
tag-suffix: ""
- dockerfile: "Dockerfile.ios"
tag-suffix: "-ios"
- dockerfile: "Dockerfile.android"
tag-suffix: "-android"
steps:
- name: Download image digests
uses: actions/download-artifact@v4
with:
pattern: digests-${{ matrix.platform.dockerfile }}-*
path: ${{ runner.temp }}/digests
merge-multiple: true
- name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=latest,enable={{ is_default_branch }},suffix=${{ matrix.platform.tag-suffix }}
type=raw,enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }},value=stable,suffix=${{ matrix.platform.tag-suffix }}
type=raw,enable=${{ github.event_name == 'release' }},value=${{ github.event.release.tag_name }},suffix=${{ matrix.platform.tag-suffix }}
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
type=sha,suffix=${{ matrix.platform.tag-suffix }}
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Publish multi-platform image
working-directory: ${{ runner.temp }}/digests
env:
METADATA_JSON: ${{ steps.meta.outputs.json }}
run: |
mapfile -t tags < <(jq -r '.tags[]' <<< "$METADATA_JSON")
tag_args=()
for tag in "${tags[@]}"; do
tag_args+=(--tag "$tag")
done
images=()
for digest in *; do
images+=("${REGISTRY}/${IMAGE_NAME}@sha256:${digest}")
done
test "${#images[@]}" -eq 2
docker buildx imagetools create "${tag_args[@]}" "${images[@]}"
docker buildx imagetools inspect "${tags[0]}"
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
env:
# Takes precedence over .python-version, which otherwise makes `uv run`
# rebuild the venv with 3.10 and test every matrix entry on 3.10.
+2 -2
View File
@@ -1,6 +1,6 @@
# Create main image
FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -62,4 +62,4 @@ WORKDIR /home/cases
RUN echo 'echo "Mobile Verification Toolkit @ Docker\n------------------------------------\n\nYou can find information about how to use this image for Android (https://github.com/mvt-project/mvt/tree/master/docs/android) and iOS (https://github.com/mvt-project/mvt/tree/master/docs/ios) in the official docs of the project.\n"' >> ~/.bashrc \
&& echo 'echo "Note that to perform the debug via USB you might need to give the Docker image access to the USB using \"docker run -it --privileged -v /dev/bus/usb:/dev/bus/usb mvt\" or, preferably, the \"--device=\" parameter.\n"' >> ~/.bashrc
CMD [ "/bin/bash" ]
CMD /bin/bash
+3 -3
View File
@@ -1,6 +1,6 @@
# Create main image
FROM python:3.11.17-alpine3.23 AS main
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
FROM python:3.10.14-alpine3.20 AS main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -8,7 +8,7 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
LABEL org.opencontainers.image.title="Mobile Verification Toolkit (Android)"
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
LABEL org.opencontainers.image.licenses="MVT License 1.1"
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.11.17-alpine3.23
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.10.14-alpine3.20
# Install runtime dependencies
RUN apk add --no-cache \
+1 -1
View File
@@ -1,7 +1,7 @@
# Main image
# ----------
FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.13.5 /uv /uvx /usr/local/bin/
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
+16
View File
@@ -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-<bundle ID>` 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.
+7 -2
View File
@@ -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}"',
+3
View File
@@ -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
+3
View File
@@ -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
+168
View File
@@ -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<container>[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
+49 -2
View File
@@ -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
@@ -213,6 +245,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()
+35 -7
View File
@@ -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,11 +100,12 @@ 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).replace(
os.sep, "/"
),
"modified": convert_unix_to_iso(os.stat(dir_path).st_mtime),
"is_directory": True,
}
except Exception:
continue
@@ -101,6 +121,14 @@ class Filesystem(IOSExtraction):
),
"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:
+47
View File
@@ -0,0 +1,47 @@
"""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
# Filesystem records and existing indicators may both use relative paths.
return indicators.check_file_path(posixpath.normpath(path.replace("\\", "/")))
+284
View File
@@ -0,0 +1,284 @@
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"<?xml version='1.0'?><plist><broken",
b"x" * (MAX_ARTIFACT_SIZE + 1),
],
ids=["invalid", "malformed-xml", "oversized"],
)
def test_malformed_or_oversized_content(tmp_path, raw):
path = tmp_path / "artifact"
path.write_bytes(raw)
assert (
inspect_coruna_artifact(str(path), {"kind": "device-cache"})["content_status"]
!= "matched"
)
@pytest.mark.parametrize(
"data",
[{**CACHE_DATA, "exp": "other"}, {"exp": "plasma"}, {**CACHE_DATA, "ecid": ""}],
)
def test_device_cache_content_requires_identifiers(tmp_path, data):
path = tmp_path / "artifact"
path.write_text(json.dumps(data))
assert (
inspect_coruna_artifact(str(path), {"kind": "device-cache"})["content_status"]
== "unmatched"
)
def test_correlation_requires_matching_identifiers_in_distinct_containers(tmp_path):
path = tmp_path / "cache"
path.write_text(json.dumps(CACHE_DATA))
a = inspect_coruna_artifact(str(path), {"kind": "device-cache", "container": "one"})
store = AlertStore()
correlate_coruna_records([{"coruna_artifact": a}, {"coruna_artifact": a}], store)
assert not store.alerts
correlate_coruna_records(
[{"coruna_artifact": a}, {"coruna_artifact": {**a, "container": "two"}}], store
)
assert len(store.alerts) == 1
assert store.alerts[0].event["containers"] == ["one", "two"]
assert store.alerts[0].level == AlertLevel.HIGH
def test_path_only_alert_is_medium():
store = AlertStore()
alert_coruna_record({"path": PHOTO, "isodate": "2026-09-15"}, store)
assert store.alerts[0].level == AlertLevel.MEDIUM
assert store.alerts[0].event_time == "2026-09-15"
@pytest.mark.parametrize(
"ioc,path,expected",
[
("/private/var/tmp/.ghost_upper", "/var/tmp/.ghost_upper/manifest.json", True),
("/var/tmp/ioslab_ctl", "private\\var\\tmp\\ioslab_ctl", True),
("/var/tmp/ioslab_ctl", "/var/tmp/ioslab_ctl2", False),
("/private/var/tmp/.ghost_upper/", "/var/tmp/.ghost_upper-copy/file", False),
("matched/directory", "matched/directory/file", True),
("matched/directory", "matched\\directory\\file", True),
("matched/directory", "matched/directory-copy/file", False),
("matched/directory", "matched/directory/../other/file", False),
],
)
def test_ios_path_iocs_respect_aliases_and_boundaries(ioc, path, expected):
assert bool(check_ios_path(indicators(file_paths=[ioc]), path)) is expected
def test_filesystem_reads_candidate_and_hashes_when_requested(tmp_path):
path = tmp_path / PHOTO.lstrip("/")
path.parent.mkdir(parents=True)
path.write_bytes(plistlib.dumps(PHOTO_DATA))
digest = hashlib.sha256(path.read_bytes()).hexdigest()
module = Filesystem(
target_path=str(tmp_path), module_options={"check_file_hashes": True}
)
module.indicators = indicators(files_sha256=[digest])
module.run()
module.check_indicators()
assert sorted(a.level.value for a in module.alertstore.alerts) == [30, 40]
record = next(r for r in module.results if "sha256" in r)
replay = Filesystem(results=[record])
replay.indicators = module.indicators
replay.check_indicators()
assert sorted(a.level.value for a in replay.alertstore.alerts) == [30, 40]
def test_filesystem_does_not_hash_by_default(tmp_path):
(tmp_path / "file").write_bytes(b"example")
module = Filesystem(target_path=str(tmp_path))
module.run()
assert all("sha256" not in r for r in module.results)
def test_backup_reads_coruna_candidate_and_maps_path_ioc(tmp_path):
file_id = "a" * 40
path = tmp_path / "aa" / file_id
path.parent.mkdir()
path.write_bytes(plistlib.dumps(PHOTO_DATA))
with sqlite3.connect(tmp_path / "Manifest.db") as db:
db.execute(
"CREATE TABLE Files(fileID TEXT, domain TEXT, relativePath TEXT, flags INTEGER, file BLOB)"
)
db.execute(
"INSERT INTO Files VALUES(?,?,?,?,?)",
(
file_id,
"HomeDomain",
"Library/Preferences/" + PHOTO.rsplit("/", 1)[-1],
1,
None,
),
)
db.execute(
"INSERT INTO Files VALUES(?,?,?,?,?)",
("b" * 40, "MediaDomain", "PostLogs.txt", 1, None),
)
db.close()
module = Manifest(
target_path=str(tmp_path), module_options={"check_file_hashes": True}
)
module.indicators = indicators(
file_paths=["/var/mobile/Media/PostLogs.txt"],
files_sha256=[hashlib.sha256(path.read_bytes()).hexdigest()],
)
module.run()
module.check_indicators()
assert sorted(a.level.value for a in module.alertstore.alerts) == [30, 30, 40]
def test_acquisition_boundary(tmp_path):
root = tmp_path / "root"
root.mkdir()
outside = tmp_path / "outside"
outside.write_bytes(b"not evidence")
assert not contained_regular_file(str(root), str(outside))
@pytest.mark.parametrize(
"metadata", [{"flags": 2}, {"is_directory": True}, {"mode": 16877}]
)
def test_directories_are_not_coruna_file_artifacts(metadata):
store = AlertStore()
alert_coruna_record({"path": PHOTO, **metadata}, store)
assert not store.alerts
def test_hash_option_reaches_ios_modules():
from mvt.ios.cmd_check_backup import CmdIOSCheckBackup
from mvt.ios.cmd_check_fs import CmdIOSCheckFS
for command in (CmdIOSCheckBackup, CmdIOSCheckFS):
assert command(hashes=True).module_options["check_file_hashes"]