mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-22 15:18:26 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
215b460a2e | ||
|
|
e9f868a71a | ||
|
|
b3aa443819 | ||
|
|
aac41ec8c2 | ||
|
|
1e05f438d7 | ||
|
|
10f739d669 | ||
|
|
94e9cd3712 | ||
|
|
300cc26112 | ||
|
|
3c12e1a019 | ||
|
|
c86bc93852 | ||
|
|
3a462e1e2c | ||
|
|
dbbcdd52df | ||
|
|
f9d179b263 | ||
|
|
d0cf303aa2 | ||
|
|
86b217a926 |
No files matched your search
+13
-116
@@ -1,107 +1,3 @@
|
||||
# Base image for building libraries
|
||||
# ---------------------------------
|
||||
FROM ubuntu:22.04 AS build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install build tools and dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool-bin \
|
||||
pkg-config \
|
||||
libcurl4-openssl-dev \
|
||||
libusb-1.0-0-dev \
|
||||
libssl-dev \
|
||||
udev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# libplist
|
||||
# --------
|
||||
FROM build-base AS build-libplist
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libplist
|
||||
|
||||
|
||||
# libimobiledevice-glue
|
||||
# ---------------------
|
||||
FROM build-base AS build-libimobiledevice-glue
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libimobiledevice-glue
|
||||
|
||||
|
||||
# libtatsu
|
||||
# --------
|
||||
FROM build-base AS build-libtatsu
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libtatsu
|
||||
|
||||
|
||||
# libusbmuxd
|
||||
# ----------
|
||||
FROM build-base AS build-libusbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libusbmuxd
|
||||
|
||||
|
||||
# libimobiledevice
|
||||
# ----------------
|
||||
FROM build-base AS build-libimobiledevice
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \
|
||||
&& ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libimobiledevice
|
||||
|
||||
|
||||
# usbmuxd
|
||||
# -------
|
||||
FROM build-base AS build-usbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
COPY --from=build-libimobiledevice /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
|
||||
&& ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib
|
||||
|
||||
|
||||
# Create main image
|
||||
FROM ubuntu:24.04 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
@@ -112,7 +8,7 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
|
||||
LABEL org.opencontainers.image.title="Mobile Verification Toolkit"
|
||||
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/ubuntu:22.04
|
||||
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04
|
||||
|
||||
# Install runtime dependencies
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
@@ -125,7 +21,6 @@ RUN apt-get update \
|
||||
git \
|
||||
jq \
|
||||
less \
|
||||
libcurl4 \
|
||||
libimage-exiftool-perl \
|
||||
libssl3 \
|
||||
libusb-1.0-0 \
|
||||
@@ -136,18 +31,20 @@ RUN apt-get update \
|
||||
sqlite3 \
|
||||
tree \
|
||||
unzip \
|
||||
xxd
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
COPY --from=build-libimobiledevice /build /
|
||||
COPY --from=build-usbmuxd /build /
|
||||
xxd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install mvt using the locally checked out source
|
||||
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
|
||||
ARG PYMOBILEDEVICE3_VERSION=11.12.4
|
||||
COPY . mvt/
|
||||
RUN uv pip install --system --break-system-packages --no-cache ./mvt \
|
||||
&& rm -rf mvt
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \
|
||||
&& uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \
|
||||
&& apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \
|
||||
&& rm -rf mvt /var/lib/apt/lists/* \
|
||||
&& pymobiledevice3 --help > /dev/null \
|
||||
&& pymobiledevice3 backup2 --help > /dev/null \
|
||||
&& mvt-ios --help > /dev/null
|
||||
|
||||
# Installing ABE
|
||||
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
|
||||
|
||||
+22
-122
@@ -1,110 +1,6 @@
|
||||
# Base image for building libraries
|
||||
# ---------------------------------
|
||||
FROM ubuntu:22.04 AS build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install build tools and dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool-bin \
|
||||
pkg-config \
|
||||
libcurl4-openssl-dev \
|
||||
libusb-1.0-0-dev \
|
||||
libssl-dev \
|
||||
udev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# libplist
|
||||
# --------
|
||||
FROM build-base AS build-libplist
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libplist
|
||||
|
||||
|
||||
# libimobiledevice-glue
|
||||
# ---------------------
|
||||
FROM build-base AS build-libimobiledevice-glue
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libimobiledevice-glue
|
||||
|
||||
|
||||
# libtatsu
|
||||
# --------
|
||||
FROM build-base AS build-libtatsu
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libtatsu
|
||||
|
||||
|
||||
# libusbmuxd
|
||||
# ----------
|
||||
FROM build-base AS build-libusbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libusbmuxd
|
||||
|
||||
|
||||
# libimobiledevice
|
||||
# ----------------
|
||||
FROM build-base AS build-libimobiledevice
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \
|
||||
&& ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libimobiledevice
|
||||
|
||||
|
||||
# usbmuxd
|
||||
# -------
|
||||
FROM build-base AS build-usbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
COPY --from=build-libimobiledevice /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
|
||||
&& ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib
|
||||
|
||||
|
||||
# Main image
|
||||
# ----------
|
||||
FROM python:3.10.14-alpine3.20 AS main
|
||||
FROM ubuntu:24.04 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"
|
||||
@@ -113,26 +9,30 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
|
||||
LABEL org.opencontainers.image.title="Mobile Verification Toolkit (iOS)"
|
||||
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.10.14-alpine3.20
|
||||
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
gcompat \
|
||||
libcurl \
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
libssl3 \
|
||||
libusb \
|
||||
sqlite
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
COPY --from=build-libimobiledevice /build /
|
||||
COPY --from=build-usbmuxd /build /
|
||||
libusb-1.0-0 \
|
||||
python3 \
|
||||
sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install mvt using the locally checked out source
|
||||
COPY ./ mvt
|
||||
RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \
|
||||
&& uv pip install --system --no-cache ./mvt \
|
||||
&& apk del .build-deps git gcc musl-dev && rm -rf ./mvt
|
||||
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
|
||||
ARG PYMOBILEDEVICE3_VERSION=11.12.4
|
||||
COPY . mvt/
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \
|
||||
&& uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \
|
||||
&& apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \
|
||||
&& rm -rf mvt /var/lib/apt/lists/* \
|
||||
&& pymobiledevice3 --help > /dev/null \
|
||||
&& pymobiledevice3 backup2 --help > /dev/null \
|
||||
&& mvt-ios --help > /dev/null
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
|
||||
+24
-3
@@ -1,4 +1,6 @@
|
||||
Using Docker simplifies having all the required dependencies and tools (including most recent versions of [libimobiledevice](https://libimobiledevice.org)) readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container).
|
||||
Using Docker simplifies running MVT with its dependencies readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container).
|
||||
|
||||
The main and iOS Docker images include pymobiledevice3 for [creating iOS backups](ios/backup/pymobiledevice3.md). You do not need to install it separately on the host. Older image tags may still include libimobiledevice instead; build from the updated source if pymobiledevice3 is unavailable.
|
||||
|
||||
Install Docker following the [official documentation](https://docs.docker.com/get-docker/).
|
||||
|
||||
@@ -37,12 +39,31 @@ If a prompt is spawned successfully, you can close it with `exit`.
|
||||
|
||||
On the Linux host, install and start [usbmuxd](https://github.com/libimobiledevice/usbmuxd), then connect and unlock the iOS device. The daemon exposes the device through the `/var/run/usbmuxd` socket.
|
||||
|
||||
Bind that socket into the container to let MVT communicate with the device:
|
||||
Bind that socket into the container to let pymobiledevice3 communicate with the device. Also mount a local directory so acquired backups persist after the container exits:
|
||||
|
||||
```bash
|
||||
docker run -it \
|
||||
mkdir -p "$PWD/cases"
|
||||
docker run --rm -it \
|
||||
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
|
||||
--mount type=bind,source="$PWD/cases",target=/home/cases \
|
||||
ghcr.io/mvt-project/mvt
|
||||
```
|
||||
|
||||
Inside the container, verify connectivity:
|
||||
|
||||
```bash
|
||||
pymobiledevice3 lockdown info
|
||||
```
|
||||
|
||||
Accept the trust prompt on the unlocked device if requested. Then follow the [backup instructions](ios/backup/pymobiledevice3.md), using a destination under `/home/cases`, such as `/home/cases/backup`.
|
||||
|
||||
The iOS-only image defaults to running `mvt-ios`. To use pymobiledevice3 instead, override its entrypoint:
|
||||
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
|
||||
--entrypoint pymobiledevice3 \
|
||||
ghcr.io/mvt-project/mvt:latest-ios lockdown info
|
||||
```
|
||||
|
||||
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
|
||||
@@ -1,19 +1,3 @@
|
||||
# Backup with libimobiledevice
|
||||
|
||||
If you have correctly [installed libimobiledevice](../install.md) you can easily generate an iTunes backup using the `idevicebackup2` tool included in the suite. First, you might want to ensure that backup encryption is enabled (**note: encrypted backup contain more data than unencrypted backups**):
|
||||
|
||||
```bash
|
||||
idevicebackup2 -i encryption on
|
||||
```
|
||||
|
||||
Note that if a backup password was previously set on this device, you might need to use the same or change it. You can try changing password using `idevicebackup2 -i changepw`, or by turning off encryption (`idevicebackup2 -i encryption off`) and turning it back on again.
|
||||
|
||||
If you are not able to recover or change the password, you should try to disable encryption and obtain an unencrypted backup.
|
||||
|
||||
If all else fails, as a *last resort* you can try resetting the password by [resetting all the settings through the iPhone's Settings app](https://support.apple.com/en-us/HT205220), via `Settings » General » Reset » Reset All Settings`. Note that resetting the settings through the iPhone's Settings app will wipe some of the files that contain useful forensic traces, so try the options explained above first.
|
||||
|
||||
Once ready, you can proceed performing the backup:
|
||||
|
||||
```bash
|
||||
idevicebackup2 backup --full /path/to/backup/
|
||||
```
|
||||
We now recommend pymobiledevice3 for iOS backups. See [Backup with pymobiledevice3](pymobiledevice3.md) for the current instructions.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Backup with pymobiledevice3
|
||||
|
||||
After [installing pymobiledevice3](../install.md) and pairing your device, you can generate an iTunes-compatible backup using its `backup2` commands.
|
||||
|
||||
## Enable backup encryption
|
||||
|
||||
We recommend encrypted backups because they contain more data than unencrypted backups. If encryption is not already enabled, enable it with:
|
||||
|
||||
```bash
|
||||
pymobiledevice3 backup2 encryption on 'YOUR_BACKUP_PASSWORD'
|
||||
```
|
||||
|
||||
Replace `YOUR_BACKUP_PASSWORD` with a strong password and keep it safe: you will need it to decrypt the backup for analysis. Passwords supplied on the command line may be saved in shell history or visible to other processes.
|
||||
|
||||
If a backup password was previously set, use that password. To change a known password:
|
||||
|
||||
```bash
|
||||
pymobiledevice3 backup2 change-password 'CURRENT_PASSWORD' 'NEW_PASSWORD'
|
||||
```
|
||||
|
||||
You can also disable encryption with `pymobiledevice3 backup2 encryption off 'CURRENT_PASSWORD'` and then enable it again. Disabling encryption requires the current password; it is not a way to bypass an unknown password.
|
||||
|
||||
!!! warning
|
||||
If you cannot recover the password, resetting it through [Reset All Settings in the iPhone's Settings app](https://support.apple.com/en-us/HT205220) should be a last resort. Resetting settings can remove files containing useful forensic traces.
|
||||
|
||||
## Create a backup
|
||||
|
||||
Choose a new destination directory to avoid overwriting an earlier acquisition:
|
||||
|
||||
```bash
|
||||
mkdir -p /path/to/backup/
|
||||
pymobiledevice3 backup2 backup --full /path/to/backup/
|
||||
```
|
||||
|
||||
The backup is saved in a subdirectory named after the device's UDID, such as `/path/to/backup/udid/`. Follow [Check a Backup with mvt-ios](check.md) to decrypt and analyze that directory.
|
||||
|
||||
For additional options, see the [pymobiledevice3 backup2 reference](https://doronz88.github.io/pymobiledevice3/cli/backup2/).
|
||||
@@ -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.
|
||||
+20
-31
@@ -1,55 +1,44 @@
|
||||
# Install libimobiledevice
|
||||
# Install pymobiledevice3
|
||||
|
||||
Before proceeding with doing any acquisition of iOS devices we recommend installing [libimobiledevice](https://libimobiledevice.org/) utilities. These utilities will become useful when extracting crash logs and generating iTunes backups. Because the utilities and its libraries are subject to frequent changes in response to new versions of iOS, you might want to consider compiling libimobiledevice utilities from sources. Otherwise, if available, you can try installing packages available in your distribution:
|
||||
Before acquiring data from an iOS device, we recommend installing [pymobiledevice3](https://github.com/doronz88/pymobiledevice3). It provides command-line tools for generating iTunes-compatible backups and extracting crash logs.
|
||||
|
||||
## Installation
|
||||
|
||||
Install `pipx` following the [MVT installation instructions](../install.md), then run:
|
||||
|
||||
```bash
|
||||
sudo apt install libimobiledevice-utils
|
||||
pipx install pymobiledevice3
|
||||
```
|
||||
|
||||
On Mac, you can try installing it from brew:
|
||||
To update an existing installation:
|
||||
|
||||
```bash
|
||||
brew install --HEAD libimobiledevice
|
||||
pipx upgrade pymobiledevice3
|
||||
```
|
||||
|
||||
If you have a reasonably recent version of libimobiledevice in your package manager, it might work straight out of the box. Try connecting your iOS device to your computer via USB and run:
|
||||
On Linux, you also need `usbmuxd` to communicate with devices over USB. On Debian and Ubuntu, install it with:
|
||||
|
||||
```bash
|
||||
ideviceinfo
|
||||
sudo apt install usbmuxd
|
||||
```
|
||||
|
||||
If you encounter unexpected issues, uninstall the packages and try compiling libimobiledevcice from sources.
|
||||
macOS includes the required USB device service. For other platforms and additional requirements, see the [pymobiledevice3 installation documentation](https://doronz88.github.io/pymobiledevice3/installation/).
|
||||
|
||||
## Compile libimobiledevice from sources
|
||||
## Verify connectivity
|
||||
|
||||
!!! warning
|
||||
The following instructions are a best effort. The installation from source requires several steps, and it is likely some have been forgotten here and that won't work for you. You will likely need to fiddle around a bit before getting this right.
|
||||
|
||||
Make sure you have uninstalled all the libimobiledevice tools from your package manage:
|
||||
Connect the iOS device to your computer with a USB cable and unlock it. Accept the **Trust This Computer** prompt and enter the device passcode if requested. Then run:
|
||||
|
||||
```bash
|
||||
sudo apt remove --purge libimobiledevice-utils libimobiledevice-dev libimobiledevice6 libplist-dev libplist3 libusbmuxd-dev libusbmuxd-tools libusbmuxd4 libusbmuxd6 usbmuxd
|
||||
pymobiledevice3 usbmux list
|
||||
pymobiledevice3 lockdown info
|
||||
```
|
||||
|
||||
Firstly you need to install [libplist](https://github.com/libimobiledevice/libplist). Then you can install [libusbmuxd](https://github.com/libimobiledevice/libusbmuxd).
|
||||
|
||||
Now you should be able to to download and install the actual suite of tools at [https://github.com/libimobiledevice/libimobiledevice](https://github.com/libimobiledevice/libimobiledevice).
|
||||
|
||||
You can now also build and install [usbmuxd](https://github.com/libimobiledevice/usbmuxd).
|
||||
|
||||
## Making sure everything works fine.
|
||||
|
||||
Once the idevice tools are available you can check if everything works fine by connecting your iOS device and running:
|
||||
These commands should list the connected device and display its details. If pairing is needed, run:
|
||||
|
||||
```bash
|
||||
ideviceinfo
|
||||
pymobiledevice3 lockdown pair
|
||||
```
|
||||
|
||||
This should show many details on the connected iOS device. If you are connecting the device to your laptop for the first time, it will require to unlock and enter the PIN code on the mobile device. If it complains that no device is connected and the mobile device is indeed plugged in through the USB cable, you might need to do this first, although typically the pairing is automatically done when connecting the device:
|
||||
If no device is found, check the USB connection, make sure the device is unlocked, and, on Linux, check that `usbmuxd` is running. See the [upstream troubleshooting guide](https://doronz88.github.io/pymobiledevice3/guides/troubleshooting/) for further help.
|
||||
|
||||
```bash
|
||||
sudo usbmuxd -f -v
|
||||
idevicepair pair
|
||||
```
|
||||
|
||||
Again, it will ask to unlock the phone and enter the PIN code.
|
||||
Once connected, follow the [backup instructions](backup/pymobiledevice3.md).
|
||||
+3
-2
@@ -5,6 +5,7 @@ copyright: Copyright © 2021-2023 MVT Project Developers
|
||||
site_description: Mobile Verification Toolkit Documentation
|
||||
not_in_nav: |
|
||||
android/adb.md
|
||||
ios/backup/libimobiledevice.md
|
||||
markdown_extensions:
|
||||
- attr_list
|
||||
- admonition
|
||||
@@ -34,10 +35,10 @@ nav:
|
||||
- Using Docker: "docker.md"
|
||||
- MVT for iOS:
|
||||
- iOS Forensic Methodology: "ios/methodology.md"
|
||||
- Install libimobiledevice: "ios/install.md"
|
||||
- Install pymobiledevice3: "ios/install.md"
|
||||
- Check an iTunes Backup:
|
||||
- Backup with iTunes app: "ios/backup/itunes.md"
|
||||
- Backup with libimobiledevice: "ios/backup/libimobiledevice.md"
|
||||
- Backup with pymobiledevice3: "ios/backup/pymobiledevice3.md"
|
||||
- Check a Backup with mvt-ios: "ios/backup/check.md"
|
||||
- Check a Filesystem Dump:
|
||||
- Dumping the filesystem: "ios/filesystem/dump.md"
|
||||
|
||||
@@ -33,7 +33,12 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
line = line.removesuffix(b"\r")
|
||||
# Track the level of indentation
|
||||
indent = len(line) - len(line.lstrip())
|
||||
if indent < cur_indent:
|
||||
# A closing brace is dedented from the block it closes, and the
|
||||
# brace handler below pops that block. Popping on the dedent as
|
||||
# well would close two levels for one brace. A blank line has no
|
||||
# indentation to compare: it ends a multiline value (handled
|
||||
# below) and is otherwise skipped.
|
||||
if indent < cur_indent and line.strip() not in (b"", b"}"):
|
||||
# If the current line is less indented than the previous one, back out
|
||||
if len(stack) <= 1:
|
||||
# Dedenting below the outermost level means this is not the
|
||||
@@ -72,6 +77,9 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
stack.pop()
|
||||
current_dict = stack[-1]
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
if key == "}":
|
||||
if len(stack) <= 1:
|
||||
self.log.error(
|
||||
|
||||
@@ -255,19 +255,25 @@ class Settings(AndroidArtifact):
|
||||
dumpsys prints the change history without a year, so it is resolved
|
||||
against the time the section was dumped: the most recent matching date
|
||||
at or before that time.
|
||||
|
||||
The year is parsed together with the value, because strptime() would
|
||||
otherwise parse it in 1900, which has no 29 February. The most recent
|
||||
matching leap day can be eight years earlier when a century year is
|
||||
not a leap year and the current year's leap day is still in the future.
|
||||
"""
|
||||
if section_end is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
partial = datetime.strptime(value, "%m-%d %H:%M:%S.%f")
|
||||
timestamp = partial.replace(year=section_end.year)
|
||||
if timestamp > section_end:
|
||||
timestamp = partial.replace(year=section_end.year - 1)
|
||||
except ValueError:
|
||||
return None
|
||||
for year in range(section_end.year, section_end.year - 9, -1):
|
||||
try:
|
||||
timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return convert_datetime_to_iso(timestamp)
|
||||
if timestamp <= section_end:
|
||||
return convert_datetime_to_iso(timestamp)
|
||||
|
||||
return None
|
||||
|
||||
def _parse_history(
|
||||
self, line: str, section_end: Optional[datetime]
|
||||
|
||||
@@ -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}"',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("\\", "/")))
|
||||
@@ -209,6 +209,85 @@ class TestDumpsysADBArtifact:
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["user_keys"][0]["user"] == user.decode()
|
||||
|
||||
def test_state_without_keys_is_parsed_without_error(self, caplog):
|
||||
# Shape printed by every device with no stored ADB keys (Samsung
|
||||
# SM-A525F, Android 12). The block's closing brace is dedented from
|
||||
# the last entry and must close exactly one level.
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=false\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
b"--------- 0.015s was the duration of dumpsys adb\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["connected_to_adb"] is False
|
||||
assert not [r for r in caplog.records if r.levelname == "ERROR"]
|
||||
|
||||
def test_nested_block_as_last_entry_is_parsed_without_error(self, caplog):
|
||||
# Shape printed by Pixel devices with no stored ADB keys.
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=false\n"
|
||||
b" adb_wifi={\n"
|
||||
b" enabled=false\n"
|
||||
b" tls_port=0\n"
|
||||
b" }\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["connected_to_adb"] is False
|
||||
assert da_adb.results[0]["adb_wifi"]["tls_port"] == b"0"
|
||||
assert not [r for r in caplog.records if r.levelname == "ERROR"]
|
||||
|
||||
def test_entry_after_nested_block_stays_in_debugging_manager(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" adb_wifi={\n"
|
||||
b" enabled=false\n"
|
||||
b" }\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["connected_to_adb"] is True
|
||||
|
||||
def test_blank_line_after_single_line_value_does_not_raise(self):
|
||||
# `system_keys` is the content of /adb_keys and ends with a newline,
|
||||
# so a blank line follows it. It is not a multiline field, so the
|
||||
# blank line is not a multiline terminator either.
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" user_keys=QUJDRA== host@example\n"
|
||||
b"\n"
|
||||
b" system_keys=RUZHSA== system@example\n"
|
||||
b"\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["user_keys"][0]["user"] == "host@example"
|
||||
assert da_adb.results[0]["system_keys"] == "RUZHSA== system@example"
|
||||
|
||||
def test_unbalanced_state_is_reported_rather_than_raising(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# 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 Optional
|
||||
|
||||
from mvt.android.artifacts.settings import Settings
|
||||
|
||||
from ..utils import get_artifact
|
||||
@@ -21,6 +23,21 @@ def find(settings: Settings, name: str) -> list:
|
||||
return [result for result in settings.results if result["name"] == name]
|
||||
|
||||
|
||||
def resolve_history_time(time: str, section_end: str) -> Optional[str]:
|
||||
settings = Settings()
|
||||
settings.parse(
|
||||
"SECURE SETTINGS (user 0)\n"
|
||||
"_id:240 name:accessibility_enabled pkg:android value:1\n"
|
||||
"\tHistory (accessibility_enabled)\n"
|
||||
f"\t\ttime:{time} mode:update oldValue:0 newValue:1 "
|
||||
"package:com.example.helper\n"
|
||||
"\n"
|
||||
"--------- 0.019s was the duration of dumpsys settings, "
|
||||
f"ending at: {section_end}\n"
|
||||
)
|
||||
return settings.results[0]["history"][0]["timestamp"]
|
||||
|
||||
|
||||
class TestSettingsArtifact:
|
||||
def test_parsing(self):
|
||||
settings = parse_bugreport_settings()
|
||||
@@ -159,6 +176,32 @@ class TestSettingsArtifact:
|
||||
}
|
||||
]
|
||||
|
||||
def test_leap_day_history_keeps_its_timestamp(self):
|
||||
assert (
|
||||
resolve_history_time("02-29 22:41:07.980", "2024-03-05 23:14:28")
|
||||
== "2024-02-29 22:41:07.980000"
|
||||
)
|
||||
|
||||
def test_leap_day_history_resolved_to_the_last_leap_year(self):
|
||||
# The most recent 29 February at or before the dump can be several
|
||||
# years back when the dump was taken in a year without one.
|
||||
assert (
|
||||
resolve_history_time("02-29 22:41:07.980", "2025-03-01 10:00:00")
|
||||
== "2024-02-29 22:41:07.980000"
|
||||
)
|
||||
assert (
|
||||
resolve_history_time("02-29 22:41:07.980", "2024-01-10 10:00:00")
|
||||
== "2020-02-29 22:41:07.980000"
|
||||
)
|
||||
|
||||
def test_leap_day_history_before_a_leap_day_after_a_skipped_century(self):
|
||||
# 2100 is not a leap year, so before 29 February 2104 the most
|
||||
# recent matching date is eight years earlier, in 2096.
|
||||
assert (
|
||||
resolve_history_time("02-29 22:41:07.980", "2104-01-10 10:00:00")
|
||||
== "2096-02-29 22:41:07.980000"
|
||||
)
|
||||
|
||||
def test_dangerous_setting_is_detected_with_the_changing_package(self):
|
||||
settings = parse_bugreport_settings()
|
||||
settings.check_indicators()
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user