mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-22 15:18:26 +02:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3222bcd98f | ||
|
|
b3aa443819 | ||
|
|
aac41ec8c2 | ||
|
|
1e05f438d7 | ||
|
|
10f739d669 | ||
|
|
94e9cd3712 | ||
|
|
300cc26112 | ||
|
|
3c12e1a019 | ||
|
|
c86bc93852 | ||
|
|
3a462e1e2c | ||
|
|
c95eeed825 | ||
|
|
60d1b414fe | ||
|
|
563fe44bf3 | ||
|
|
85fca1c825 | ||
|
|
3704bf26c0 | ||
|
|
a80b0fff2e | ||
|
|
f98f4f6cd2 | ||
|
|
3e99412c32 | ||
|
|
fd37c4d8d9 | ||
|
|
c615d5e304 | ||
|
|
9bdc25f57d | ||
|
|
8238d80035 | ||
|
|
22d97f0a88 | ||
|
|
d224db5a78 | ||
|
|
658c7aa327 | ||
|
|
9316ac7fb0 | ||
|
|
6ddb2c9671 | ||
|
|
165d87b97f | ||
|
|
d2e992e40e | ||
|
|
aa3ae511ad | ||
|
|
02aa23cb81 | ||
|
|
99b3b5f014 | ||
|
|
9ca4254649 | ||
|
|
f1e52b297a | ||
|
|
8c5278f540 | ||
|
|
ff5ebf73cc | ||
|
|
dbbcdd52df | ||
|
|
d0cf303aa2 | ||
|
|
86b217a926 | ||
|
|
5169a46b59 | ||
|
|
5a129430ae | ||
|
|
190a5f119f | ||
|
|
02c52f43c8 |
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" ]
|
||||
+25
-4
@@ -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
|
||||
```
|
||||
|
||||
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `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
-1
@@ -1,6 +1,6 @@
|
||||
# Installation
|
||||
|
||||
Before proceeding, please note that MVT requires Python 3.6+ to run. While it should be available on most operating systems, please make sure of that before proceeding.
|
||||
Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
|
||||
|
||||
## Dependencies on Linux
|
||||
|
||||
|
||||
@@ -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/).
|
||||
+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).
|
||||
@@ -204,6 +204,10 @@ This JSON file is created by mvt-ios' `Manifest` module. The module extracts rec
|
||||
|
||||
If indicators are provided through the command-line, they are checked against the original relative path in case. In some cases, there might be records of files created containing a domain name in their name, for example in the case of browser cache folders. Any matches are stored in *manifest_detected.json*.
|
||||
|
||||
An incomplete backup still lists in its manifest the files it failed to acquire. Those records carry a `"missing": true` field, and the module reports how many of them it found. Use it to tell a module which returned nothing because the artifact was not acquired apart from one which found nothing on a device that never had it.
|
||||
|
||||
Files must be stored at their expected paths inside the backup folder. If the module cannot finish listing the backup files, it logs a warning and skips the missing-file check; the manifest metadata is still extracted.
|
||||
|
||||
---
|
||||
|
||||
### `os_analytics_ad_daily.json`
|
||||
|
||||
+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"
|
||||
|
||||
@@ -29,11 +29,26 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
stack = [res]
|
||||
cur_indent = 0
|
||||
in_multiline = False
|
||||
for line in dump_data.strip(b"\n").split(b"\n"):
|
||||
for line in dump_data.strip(b"\r\n").split(b"\n"):
|
||||
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
|
||||
# well-formed block the parser expects. Stop here rather than
|
||||
# raise IndexError out of the module on the next line.
|
||||
self.log.error(
|
||||
"Unexpected indentation in ADB manager state, "
|
||||
"stopping the parse of this section"
|
||||
)
|
||||
break
|
||||
stack.pop()
|
||||
cur_indent = indent
|
||||
else:
|
||||
@@ -62,7 +77,16 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
stack.pop()
|
||||
current_dict = stack[-1]
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
if key == "}":
|
||||
if len(stack) <= 1:
|
||||
self.log.error(
|
||||
"Unbalanced closing brace in ADB manager state, "
|
||||
"stopping the parse of this section"
|
||||
)
|
||||
break
|
||||
stack.pop()
|
||||
continue
|
||||
|
||||
@@ -161,6 +185,26 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
f"'{user_key['fingerprint']}'"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_state_end(content: bytes, open_brace: int) -> int:
|
||||
"""Find the unindented line closing the ADB manager state, or -1.
|
||||
|
||||
Braces inside key comments or embedded keystore data are values, while
|
||||
nested structural closing braces are indented.
|
||||
"""
|
||||
line_start = open_brace
|
||||
while line_start < len(content):
|
||||
line_end = content.find(b"\n", line_start)
|
||||
if line_end == -1:
|
||||
line_end = len(content)
|
||||
line = content[line_start:line_end].removesuffix(b"\r")
|
||||
if line_start > open_brace and line == b"}":
|
||||
return line_start
|
||||
if line.startswith((b"---------", b"DUMP OF SERVICE ")):
|
||||
break
|
||||
line_start = line_end + 1
|
||||
return -1
|
||||
|
||||
def parse(self, content: bytes) -> None:
|
||||
"""
|
||||
Parse the Dumpsys ADB section
|
||||
@@ -180,12 +224,16 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
self.log.error("Unable to find ADB manager state in dumpsys output")
|
||||
return
|
||||
|
||||
end_of_json = content.rfind(b"}\n")
|
||||
if end_of_json == -1 or end_of_json <= start_of_json:
|
||||
self.log.error("Unable to find complete ADB manager state in dumpsys output")
|
||||
end_of_json = self._find_state_end(content, start_of_json + 1)
|
||||
if end_of_json == -1:
|
||||
self.log.error(
|
||||
"Unable to find complete ADB manager state in dumpsys output"
|
||||
)
|
||||
return
|
||||
|
||||
json_content = content[start_of_json + 2 : end_of_json - 2].rstrip()
|
||||
# The brace that opens the state and the one that closes it are not part
|
||||
# of the indented body.
|
||||
json_content = content[start_of_json + 2 : end_of_json].rstrip()
|
||||
|
||||
parsed = self.indented_dump_parser(json_content)
|
||||
if parsed.get("debugging_manager") is None:
|
||||
|
||||
@@ -73,8 +73,12 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
line = line.strip().replace("Update ", "")
|
||||
package_name, vers = line.split(" ", 1)
|
||||
vers_raw = vers.split("=", 1)[1]
|
||||
# A truncated or vendor-specific line must not abort the parse and
|
||||
# lose every record after it.
|
||||
package_name, _, vers = line.partition(" ")
|
||||
vers_raw = vers.partition("=")[2]
|
||||
if not package_name or not vers_raw:
|
||||
continue
|
||||
try:
|
||||
version_code: int | str = int(vers_raw)
|
||||
except ValueError:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -145,7 +145,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
|
||||
# Add some extra metadata
|
||||
tombstone_dict["timestamp"] = self._parse_timestamp_string(
|
||||
tombstone_pb.timestamp
|
||||
tombstone_pb.timestamp, file_timestamp
|
||||
)
|
||||
tombstone_dict["file_name"] = file_name
|
||||
tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp)
|
||||
@@ -288,11 +288,21 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
|
||||
def _load_timestamp_line(self, line: str, tombstone: dict) -> bool:
|
||||
timestamp = line.split(":", 1)[1].strip()
|
||||
tombstone["timestamp"] = self._parse_timestamp_string(timestamp)
|
||||
tombstone["timestamp"] = self._parse_timestamp_string(timestamp, None)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp_string(timestamp: str) -> str:
|
||||
def _parse_timestamp_string(
|
||||
timestamp: str, fallback_timestamp: Optional[datetime.datetime]
|
||||
) -> str:
|
||||
"""Parse timestamp string, using fallback if timestamp is empty."""
|
||||
# Handle empty or whitespace-only timestamps
|
||||
if not timestamp or not timestamp.strip():
|
||||
if fallback_timestamp:
|
||||
return convert_datetime_to_iso(fallback_timestamp)
|
||||
else:
|
||||
raise ValueError("Empty timestamp with no fallback provided")
|
||||
|
||||
timestamp_parsed = parser.parse(timestamp)
|
||||
return convert_datetime_to_iso(timestamp_parsed)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from mvt.common.module import MVTModule
|
||||
|
||||
from .dumpsys_accessibility import DumpsysAccessibility
|
||||
from .dumpsys_activities import DumpsysActivities
|
||||
from .dumpsys_adb_state import DumpsysADBState
|
||||
from .dumpsys_appops import DumpsysAppops
|
||||
from .dumpsys_battery_daily import DumpsysBatteryDaily
|
||||
from .dumpsys_battery_history import DumpsysBatteryHistory
|
||||
@@ -15,7 +16,6 @@ from .dumpsys_getprop import DumpsysGetProp
|
||||
from .dumpsys_packages import DumpsysPackages
|
||||
from .dumpsys_platform_compat import DumpsysPlatformCompat
|
||||
from .dumpsys_receivers import DumpsysReceivers
|
||||
from .dumpsys_adb_state import DumpsysADBState
|
||||
from .fs_timestamps import BugReportTimestamps
|
||||
from .tombstones import Tombstones
|
||||
from .mounts import Mounts
|
||||
|
||||
@@ -68,6 +68,11 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
|
||||
}
|
||||
try:
|
||||
data = self._get_file_content(file_path)
|
||||
if not data:
|
||||
self.log.debug("Skipping empty tombstone file %s (0 bytes)", file_path)
|
||||
source_info["error"] = "empty tombstone"
|
||||
parsed_sources[source] = source_info
|
||||
continue
|
||||
if source == "protobuf":
|
||||
record = self.parse_protobuf_record(
|
||||
file_name, file_timestamp, data
|
||||
|
||||
@@ -1279,5 +1279,9 @@
|
||||
{
|
||||
"version": "27.0",
|
||||
"build": "24A437"
|
||||
},
|
||||
{
|
||||
"version": "27.0.1",
|
||||
"build": "24A446"
|
||||
}
|
||||
]
|
||||
@@ -147,6 +147,12 @@ class Manifest(IOSExtraction):
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
# An incomplete backup still lists the files it failed to acquire in
|
||||
# its manifest. Only regular files are stored in the backup folder, so
|
||||
# directories and symlinks (flags 2 and 4) are not looked up.
|
||||
stored_file_ids = self._get_stored_backup_file_ids()
|
||||
missing_files = 0
|
||||
|
||||
for file_entry in cur:
|
||||
file_data = {}
|
||||
for index, value in enumerate(file_entry):
|
||||
@@ -160,6 +166,22 @@ class Manifest(IOSExtraction):
|
||||
"created": "",
|
||||
}
|
||||
|
||||
if (
|
||||
stored_file_ids is not None
|
||||
and file_data["flags"] == 1
|
||||
and file_data["fileID"] not in stored_file_ids
|
||||
):
|
||||
# Without this, a module which found nothing for one of these
|
||||
# files would look like a negative result rather than a gap in
|
||||
# the acquisition.
|
||||
cleaned_metadata["missing"] = True
|
||||
missing_files += 1
|
||||
self.log.debug(
|
||||
"File %s is listed in the manifest but was not found in the "
|
||||
"backup folder",
|
||||
cleaned_metadata["relative_path"],
|
||||
)
|
||||
|
||||
if file_data["file"]:
|
||||
try:
|
||||
file_plist = plistlib.load(io.BytesIO(file_data["file"]))
|
||||
@@ -197,3 +219,10 @@ class Manifest(IOSExtraction):
|
||||
conn.close()
|
||||
|
||||
self.log.info("Extracted a total of %d file metadata items", len(self.results))
|
||||
|
||||
if missing_files:
|
||||
self.log.info(
|
||||
"Found %d files listed in the manifest but missing from the backup "
|
||||
"folder. The backup might be incomplete.",
|
||||
missing_files,
|
||||
)
|
||||
@@ -216,6 +216,55 @@ class IOSExtraction(MVTModule):
|
||||
|
||||
return None
|
||||
|
||||
def _get_stored_backup_file_ids(self) -> Optional[set[str]]:
|
||||
"""List the IDs of the files actually stored in the backup folder.
|
||||
|
||||
A backup folder stores each file under a two character subfolder named
|
||||
after the first two characters of its file ID. Walking the folders once
|
||||
is cheaper than a filesystem lookup per file ID, and it keeps callers
|
||||
which compare a whole manifest against the folder from paying a
|
||||
`resolve()` for every entry.
|
||||
|
||||
:returns: The file IDs found at their expected paths within the backup
|
||||
folder, or None if the inventory could not be completed.
|
||||
"""
|
||||
if not self.target_path:
|
||||
return None
|
||||
|
||||
file_ids: set[str] = set()
|
||||
try:
|
||||
backup_root = Path(self.target_path).resolve()
|
||||
with os.scandir(self.target_path) as entries:
|
||||
for entry in entries:
|
||||
if len(entry.name) != 2 or any(
|
||||
char not in "0123456789abcdef" for char in entry.name
|
||||
):
|
||||
continue
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if not Path(entry.path).resolve().is_relative_to(backup_root):
|
||||
continue
|
||||
with os.scandir(entry.path) as sub_entries:
|
||||
for sub_entry in sub_entries:
|
||||
if sub_entry.name[:2] != entry.name:
|
||||
continue
|
||||
if sub_entry.is_symlink() and not Path(
|
||||
sub_entry.path
|
||||
).resolve().is_relative_to(backup_root):
|
||||
continue
|
||||
if sub_entry.is_file():
|
||||
file_ids.add(sub_entry.name)
|
||||
except OSError as exc:
|
||||
self.log.warning(
|
||||
"Unable to list the files stored in the backup folder %s: %s. "
|
||||
"Skipping the missing-file check.",
|
||||
self.target_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
return file_ids
|
||||
|
||||
def _get_fs_files_from_patterns(self, root_paths: list) -> Iterator[str]:
|
||||
if not self.target_path:
|
||||
return
|
||||
|
||||
@@ -82,7 +82,9 @@ class Filesystem(IOSExtraction):
|
||||
try:
|
||||
dir_path = os.path.join(root, dir_name)
|
||||
result = {
|
||||
"path": os.path.relpath(dir_path, self.target_path),
|
||||
"path": os.path.relpath(dir_path, self.target_path).replace(
|
||||
os.sep, "/"
|
||||
),
|
||||
"modified": convert_unix_to_iso(os.stat(dir_path).st_mtime),
|
||||
}
|
||||
except Exception:
|
||||
@@ -94,7 +96,9 @@ class Filesystem(IOSExtraction):
|
||||
try:
|
||||
file_path = os.path.join(root, file_name)
|
||||
result = {
|
||||
"path": os.path.relpath(file_path, self.target_path),
|
||||
"path": os.path.relpath(file_path, self.target_path).replace(
|
||||
os.sep, "/"
|
||||
),
|
||||
"modified": convert_unix_to_iso(os.stat(file_path).st_mtime),
|
||||
}
|
||||
except Exception:
|
||||
|
||||
@@ -130,6 +130,180 @@ class TestDumpsysADBArtifact:
|
||||
assert key_store_entry["fingerprint"] == expected_fingerprint
|
||||
assert key_store_entry["last_connected"] == "1628501829898"
|
||||
|
||||
def test_parsing_adb_xml_with_crlf_line_endings(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
file = get_artifact("android_data/dumpsys_adb_xml.txt")
|
||||
with open(file, "rb") as f:
|
||||
data = f.read().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
|
||||
|
||||
da_adb.parse(data)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["user_keys"][0]["user"] == "user@laptop"
|
||||
assert da_adb.results[0]["keystore"][0]["last_connected"] == "1628501829898"
|
||||
|
||||
def test_parsing_adb_wifi_with_mixed_line_endings(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
data = (
|
||||
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" adb_wifi={\n"
|
||||
b" enabled=false\n"
|
||||
b" }\n"
|
||||
b" }\r\n"
|
||||
b"}\n"
|
||||
b"--------- duration\n"
|
||||
)
|
||||
|
||||
da_adb.parse(data)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["user_keys"][0]["user"] == "host@example"
|
||||
assert da_adb.results[0]["adb_wifi"]["enabled"] == b"false"
|
||||
|
||||
ADB_STATE = (
|
||||
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"}\n"
|
||||
b"--------- 0.5s was the duration of 'dumpsys adb'\n"
|
||||
)
|
||||
|
||||
def test_a_later_dumpsys_section_does_not_extend_the_adb_state(self):
|
||||
# A bug report holds many sections. Looking for the last closing brace
|
||||
# in the whole output pulled a later section into this one, which threw
|
||||
# IndexError out of parse() and lost the ADB records entirely.
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
self.ADB_STATE + b"DUMP OF SERVICE other:\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=false\n"
|
||||
b" user_keys=RVZJTA== attacker@host\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert [key["user"] for key in da_adb.results[0]["user_keys"]] == [
|
||||
"host@example"
|
||||
]
|
||||
assert da_adb.results[0]["connected_to_adb"] is True
|
||||
|
||||
def test_braces_in_key_comment_are_not_state_delimiters(self):
|
||||
for user in (b"host{example", b"host}}example"):
|
||||
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== " + user + b"\n }\n}\n"
|
||||
)
|
||||
|
||||
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(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" }\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
# No exception, and whatever was read before the bad line is kept.
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["connected_to_adb"] is True
|
||||
|
||||
|
||||
class TestDumpsysADBStateAlerts:
|
||||
def test_no_androidqf_context_preserves_existing_behavior(self):
|
||||
|
||||
@@ -144,3 +144,18 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
"Detected uninstall of package com.example.app (vers 0)"
|
||||
)
|
||||
assert uninstall_alert.event_time == "2026-01-10"
|
||||
|
||||
def test_malformed_update_line_does_not_lose_later_records(self):
|
||||
# A truncated "Update" line, or one from a vendor that omits "vers=",
|
||||
# used to raise out of parse() and lose every record after it.
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
" Daily from 2021-05-10-08-00-00 to 2021-05-11-08-00-00:\n"
|
||||
" Update com.first vers=1\n"
|
||||
" Update com.truncated\n"
|
||||
" Update com.no.equals vers 2\n"
|
||||
" Update com.last vers=3\n"
|
||||
)
|
||||
|
||||
assert [r["package_name"] for r in dba.results] == ["com.first", "com.last"]
|
||||
assert [r["version_code"] for r in dba.results] == [1, 3]
|
||||
@@ -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()
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
import os
|
||||
import datetime
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -130,3 +130,83 @@ class TestTombstoneCrashArtifact:
|
||||
|
||||
# Tombstones include an explicit offset, so normalize them to UTC.
|
||||
assert tombstone_result.get("timestamp") == "2023-04-12 10:32:40.518290"
|
||||
|
||||
def test_tombstone_pb_empty_timestamp(self):
|
||||
"""Test parsing a protobuf tombstone with an empty timestamp."""
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
artifact_path = "android_data/bugreport_tombstones/tombstone_empty_timestamp.pb"
|
||||
file = get_artifact(artifact_path)
|
||||
with open(file, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
file_name = os.path.basename(artifact_path)
|
||||
file_timestamp = datetime.datetime(2024, 1, 15, 10, 30, 45, 123456)
|
||||
tombstone_artifact.parse_protobuf(file_name, file_timestamp, data)
|
||||
|
||||
assert len(tombstone_artifact.results) == 1
|
||||
result = tombstone_artifact.results[0]
|
||||
|
||||
# When tombstone has empty timestamp, should use file modification time
|
||||
assert result.get("timestamp") == "2024-01-15 10:30:45.123456"
|
||||
assert result.get("pid") == 12345
|
||||
assert result.get("uid") == 1000
|
||||
assert result.get("signal_info", {}).get("name") == "SIGSEGV"
|
||||
|
||||
def test_tombstone_pb_empty_timestamp_with_threads(self):
|
||||
"""Test parsing a protobuf tombstone with empty timestamp and thread info."""
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
artifact_path = "android_data/bugreport_tombstones/tombstone_empty_timestamp_with_threads.pb"
|
||||
file = get_artifact(artifact_path)
|
||||
with open(file, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
file_name = os.path.basename(artifact_path)
|
||||
file_timestamp = datetime.datetime(2024, 2, 20, 14, 15, 30, 0)
|
||||
tombstone_artifact.parse_protobuf(file_name, file_timestamp, data)
|
||||
|
||||
assert len(tombstone_artifact.results) == 1
|
||||
result = tombstone_artifact.results[0]
|
||||
|
||||
# Verify timestamp fallback
|
||||
assert result.get("timestamp") == "2024-02-20 14:15:30.000000"
|
||||
assert result.get("pid") == 9876
|
||||
assert result.get("uid") == 10001
|
||||
assert result.get("signal_info", {}).get("name") == "SIGABRT"
|
||||
assert result.get("process_name") == "ExampleThread"
|
||||
|
||||
def test_tombstone_pb_whitespace_timestamp(self):
|
||||
"""Test parsing a protobuf tombstone with whitespace-only timestamp."""
|
||||
tombstone_artifact = TombstoneCrashArtifact()
|
||||
artifact_path = (
|
||||
"android_data/bugreport_tombstones/tombstone_whitespace_timestamp.pb"
|
||||
)
|
||||
file = get_artifact(artifact_path)
|
||||
with open(file, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
file_name = os.path.basename(artifact_path)
|
||||
file_timestamp = datetime.datetime(2024, 3, 10, 8, 0, 0, 0)
|
||||
tombstone_artifact.parse_protobuf(file_name, file_timestamp, data)
|
||||
|
||||
assert len(tombstone_artifact.results) == 1
|
||||
result = tombstone_artifact.results[0]
|
||||
|
||||
# Verify whitespace timestamp is treated as empty
|
||||
assert result.get("timestamp") == "2024-03-10 08:00:00.000000"
|
||||
assert result.get("pid") == 11111
|
||||
assert result.get("uid") == 2000
|
||||
assert result.get("signal_info", {}).get("name") == "SIGILL"
|
||||
|
||||
def test_tombstone_pb_empty_file(self):
|
||||
"""Test that empty (0 bytes) tombstone files are handled gracefully."""
|
||||
artifact_path = "android_data/bugreport_tombstones/tombstone_empty_file.pb"
|
||||
file = get_artifact(artifact_path)
|
||||
with open(file, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
# Verify the file is actually empty
|
||||
assert len(data) == 0, "Test file should be empty (0 bytes)"
|
||||
|
||||
# Empty files should be skipped in the module (not parsed)
|
||||
# The actual skipping happens in the Tombstones module's run() method
|
||||
# This test verifies that empty data is detectable
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
5test/build/fingerprint:12/TEST/V1.0:user/release-keystest-rev-001(¹`0º`8èBu:r:system_app:s0J/system/bin/test_process <RSIGSEGV"SEGV_MAPERR
|
||||
+1
@@ -0,0 +1 @@
|
||||
5test/build/fingerprint:13/TEST/V2.0:user/release-keystest-rev-002(”M0•M8‘NBu:r:untrusted_app:s0J-/data/app/com.example.app/lib/arm64/libapp.so xRSIGABRT"SI_TKILL‚•M•M␍ExampleThread
|
||||
@@ -0,0 +1,2 @@
|
||||
5test/build/fingerprint:11/TEST/V3.0:user/release-keystest-rev-003" (çV0èV8ÐBu:r:shell:s0J/system/bin/app_process64 RSIGILL"
|
||||
ILL_ILLOPN
|
||||
@@ -1,3 +1,4 @@
|
||||
import shlex
|
||||
from types import SimpleNamespace
|
||||
|
||||
import click
|
||||
@@ -110,7 +111,7 @@ def test_load_command_option_supports_folders_and_repeated_paths(tmp_path):
|
||||
def test_loaded_command_participates_in_shell_completion(tmp_path):
|
||||
command_path = _write_command(tmp_path / "hello.py", "hello")
|
||||
group = _make_group()
|
||||
words = f"group --load-command {command_path} he"
|
||||
words = f"group --load-command {shlex.quote(str(command_path))} he"
|
||||
|
||||
result = CliRunner().invoke(
|
||||
group,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -54,20 +55,23 @@ def _run(command, arguments):
|
||||
return CliRunner().invoke(command, arguments, env={"COLUMNS": "200"})
|
||||
|
||||
|
||||
def _table_rows(output):
|
||||
"""Return the content of the table rows, without the header and the box."""
|
||||
def _table_lines(output):
|
||||
"""Return the cells of each line of the table, header first."""
|
||||
return [
|
||||
[cell.strip() for cell in line.strip().strip("│").split("│")]
|
||||
[cell.strip() for cell in re.split("[│┃]", line.strip().strip("│┃"))]
|
||||
for line in output.splitlines()
|
||||
if "│" in line
|
||||
if "│" in line or "┃" in line
|
||||
]
|
||||
|
||||
|
||||
def _table_rows(output):
|
||||
"""Return the content of the table rows, without the header and the box."""
|
||||
return _table_lines(output)[1:]
|
||||
|
||||
|
||||
def _table_header(output):
|
||||
for line in output.splitlines():
|
||||
if "┃" in line:
|
||||
return [cell.strip() for cell in line.strip().strip("┃").split("┃")]
|
||||
return []
|
||||
lines = _table_lines(output)
|
||||
return lines[0] if lines else []
|
||||
|
||||
|
||||
def _install(monkeypatch, distributions, entry_points):
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from mvt.ios.decrypt import DecryptBackup, MVTEncryptedBackup
|
||||
@@ -81,7 +82,10 @@ def test_extract_file_by_id_copies_unencrypted_files(mocker, tmp_path):
|
||||
assert output_path.read_bytes() == b"plain content"
|
||||
|
||||
|
||||
def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_path):
|
||||
@pytest.mark.parametrize("with_symlink", [False, True], ids=["file-id", "symlink"])
|
||||
def test_process_backup_rejects_unsafe_file_ids_and_destinations(
|
||||
mocker, tmp_path, with_symlink
|
||||
):
|
||||
backup_path = tmp_path / "backup"
|
||||
destination = tmp_path / "destination"
|
||||
outside = tmp_path / "outside"
|
||||
@@ -92,20 +96,27 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat
|
||||
safe_file_id = "ef" + "3" * 38
|
||||
unsafe_file_id = "../../outside-file"
|
||||
symlink_file_id = "ab" + "4" * 38
|
||||
for file_id in (safe_file_id, symlink_file_id):
|
||||
file_ids = [safe_file_id]
|
||||
if with_symlink:
|
||||
file_ids.append(symlink_file_id)
|
||||
for file_id in file_ids:
|
||||
source_path = backup_path / file_id[:2] / file_id
|
||||
source_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path.write_bytes(b"encrypted")
|
||||
(destination / "ab").symlink_to(outside, target_is_directory=True)
|
||||
if with_symlink:
|
||||
try:
|
||||
(destination / "ab").symlink_to(outside, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("creating symbolic links is not permitted on this system")
|
||||
|
||||
cursor = mocker.MagicMock()
|
||||
cursor.__iter__.return_value = iter(
|
||||
[
|
||||
(safe_file_id, "Domain", "safe", b"plist"),
|
||||
(unsafe_file_id, "Domain", "unsafe", b"plist"),
|
||||
(symlink_file_id, "Domain", "symlink", b"plist"),
|
||||
]
|
||||
)
|
||||
records = [
|
||||
(safe_file_id, "Domain", "safe", b"plist"),
|
||||
(unsafe_file_id, "Domain", "unsafe", b"plist"),
|
||||
]
|
||||
if with_symlink:
|
||||
records.append((symlink_file_id, "Domain", "symlink", b"plist"))
|
||||
cursor.__iter__.return_value = iter(records)
|
||||
cursor_context = mocker.MagicMock()
|
||||
cursor_context.__enter__.return_value = cursor
|
||||
|
||||
@@ -124,7 +135,8 @@ def test_process_backup_rejects_unsafe_file_ids_and_destinations(mocker, tmp_pat
|
||||
decryptor.process_backup()
|
||||
|
||||
assert (destination / safe_file_id[:2] / safe_file_id).read_bytes() == b"decrypted"
|
||||
assert not (outside / symlink_file_id).exists()
|
||||
if with_symlink:
|
||||
assert not (outside / symlink_file_id).exists()
|
||||
backup.extract_file_by_id.assert_called_once()
|
||||
assert backup.extract_file_by_id.call_args.kwargs["file_id"] == safe_file_id
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
import gc
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import run_module
|
||||
@@ -14,8 +19,58 @@ from mvt.ios.modules.backup.manifest import Manifest
|
||||
|
||||
from ..utils import get_ios_backup_folder
|
||||
|
||||
# fileID of HomeDomain::Library/SMS/sms.db in the test backup. It is one of the
|
||||
# few files the test backup actually stores.
|
||||
SMS_FILE_ID = "3d0d7e5fb2ce288813306e4d4636395e047a3d28"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backup_without_stored_files(tmp_path):
|
||||
"""A copy of the test backup with every stored file removed.
|
||||
|
||||
The test backup only ships a handful of the files its manifest lists, so
|
||||
dropping what it does store leaves a backup where every regular file the
|
||||
manifest mentions is missing from the folder.
|
||||
"""
|
||||
backup_path = tmp_path / "backup"
|
||||
shutil.copytree(get_ios_backup_folder(), backup_path)
|
||||
|
||||
for file_id_folder in backup_path.iterdir():
|
||||
if not file_id_folder.is_dir():
|
||||
continue
|
||||
for backup_file in file_id_folder.iterdir():
|
||||
backup_file.unlink()
|
||||
|
||||
return str(backup_path)
|
||||
|
||||
|
||||
class TestIOSExtraction:
|
||||
@pytest.mark.parametrize("link_directory", [False, True], ids=["file", "directory"])
|
||||
@pytest.mark.parametrize("inside_backup", [False, True], ids=["outside", "inside"])
|
||||
def test_stored_file_inventory_matches_symlink_resolution(
|
||||
self, tmp_path, link_directory, inside_backup
|
||||
):
|
||||
backup_path = tmp_path / "backup"
|
||||
backup_path.mkdir()
|
||||
destination = (backup_path if inside_backup else tmp_path) / "contents"
|
||||
destination.mkdir()
|
||||
(destination / SMS_FILE_ID).write_bytes(b"backup file")
|
||||
bucket = backup_path / SMS_FILE_ID[:2]
|
||||
try:
|
||||
if link_directory:
|
||||
bucket.symlink_to(destination, target_is_directory=True)
|
||||
else:
|
||||
bucket.mkdir()
|
||||
(bucket / SMS_FILE_ID).symlink_to(destination / SMS_FILE_ID)
|
||||
except OSError:
|
||||
pytest.skip("creating symbolic links is not permitted on this system")
|
||||
|
||||
m = IOSExtraction(target_path=str(backup_path))
|
||||
assert bool(m._get_backup_file_from_id(SMS_FILE_ID)) is inside_backup
|
||||
assert m._get_stored_backup_file_ids() == (
|
||||
{SMS_FILE_ID} if inside_backup else set()
|
||||
)
|
||||
|
||||
def test_get_backup_files_from_manifest_closes_connection(self):
|
||||
m = IOSExtraction(target_path=get_ios_backup_folder())
|
||||
|
||||
@@ -41,6 +96,103 @@ class TestManifestModule:
|
||||
assert len(m.timeline) == 5881
|
||||
assert len(m.alertstore.alerts) == 0
|
||||
|
||||
def test_manifest_flags_the_files_missing_from_an_incomplete_backup(self):
|
||||
m = Manifest(target_path=get_ios_backup_folder())
|
||||
run_module(m)
|
||||
|
||||
missing = [result for result in m.results if result.get("missing")]
|
||||
# The test backup only stores a handful of the files its manifest
|
||||
# lists, and every one of the rest is a regular file (flags 1).
|
||||
assert len(missing) == 1079
|
||||
assert all(result["flags"] == 1 for result in missing)
|
||||
|
||||
stored = [result for result in m.results if result["file_id"] == SMS_FILE_ID]
|
||||
assert len(stored) == 1
|
||||
assert "missing" not in stored[0]
|
||||
|
||||
def test_manifest_flags_every_stored_file_removed_from_the_backup_folder(
|
||||
self, backup_without_stored_files
|
||||
):
|
||||
m = Manifest(target_path=backup_without_stored_files)
|
||||
run_module(m)
|
||||
|
||||
missing = [result for result in m.results if result.get("missing")]
|
||||
assert len(missing) == 1089
|
||||
|
||||
def test_manifest_flags_a_file_removed_from_the_backup_folder(self, tmp_path):
|
||||
backup_path = tmp_path / "backup"
|
||||
shutil.copytree(get_ios_backup_folder(), backup_path)
|
||||
(backup_path / SMS_FILE_ID[:2] / SMS_FILE_ID).unlink()
|
||||
|
||||
m = Manifest(target_path=str(backup_path))
|
||||
run_module(m)
|
||||
|
||||
removed = [result for result in m.results if result["file_id"] == SMS_FILE_ID]
|
||||
assert len(removed) == 1
|
||||
assert removed[0]["missing"] is True
|
||||
|
||||
@pytest.mark.parametrize("failed_folder", ["", "3d"], ids=["root", "bucket"])
|
||||
def test_manifest_skips_missing_check_when_inventory_fails(
|
||||
self, monkeypatch, caplog, failed_folder
|
||||
):
|
||||
backup_path = Path(get_ios_backup_folder())
|
||||
failed_path = backup_path / failed_folder
|
||||
scandir = os.scandir
|
||||
|
||||
def fail_listing(path):
|
||||
if Path(path) == failed_path:
|
||||
raise PermissionError("cannot list backup folder")
|
||||
return scandir(path)
|
||||
|
||||
monkeypatch.setattr(os, "scandir", fail_listing)
|
||||
m = Manifest(target_path=str(backup_path))
|
||||
with caplog.at_level(logging.INFO):
|
||||
m.run()
|
||||
|
||||
assert len(m.results) == 3721
|
||||
assert m._get_backup_file_from_id(SMS_FILE_ID) is not None
|
||||
assert all("missing" not in result for result in m.results)
|
||||
assert "Skipping the missing-file check" in caplog.text
|
||||
assert "The backup might be incomplete" not in caplog.text
|
||||
|
||||
def test_manifest_ignores_unreadable_unrelated_folders(self, tmp_path, monkeypatch):
|
||||
backup_path = tmp_path / "backup"
|
||||
shutil.copytree(get_ios_backup_folder(), backup_path)
|
||||
unrelated = backup_path / "notes"
|
||||
unrelated.mkdir()
|
||||
scandir = os.scandir
|
||||
|
||||
def fail_listing(path):
|
||||
if Path(path) == unrelated:
|
||||
raise PermissionError("cannot list unrelated folder")
|
||||
return scandir(path)
|
||||
|
||||
monkeypatch.setattr(os, "scandir", fail_listing)
|
||||
m = Manifest(target_path=str(backup_path))
|
||||
m.run()
|
||||
|
||||
assert sum(bool(result.get("missing")) for result in m.results) == 1079
|
||||
stored = next(
|
||||
result for result in m.results if result["file_id"] == SMS_FILE_ID
|
||||
)
|
||||
assert "missing" not in stored
|
||||
|
||||
@pytest.mark.parametrize("wrong_folder", ["ab", "notes"])
|
||||
def test_manifest_flags_files_in_the_wrong_folder(self, tmp_path, wrong_folder):
|
||||
backup_path = tmp_path / "backup"
|
||||
shutil.copytree(get_ios_backup_folder(), backup_path)
|
||||
destination = backup_path / wrong_folder
|
||||
destination.mkdir(exist_ok=True)
|
||||
(backup_path / SMS_FILE_ID[:2] / SMS_FILE_ID).rename(destination / SMS_FILE_ID)
|
||||
|
||||
m = Manifest(target_path=str(backup_path))
|
||||
m.run()
|
||||
|
||||
assert m._get_backup_file_from_id(SMS_FILE_ID) is None
|
||||
moved = next(result for result in m.results if result["file_id"] == SMS_FILE_ID)
|
||||
assert moved["missing"] is True
|
||||
assert sum(bool(result.get("missing")) for result in m.results) == 1080
|
||||
|
||||
def test_detection(self, indicator_file):
|
||||
m = Manifest(target_path=get_ios_backup_folder())
|
||||
ind = Indicators(log=logging.getLogger())
|
||||
|
||||
@@ -9,6 +9,8 @@ import plistlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from mvt.ios.modules.base import IOSExtraction
|
||||
from mvt.ios.modules.fs.analytics import Analytics
|
||||
|
||||
@@ -45,6 +47,9 @@ def test_open_sqlite_reads_wal_without_modifying_evidence(tmp_path):
|
||||
assert not os.path.exists(str(evidence_path) + "-shm")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
shutil.which("sqlite3") is None, reason="the recovery needs the sqlite3 binary"
|
||||
)
|
||||
def test_recovery_preserves_source_database(tmp_path):
|
||||
database_path = tmp_path / "source.db"
|
||||
conn = sqlite3.connect(database_path)
|
||||
|
||||
@@ -3,15 +3,45 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
import logging
|
||||
import ntpath
|
||||
from types import SimpleNamespace
|
||||
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import run_module
|
||||
from mvt.ios.modules.fs import filesystem
|
||||
from mvt.ios.modules.fs.filesystem import Filesystem
|
||||
|
||||
from ..utils import get_ios_backup_folder
|
||||
|
||||
|
||||
class TestFilesystem:
|
||||
def test_windows_paths_are_normalized_for_indicators(
|
||||
self, monkeypatch, indicators_factory
|
||||
):
|
||||
m = Filesystem(target_path=r"C:\dump")
|
||||
m.indicators = indicators_factory(
|
||||
file_paths=["matched/directory"], processes=["matched"]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
filesystem,
|
||||
"os",
|
||||
SimpleNamespace(
|
||||
sep="\\",
|
||||
path=ntpath,
|
||||
walk=lambda _: [(r"C:\dump\matched", ["directory"], ["file.txt"])],
|
||||
stat=lambda _: SimpleNamespace(st_mtime=0),
|
||||
),
|
||||
)
|
||||
|
||||
m.run()
|
||||
m.check_indicators()
|
||||
|
||||
assert {result["path"] for result in m.results} == {
|
||||
"matched/directory",
|
||||
"matched/file.txt",
|
||||
}
|
||||
assert len(m.alertstore.alerts) == 3
|
||||
|
||||
def test_filesystem(self):
|
||||
m = Filesystem(target_path=get_ios_backup_folder())
|
||||
run_module(m)
|
||||
|
||||
@@ -85,7 +85,7 @@ class TestCheckAndroidqfCommand:
|
||||
def test_acquisition_context_falls_back_to_public_key_file(self, tmp_path):
|
||||
data_path = tmp_path / "androidqf"
|
||||
data_path.mkdir()
|
||||
(data_path / "adb_host_key.pub").write_text("QUJDRA== acquisition@host\n")
|
||||
(data_path / "adb_host_key.pub").write_bytes(b"QUJDRA== acquisition@host\n")
|
||||
command = CmdAndroidCheckAndroidQF(target_path=str(data_path))
|
||||
|
||||
command.init()
|
||||
|
||||
@@ -116,7 +116,7 @@ def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path):
|
||||
module = SysdiagnoseExtraction()
|
||||
command.module_init(module)
|
||||
assert module.tar is None
|
||||
assert module.parent_path == str(extracted_path.parent)
|
||||
assert Path(module.parent_path) == extracted_path.parent
|
||||
finally:
|
||||
command.finish()
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class TestCompletionCommand:
|
||||
|
||||
def test_completion_install_updates_bashrc_once(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(mvt_cli, ["completion", "bash", "--install"])
|
||||
@@ -67,6 +68,7 @@ class TestCompletionCommand:
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
runner = CliRunner()
|
||||
|
||||
result = runner.invoke(mvt_cli, ["completion", "fish", "--install"])
|
||||
|
||||
Reference in new issue
Block a user