mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-26 16:03:52 +01:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3c078bf5 | ||
|
|
3311dcde34 | ||
|
|
81d508b708 | ||
|
|
088a3f453a | ||
|
|
6a76191155 | ||
|
|
c779009550 | ||
|
|
801c464492 | ||
|
|
6d1d499c4e | ||
|
|
cc7781e255 | ||
|
|
c6837a455a | ||
|
|
b1f0a2de06 | ||
|
|
d259ab4810 | ||
|
|
d4b970c7c0 | ||
|
|
4b6a101cc7 | ||
|
|
5b1f4df7a4 | ||
|
|
301582d7dd | ||
|
|
af8c56675b | ||
|
|
2302e74a86 | ||
|
|
e9e621640b | ||
|
|
05ad7d274c | ||
|
|
70d646af78 | ||
|
|
2d547662f8 | ||
|
|
ca0bc46f11 | ||
|
|
1b03002a00 | ||
|
|
6bac787cb5 | ||
|
|
064b9fbeb9 | ||
|
|
4c1cdf5129 | ||
|
|
a08c24b02a | ||
|
|
5d696350dc |
No files matched your search
@@ -5,9 +5,7 @@
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "uv" # See documentation for possible values
|
||||
- package-ecosystem: "pip" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
@@ -7,19 +7,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: 3.9
|
||||
cache: 'pip'
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
uv sync --locked --group dev
|
||||
pip install mypy
|
||||
- name: mypy
|
||||
run: |
|
||||
make mypy
|
||||
@@ -4,8 +4,6 @@ name: Create and publish a Docker image
|
||||
# Configures this workflow to run every time a release is published.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -25,18 +23,9 @@ jobs:
|
||||
attestations: write
|
||||
id-token: write
|
||||
#
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- dockerfile: "Dockerfile"
|
||||
tag-suffix: ""
|
||||
- dockerfile: "Dockerfile.ios"
|
||||
tag-suffix: "-ios"
|
||||
- dockerfile: "Dockerfile.android"
|
||||
tag-suffix: "-android"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
@@ -47,33 +36,26 @@ jobs:
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{ is_default_branch }},suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=raw,enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }},value=stable,suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=raw,enable=${{ github.event_name == 'release' }},value=${{ github.event.release.tag_name }},suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=sha,suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
|
||||
# This step sets up some additional capabilities to generate the provenance and sbom attestations
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
file: ${{ matrix.platform.dockerfile }}
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
|
||||
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
@@ -11,19 +11,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: 3.9
|
||||
cache: 'pip'
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
uv sync --locked --group dev
|
||||
pip install ruff
|
||||
- name: ruff
|
||||
run: |
|
||||
make ruff
|
||||
@@ -15,18 +15,15 @@ jobs:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --locked --group dev --python ${{ matrix.python-version }}
|
||||
make install
|
||||
make test-requirements
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
set -o pipefail
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Run script to fetch latest iOS releases from Apple RSS feed.
|
||||
run: python3 .github/workflows/scripts/update-ios-releases.py
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
title: '[auto] Update iOS releases and versions'
|
||||
commit-message: Add new iOS versions and build numbers
|
||||
@@ -27,4 +27,4 @@ jobs:
|
||||
add-paths: |
|
||||
*.json
|
||||
labels: |
|
||||
automated pr
|
||||
automated pr
|
||||
+1
-4
@@ -86,9 +86,6 @@ ipython_config.py
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# uv project Python version
|
||||
!.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
@@ -141,4 +138,4 @@ dmypy.json
|
||||
.idea
|
||||
|
||||
# Sublime Text project files
|
||||
*.sublime*
|
||||
*.sublime*
|
||||
@@ -1 +0,0 @@
|
||||
3.10
|
||||
+4
-6
@@ -6,16 +6,14 @@
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: "ubuntu-24.04"
|
||||
os: "ubuntu-22.04"
|
||||
tools:
|
||||
python: "3.11"
|
||||
|
||||
mkdocs:
|
||||
configuration: mkdocs.yml
|
||||
|
||||
# Optionally set the version of Python and requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- method: uv
|
||||
command: sync
|
||||
groups:
|
||||
- docs
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
+14
-11
@@ -1,6 +1,6 @@
|
||||
# Base image for building libraries
|
||||
# ---------------------------------
|
||||
FROM ubuntu:22.04 AS build-base
|
||||
FROM ubuntu:22.04 as build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -22,7 +22,7 @@ RUN apt-get update \
|
||||
|
||||
# libplist
|
||||
# --------
|
||||
FROM build-base AS build-libplist
|
||||
FROM build-base as build-libplist
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
@@ -32,7 +32,7 @@ RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
|
||||
# libimobiledevice-glue
|
||||
# ---------------------
|
||||
FROM build-base AS build-libimobiledevice-glue
|
||||
FROM build-base as build-libimobiledevice-glue
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -45,7 +45,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd li
|
||||
|
||||
# libtatsu
|
||||
# --------
|
||||
FROM build-base AS build-libtatsu
|
||||
FROM build-base as build-libtatsu
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -58,7 +58,7 @@ RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
|
||||
|
||||
# libusbmuxd
|
||||
# ----------
|
||||
FROM build-base AS build-libusbmuxd
|
||||
FROM build-base as build-libusbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -72,7 +72,7 @@ RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
|
||||
|
||||
# libimobiledevice
|
||||
# ----------------
|
||||
FROM build-base AS build-libimobiledevice
|
||||
FROM build-base as build-libimobiledevice
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -88,7 +88,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimob
|
||||
|
||||
# usbmuxd
|
||||
# -------
|
||||
FROM build-base AS build-usbmuxd
|
||||
FROM build-base as build-usbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -103,8 +103,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
|
||||
|
||||
|
||||
# Create main image
|
||||
FROM ubuntu:24.04 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
FROM ubuntu:24.04 as main
|
||||
|
||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
||||
@@ -134,8 +133,12 @@ COPY --from=build-usbmuxd /build /
|
||||
|
||||
# Install mvt using the locally checked out source
|
||||
COPY . mvt/
|
||||
RUN uv pip install --system --break-system-packages --no-cache ./mvt \
|
||||
&& rm -rf mvt
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y git python3-pip \
|
||||
&& PIP_NO_CACHE_DIR=1 pip3 install --break-system-packages ./mvt \
|
||||
&& apt-get remove -y python3-pip git && apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf mvt
|
||||
|
||||
# Installing ABE
|
||||
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
# Create main image
|
||||
FROM python:3.10.14-alpine3.20 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
FROM python:3.10.14-alpine3.20 as main
|
||||
|
||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
||||
@@ -21,7 +20,7 @@ RUN apk add --no-cache \
|
||||
# Install mvt
|
||||
COPY ./ mvt
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev \
|
||||
&& uv pip install --system --no-cache ./mvt \
|
||||
&& PIP_NO_CACHE_DIR=1 pip3 install ./mvt \
|
||||
&& apk del .build-deps gcc musl-dev && rm -rf ./mvt
|
||||
|
||||
# Installing ABE
|
||||
|
||||
+9
-10
@@ -1,6 +1,6 @@
|
||||
# Base image for building libraries
|
||||
# ---------------------------------
|
||||
FROM ubuntu:22.04 AS build-base
|
||||
FROM ubuntu:22.04 as build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -22,7 +22,7 @@ RUN apt-get update \
|
||||
|
||||
# libplist
|
||||
# --------
|
||||
FROM build-base AS build-libplist
|
||||
FROM build-base as build-libplist
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
@@ -32,7 +32,7 @@ RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
|
||||
# libimobiledevice-glue
|
||||
# ---------------------
|
||||
FROM build-base AS build-libimobiledevice-glue
|
||||
FROM build-base as build-libimobiledevice-glue
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -45,7 +45,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd li
|
||||
|
||||
# libtatsu
|
||||
# --------
|
||||
FROM build-base AS build-libtatsu
|
||||
FROM build-base as build-libtatsu
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -58,7 +58,7 @@ RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
|
||||
|
||||
# libusbmuxd
|
||||
# ----------
|
||||
FROM build-base AS build-libusbmuxd
|
||||
FROM build-base as build-libusbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -72,7 +72,7 @@ RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
|
||||
|
||||
# libimobiledevice
|
||||
# ----------------
|
||||
FROM build-base AS build-libimobiledevice
|
||||
FROM build-base as build-libimobiledevice
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -88,7 +88,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimob
|
||||
|
||||
# usbmuxd
|
||||
# -------
|
||||
FROM build-base AS build-usbmuxd
|
||||
FROM build-base as build-usbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -104,8 +104,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
|
||||
|
||||
# Main image
|
||||
# ----------
|
||||
FROM python:3.10.14-alpine3.20 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
FROM python:3.10.14-alpine3.20 as main
|
||||
|
||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
||||
@@ -132,7 +131,7 @@ COPY --from=build-usbmuxd /build /
|
||||
# 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 \
|
||||
&& PIP_NO_CACHE_DIR=1 pip3 install ./mvt \
|
||||
&& apk del .build-deps git gcc musl-dev && rm -rf ./mvt
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
|
||||
@@ -1,39 +1,39 @@
|
||||
PWD = $(shell pwd)
|
||||
UV ?= uv
|
||||
|
||||
check: ruff mypy
|
||||
|
||||
ruff:
|
||||
$(UV) run ruff check .
|
||||
ruff check .
|
||||
|
||||
mypy:
|
||||
$(UV) run mypy
|
||||
mypy
|
||||
|
||||
test:
|
||||
$(UV) run pytest
|
||||
python3 -m pytest
|
||||
|
||||
test-ci:
|
||||
$(UV) run pytest -v
|
||||
python3 -m pytest -v
|
||||
|
||||
install:
|
||||
$(UV) sync
|
||||
python3 -m pip install --upgrade -e .
|
||||
|
||||
test-requirements:
|
||||
$(UV) sync --group dev
|
||||
python3 -m pip install --upgrade --group dev
|
||||
|
||||
generate-proto-parsers:
|
||||
# Generate python parsers for protobuf files
|
||||
PROTO_FILES=$$(find src/mvt/android/parsers/proto/ -iname "*.proto"); \
|
||||
$(UV) run protoc -Isrc/mvt/android/parsers/proto/ --python_betterproto2_out=src/mvt/android/parsers/proto/ $$PROTO_FILES
|
||||
protoc -Isrc/mvt/android/parsers/proto/ --python_betterproto_out=src/mvt/android/parsers/proto/ $$PROTO_FILES
|
||||
|
||||
clean:
|
||||
rm -rf $(PWD)/build $(PWD)/dist $(PWD)/src/mvt.egg-info
|
||||
|
||||
dist:
|
||||
$(UV) build
|
||||
python3 -m pip install --upgrade build
|
||||
python3 -m build
|
||||
|
||||
upload:
|
||||
$(UV) tool run twine upload dist/*
|
||||
python3 -m twine upload dist/*
|
||||
|
||||
test-upload:
|
||||
$(UV) tool run twine upload --repository testpypi dist/*
|
||||
python3 -m twine upload --repository testpypi dist/*
|
||||
@@ -4,9 +4,6 @@
|
||||
|
||||
# Mobile Verification Toolkit
|
||||
|
||||
> [!IMPORTANT]
|
||||
> We recently merged the "v3" branch. This introduced breaking changes. If you relied on mvt output in other scripts They might have broken. More details: https://github.com/mvt-project/mvt/issues/757
|
||||
|
||||
[](https://pypi.org/project/mvt/)
|
||||
[](https://docs.mvt.re/en/latest/?badge=latest)
|
||||
[](https://github.com/mvt-project/mvt/actions/workflows/tests.yml)
|
||||
@@ -41,18 +38,6 @@ MVT can be installed from sources or from [PyPI](https://pypi.org/project/mvt/)
|
||||
pip3 install mvt
|
||||
```
|
||||
|
||||
You can also install MVT from PyPI with [uv](https://docs.astral.sh/uv/). First, install uv:
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
Then install MVT as a command-line tool:
|
||||
|
||||
```bash
|
||||
uv tool install mvt
|
||||
```
|
||||
|
||||
For alternative installation options and known issues, please refer to the [documentation](https://docs.mvt.re/en/latest/install/) as well as [GitHub Issues](https://github.com/mvt-project/mvt/issues).
|
||||
|
||||
|
||||
|
||||
+36
-15
@@ -1,29 +1,50 @@
|
||||
# ADB Analysis Removed
|
||||
# Check over ADB
|
||||
|
||||
The ability to analyze Android devices directly over the [Android Debug Bridge (adb)](https://developer.android.com/studio/command-line/adb) has been removed from MVT.
|
||||
In order to check an Android device over the [Android Debug Bridge (adb)](https://developer.android.com/studio/command-line/adb) you will first need to install [Android SDK Platform Tools](https://developer.android.com/studio/releases/platform-tools). If you have installed [Android Studio](https://developer.android.com/studio/) you should already have access to `adb` and other utilities.
|
||||
|
||||
Use [AndroidQF](https://github.com/mvt-project/androidqf) to collect forensic artifacts from an Android device, then analyze the collected output with:
|
||||
While many Linux distributions already package Android Platform Tools (for example `android-platform-tools-base` on Debian), it is preferable to install the most recent version from the official website. Packaged versions might be outdated and incompatible with most recent Android handsets.
|
||||
|
||||
Next you will need to enable debugging on the Android device you are testing. [Please follow the official instructions on how to do so.](https://developer.android.com/studio/command-line/adb)
|
||||
|
||||
## Connecting over USB
|
||||
|
||||
The easiest way to check the device is over a USB transport. You will need to have USB debugging enabled and the device plugged into your computer. If everything is configured appropriately you should see your device when launching the command `adb devices`.
|
||||
|
||||
Now you can try launching MVT with:
|
||||
|
||||
```bash
|
||||
mvt-android check-androidqf /path/to/androidqf-output
|
||||
mvt-android check-adb --output /path/to/results
|
||||
```
|
||||
|
||||
For a standalone Android bug report, use:
|
||||
!!! warning
|
||||
The `check-adb` command is deprecated and will be removed in a future release.
|
||||
Whenever possible, prefer acquiring device data using the AndroidQF project (https://github.com/mvt-project/androidqf/) and then analyze those acquisitions with MVT.
|
||||
|
||||
```bash
|
||||
mvt-android check-bugreport /path/to/bugreport.zip
|
||||
```
|
||||
Running `mvt-android check-adb` will also emit a runtime deprecation warning advising you to migrate to AndroidQF.
|
||||
|
||||
## Reasons for Removal
|
||||
If you have previously started an adb daemon MVT will alert you and require you to kill it with `adb kill-server` and relaunch the command.
|
||||
|
||||
!!! warning
|
||||
|
||||
The `mvt-android check-adb` command has been deprecated and removed from MVT.
|
||||
|
||||
The ability to analyze Android devices over ADB (`mvt-android check-adb`) has been removed from MVT due to several technical and forensic limitations.
|
||||
|
||||
## Reasons for Deprecation
|
||||
|
||||
1. **Inconsistent Data Collection Across Devices**
|
||||
Android devices vary significantly in system architecture, security policy, and diagnostic log availability. This made it difficult to collect reliable forensic data across devices.
|
||||
Android devices vary significantly in their system architecture, security policies, and available diagnostic logs. This inconsistency makes it difficult to ensure that MVT can reliably collect necessary forensic data across all devices.
|
||||
|
||||
2. **Incomplete Forensic Data Acquisition**
|
||||
Direct ADB analysis did not retrieve a complete forensic snapshot. Critical artifacts such as full bug reports could be missing.
|
||||
The `check-adb` command did not retrieve a full forensic snapshot of all available data on the device. For example, critical logs such as the **full bugreport** were not systematically collected, leading to potential gaps in forensic analysis. This can be a serious problem in scenarios where the analyst only had one time access to the Android device.
|
||||
|
||||
3. **Duplicated Analysis Paths**
|
||||
Similar artifacts were parsed through separate ADB, AndroidQF, and bugreport modules, which made behavior harder to keep consistent.
|
||||
4. **Code Duplication and Difficulty Ensuring Consistent Behavior Across Sources**
|
||||
Similar forensic data such as "dumpsys" logs were being loaded and parsed by MVT's ADB, AndroidQF and Bugreport commands. Multiple modules were needed to handle each source format which created duplication leading to inconsistent
|
||||
behavior and difficulties in maintaining the code base.
|
||||
|
||||
4. **Workflow Consistency**
|
||||
MVT now focuses on analyzing acquired artifacts rather than interacting with live devices directly.
|
||||
5. **Alignment with iOS Workflow**
|
||||
MVT’s forensic workflow for iOS relies on pre-extracted artifacts, such as iTunes backups or filesystem dumps, rather than preforming commands or interactions directly on a live device. Removing the ADB functionality ensures a more consistent methodology across both Android and iOS mobile forensic.
|
||||
|
||||
## Alternative: Using AndroidQF for Forensic Data Collection
|
||||
|
||||
To replace the deprecated ADB-based approach, forensic analysts should use [AndroidQF](https://github.com/mvt-project/androidqf) for comprehensive data collection, followed by MVT for forensic analysis. The workflow is outlined in the MVT [Android methodology](./methodology.md)
|
||||
@@ -0,0 +1,28 @@
|
||||
# Downloading APKs from an Android phone
|
||||
|
||||
MVT allows you to attempt to download all available installed packages (APKs) from a device in order to further inspect them and potentially identify any which might be malicious in nature.
|
||||
|
||||
You can do so by launching the following command:
|
||||
|
||||
```bash
|
||||
mvt-android download-apks --output /path/to/folder
|
||||
```
|
||||
|
||||
It might take several minutes to complete.
|
||||
|
||||
!!! info
|
||||
MVT will likely warn you it was unable to download certain installed packages. There is no reason to be alarmed: this is typically expected behavior when MVT attempts to download a system package it has no privileges to access.
|
||||
|
||||
Optionally, you can decide to enable lookups of the SHA256 hash of all the extracted APKs on [VirusTotal](https://www.virustotal.com). While these lookups do not provide any conclusive assessment on all of the extracted APKs, they might highlight any known malicious ones:
|
||||
|
||||
```bash
|
||||
MVT_VT_API_KEY=<key> mvt-android download-apks --output /path/to/folder --virustotal
|
||||
```
|
||||
|
||||
Please note that in order to use VirusTotal lookups you are required to provide your own API key through the `MVT_VT_API_KEY` environment variable. You should also note that VirusTotal enforces strict API usage. Be mindful that MVT might consume your hourly search quota.
|
||||
|
||||
In case you have a previous extraction of APKs you want to later check against VirusTotal, you can do so with the following arguments:
|
||||
|
||||
```bash
|
||||
MVT_VT_API_KEY=<key> mvt-android download-apks --from-file /path/to/folder/apks.json --virustotal
|
||||
```
|
||||
@@ -38,9 +38,11 @@ By separating artifact collection from forensic analysis, this approach ensures
|
||||
|
||||
For more information, refer to the [AndroidQF project documentation](https://github.com/mvt-project/androidqf).
|
||||
|
||||
## Android Debug Bridge analysis removed
|
||||
## Check the device over Android Debug Bridge
|
||||
|
||||
The ability to analyze Android devices directly over ADB has been removed from MVT. Use AndroidQF for device acquisition and `mvt-android check-androidqf` for analysis.
|
||||
The ability to analyze Android devices over ADB (`mvt-android check-adb`) has been removed from MVT.
|
||||
|
||||
See the [Android ADB documentation](./adb.md) for more information.
|
||||
|
||||
## Check an Android Backup (SMS messages)
|
||||
|
||||
|
||||
+2
-10
@@ -2,17 +2,9 @@
|
||||
|
||||
The Mobile Verification Toolkit team welcomes contributions of new forensic modules or other contributions which help improve the software.
|
||||
|
||||
## Local environment
|
||||
|
||||
MVT uses `uv` for dependency management. To install the project and development dependencies from the locked environment, run:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `ruff` and `mypy`. All can
|
||||
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `flake8`, `ruff` and `black`. All can
|
||||
be run automatically with:
|
||||
|
||||
```bash
|
||||
@@ -32,4 +24,4 @@ MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_
|
||||
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
||||
```
|
||||
|
||||
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
|
||||
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
|
||||
@@ -0,0 +1,5 @@
|
||||
mkdocs==1.6.1
|
||||
mkdocs-autorefs==1.4.3
|
||||
mkdocs-material==9.6.20
|
||||
mkdocs-material-extensions==1.3.1
|
||||
mkdocstrings==1.0.0
|
||||
@@ -42,7 +42,9 @@ nav:
|
||||
- Records extracted by mvt-ios: "ios/records.md"
|
||||
- MVT for Android:
|
||||
- Android Forensic Methodology: "android/methodology.md"
|
||||
- Check over ADB: "android/adb.md"
|
||||
- Check an Android Backup (SMS messages): "android/backup.md"
|
||||
- Download APKs: "android/download_apks.md"
|
||||
- Indicators of Compromise: "iocs.md"
|
||||
- Development: "development.md"
|
||||
- License: "license.md"
|
||||
+12
-21
@@ -17,25 +17,25 @@ classifiers = [
|
||||
"Programming Language :: Python",
|
||||
]
|
||||
dependencies = [
|
||||
"click==8.3.2",
|
||||
"rich==14.3.3",
|
||||
"click==8.3.1",
|
||||
"rich==14.1.0",
|
||||
"tld==0.13.1",
|
||||
"requests==2.33.1",
|
||||
"requests==2.33.0",
|
||||
"simplejson==3.20.2",
|
||||
"packaging==26.0",
|
||||
"packaging==25.0",
|
||||
"appdirs==1.4.4",
|
||||
"iOSbackup==0.9.925",
|
||||
"adb-shell[usb]==0.4.4",
|
||||
"libusb1==3.3.1",
|
||||
"cryptography==47.0.0",
|
||||
"cryptography==46.0.6",
|
||||
"PyYAML>=6.0.2",
|
||||
"pyahocorasick==2.2.0",
|
||||
"betterproto2==0.9.1",
|
||||
"pydantic==2.13.3",
|
||||
"pydantic-settings==2.13.1",
|
||||
"betterproto==1.2.5",
|
||||
"pydantic==2.12.5",
|
||||
"pydantic-settings==2.10.1",
|
||||
"NSKeyedUnArchiver==1.5.2",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"tzdata==2026.1",
|
||||
"tzdata==2025.2",
|
||||
]
|
||||
requires-python = ">= 3.10"
|
||||
|
||||
@@ -57,17 +57,7 @@ dev = [
|
||||
"stix2>=3.0.1",
|
||||
"ruff>=0.1.6",
|
||||
"mypy>=1.7.1",
|
||||
"betterproto2-compiler",
|
||||
"types-PyYAML>=6.0.12.20250516",
|
||||
"types-python-dateutil>=2.9.0.20250822",
|
||||
"types-requests>=2.32.4.20250913",
|
||||
]
|
||||
docs = [
|
||||
"mkdocs==1.6.1",
|
||||
"mkdocs-autorefs==1.4.4",
|
||||
"mkdocs-material==9.7.6",
|
||||
"mkdocs-material-extensions==1.3.1",
|
||||
"mkdocstrings==1.0.4",
|
||||
"betterproto[compiler]",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
@@ -81,7 +71,8 @@ omit = ["tests/*"]
|
||||
directory = "htmlcov"
|
||||
|
||||
[tool.mypy]
|
||||
install_types = false
|
||||
install_types = true
|
||||
non_interactive = true
|
||||
ignore_missing_imports = true
|
||||
packages = "src"
|
||||
|
||||
|
||||
@@ -91,10 +91,10 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
|
||||
# Check for uninstall (version 0)
|
||||
if vers_nr == "0":
|
||||
self.alertstore.medium(
|
||||
f"Detected uninstall of package {package_name} (vers 0)",
|
||||
self.log.warning(
|
||||
"Detected uninstall of package %s (vers 0) on %s",
|
||||
package_name,
|
||||
daily["from"],
|
||||
update_record,
|
||||
)
|
||||
# Check for downgrade
|
||||
elif package_name in package_versions:
|
||||
@@ -104,11 +104,12 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
if current_vers < previous_vers:
|
||||
update_record["action"] = "downgrade"
|
||||
update_record["previous_vers"] = str(previous_vers)
|
||||
self.alertstore.medium(
|
||||
f"Detected downgrade of package {package_name} "
|
||||
f"from vers {previous_vers} to vers {current_vers}",
|
||||
self.log.warning(
|
||||
"Detected downgrade of package %s from vers %d to vers %d on %s",
|
||||
package_name,
|
||||
previous_vers,
|
||||
current_vers,
|
||||
daily["from"],
|
||||
update_record,
|
||||
)
|
||||
except ValueError:
|
||||
# If version numbers aren't integers, skip comparison
|
||||
|
||||
@@ -22,6 +22,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
if not self.indicators:
|
||||
@@ -32,6 +33,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
|
||||
@@ -154,7 +154,7 @@ class Mounts(AndroidArtifact):
|
||||
):
|
||||
continue
|
||||
suspicious_mounts.append(mount)
|
||||
self.alertstore.medium(
|
||||
self.alertstore.high(
|
||||
f"Suspicious mount options found for {mount_point}: {', '.join(suspicious_opts)}",
|
||||
"",
|
||||
mount,
|
||||
@@ -180,18 +180,18 @@ class Mounts(AndroidArtifact):
|
||||
ioc = self.indicators.check_file_path(mount.get("mount_point", ""))
|
||||
if ioc:
|
||||
self.alertstore.critical(
|
||||
ioc.message,
|
||||
f"Mount point matches indicator: {mount.get('mount_point', '')}",
|
||||
"",
|
||||
mount,
|
||||
matched_indicator=ioc.ioc,
|
||||
matched_indicator=ioc,
|
||||
)
|
||||
|
||||
# Check device paths for indicators
|
||||
ioc = self.indicators.check_file_path(mount.get("device", ""))
|
||||
if ioc:
|
||||
self.alertstore.critical(
|
||||
ioc.message,
|
||||
f"Device path matches indicator: {mount.get('device', '')}",
|
||||
"",
|
||||
mount,
|
||||
matched_indicator=ioc.ioc,
|
||||
matched_indicator=ioc,
|
||||
)
|
||||
@@ -6,8 +6,8 @@
|
||||
import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import betterproto
|
||||
import pydantic
|
||||
import betterproto2
|
||||
from dateutil import parser
|
||||
|
||||
from mvt.android.parsers.proto.tombstone import Tombstone
|
||||
@@ -131,7 +131,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
"""Parse Android tombstone crash files from a protobuf object."""
|
||||
tombstone_pb = Tombstone().parse(data)
|
||||
tombstone_dict = tombstone_pb.to_dict(
|
||||
casing=betterproto2.Casing.SNAKE, include_default_values=True
|
||||
betterproto.Casing.SNAKE, include_default_values=True
|
||||
)
|
||||
|
||||
# Add some extra metadata
|
||||
|
||||
@@ -96,7 +96,6 @@ def version():
|
||||
@click.pass_context
|
||||
def check_adb(ctx):
|
||||
log.error(HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION)
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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/
|
||||
|
||||
from .chrome_history import ChromeHistory
|
||||
from .dumpsys_full import DumpsysFull
|
||||
from .files import Files
|
||||
from .getprop import Getprop
|
||||
from .logcat import Logcat
|
||||
from .packages import Packages
|
||||
from .processes import Processes
|
||||
from .root_binaries import RootBinaries
|
||||
from .selinux_status import SELinuxStatus
|
||||
from .settings import Settings
|
||||
from .sms import SMS
|
||||
from .whatsapp import Whatsapp
|
||||
|
||||
ADB_MODULES = [
|
||||
ChromeHistory,
|
||||
SMS,
|
||||
Whatsapp,
|
||||
Processes,
|
||||
Getprop,
|
||||
Settings,
|
||||
SELinuxStatus,
|
||||
DumpsysFull,
|
||||
Packages,
|
||||
Logcat,
|
||||
RootBinaries,
|
||||
Files,
|
||||
]
|
||||
@@ -0,0 +1,117 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import convert_chrometime_to_datetime, convert_datetime_to_iso
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
CHROME_HISTORY_PATH = "data/data/com.android.chrome/app_chrome/Default/History"
|
||||
|
||||
|
||||
class ChromeHistory(AndroidExtraction):
|
||||
"""This module extracts records from Android's Chrome browsing history."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self.results: list = []
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
return {
|
||||
"timestamp": record["isodate"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "visit",
|
||||
"data": f"{record['id']} - {record['url']} (visit ID: {record['visit_id']}, "
|
||||
f"redirect source: {record['redirect_source']})",
|
||||
}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_url(result["url"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
def _parse_db(self, db_path: str) -> None:
|
||||
"""Parse a Chrome History database file.
|
||||
|
||||
:param db_path: Path to the History database to process.
|
||||
|
||||
"""
|
||||
assert isinstance(self.results, list) # assert results type for mypy
|
||||
conn = sqlite3.connect(db_path)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
urls.id,
|
||||
urls.url,
|
||||
visits.id,
|
||||
visits.visit_time,
|
||||
visits.from_visit
|
||||
FROM urls
|
||||
JOIN visits ON visits.url = urls.id
|
||||
ORDER BY visits.visit_time;
|
||||
"""
|
||||
)
|
||||
|
||||
for item in cur:
|
||||
self.results.append(
|
||||
{
|
||||
"id": item[0],
|
||||
"url": item[1],
|
||||
"visit_id": item[2],
|
||||
"timestamp": item[3],
|
||||
"isodate": convert_datetime_to_iso(
|
||||
convert_chrometime_to_datetime(item[3])
|
||||
),
|
||||
"redirect_source": item[4],
|
||||
}
|
||||
)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
self.log.info("Extracted a total of %d history items", len(self.results))
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
try:
|
||||
self._adb_process_file(
|
||||
os.path.join("/", CHROME_HISTORY_PATH), self._parse_db
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.error(exc)
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,46 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .base import AndroidExtraction
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class DumpsysFull(AndroidExtraction):
|
||||
"""This module extracts stats on battery consumption by processes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
output = self._adb_command("dumpsys")
|
||||
if self.results_path:
|
||||
output_path = os.path.join(self.results_path, "dumpsys.txt")
|
||||
with open(output_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(output)
|
||||
|
||||
self.log.info("Full dumpsys output stored at %s", output_path)
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,160 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
import os
|
||||
import stat
|
||||
from typing import Optional, Union
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.utils import convert_unix_to_iso
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
ANDROID_TMP_FOLDERS = [
|
||||
"/tmp/",
|
||||
"/data/local/tmp/",
|
||||
]
|
||||
ANDROID_MEDIA_FOLDERS = [
|
||||
"/data/media/0",
|
||||
"/sdcard/",
|
||||
]
|
||||
|
||||
|
||||
class Files(AndroidExtraction):
|
||||
"""This module extracts the list of files on the device."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self.full_find = False
|
||||
|
||||
def serialize(self, record: dict) -> Union[dict, list, None]:
|
||||
if "modified_time" in record:
|
||||
return {
|
||||
"timestamp": record["modified_time"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "file_modified",
|
||||
"data": record["path"],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for result in self.results:
|
||||
if result.get("is_suid"):
|
||||
self.log.warning(
|
||||
'Found an SUID file in a non-standard directory "%s".',
|
||||
result["path"],
|
||||
)
|
||||
|
||||
if self.indicators:
|
||||
ioc_match = self.indicators.check_file_path(result["path"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
f'Found a known suspicious file at path: "{result["path"]}"',
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match,
|
||||
)
|
||||
|
||||
def backup_file(self, file_path: str) -> None:
|
||||
if not self.results_path:
|
||||
return
|
||||
|
||||
local_file_name = file_path.replace("/", "_").replace(" ", "-")
|
||||
local_files_folder = os.path.join(self.results_path, "files")
|
||||
if not os.path.exists(local_files_folder):
|
||||
os.mkdir(local_files_folder)
|
||||
|
||||
local_file_path = os.path.join(local_files_folder, local_file_name)
|
||||
|
||||
try:
|
||||
self._adb_download(remote_path=file_path, local_path=local_file_path)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.log.info(
|
||||
"Downloaded file %s to local copy at %s", file_path, local_file_path
|
||||
)
|
||||
|
||||
def find_files(self, folder: str) -> None:
|
||||
assert isinstance(self.results, list)
|
||||
if self.full_find:
|
||||
cmd = f"find '{folder}' -type f -printf '%T@ %m %s %u %g %p\n' 2> /dev/null"
|
||||
output = self._adb_command(cmd)
|
||||
|
||||
for file_line in output.splitlines():
|
||||
file_info = file_line.rstrip().split(" ", 5)
|
||||
if len(file_line) < 6:
|
||||
self.log.info("Skipping invalid file info - %s", file_line.rstrip())
|
||||
continue
|
||||
[unix_timestamp, mode, size, owner, group, full_path] = file_info
|
||||
mod_time = convert_unix_to_iso(unix_timestamp)
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"path": full_path,
|
||||
"modified_time": mod_time,
|
||||
"mode": mode,
|
||||
"is_suid": (int(mode, 8) & stat.S_ISUID) == 2048,
|
||||
"is_sgid": (int(mode, 8) & stat.S_ISGID) == 1024,
|
||||
"size": size,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
}
|
||||
)
|
||||
else:
|
||||
output = self._adb_command(f"find '{folder}' -type f 2> /dev/null")
|
||||
for file_line in output.splitlines():
|
||||
self.results.append({"path": file_line.rstrip()})
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
cmd = "find '/' -maxdepth 1 -printf '%T@ %m %s %u %g %p\n' 2> /dev/null"
|
||||
output = self._adb_command(cmd)
|
||||
if output or output.strip().splitlines():
|
||||
self.full_find = True
|
||||
|
||||
for tmp_folder in ANDROID_TMP_FOLDERS:
|
||||
self.find_files(tmp_folder)
|
||||
|
||||
for entry in self.results:
|
||||
self.log.info("Found file in tmp folder at path %s", entry.get("path"))
|
||||
self.backup_file(entry.get("path"))
|
||||
|
||||
for media_folder in ANDROID_MEDIA_FOLDERS:
|
||||
self.find_files(media_folder)
|
||||
|
||||
self.log.info(
|
||||
"Found %s files in primary Android tmp and media folders", len(self.results)
|
||||
)
|
||||
|
||||
if self.module_options.get("fast_mode", None):
|
||||
self.log.info(
|
||||
"The `fast_mode` option was enabled: skipping full file listing"
|
||||
)
|
||||
else:
|
||||
self.log.info("Processing full file listing. This may take a while...")
|
||||
self.find_files("/")
|
||||
|
||||
self.log.info("Found %s total files", len(self.results))
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,44 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.getprop import GetProp as GetPropArtifact
|
||||
|
||||
from .base import AndroidExtraction
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class Getprop(GetPropArtifact, AndroidExtraction):
|
||||
"""This module extracts device properties from getprop command."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.results = {} if not results else results
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
output = self._adb_command("getprop")
|
||||
self._adb_disconnect()
|
||||
|
||||
self.parse(output)
|
||||
self.log.info("Extracted %d Android system properties", len(self.results))
|
||||
@@ -0,0 +1,58 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .base import AndroidExtraction
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class Logcat(AndroidExtraction):
|
||||
"""This module extracts details on installed packages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
# Get the current logcat.
|
||||
output = self._adb_command('logcat -d -b all "*:V"')
|
||||
# Get the locat prior to last reboot.
|
||||
last_output = self._adb_command('logcat -L -b all "*:V"')
|
||||
|
||||
if self.results_path:
|
||||
logcat_path = os.path.join(self.results_path, "logcat.txt")
|
||||
with open(logcat_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(output)
|
||||
|
||||
self.log.info("Current logcat logs stored at %s", logcat_path)
|
||||
|
||||
logcat_last_path = os.path.join(self.results_path, "logcat_last.txt")
|
||||
with open(logcat_last_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(last_output)
|
||||
|
||||
self.log.info(
|
||||
"Logcat logs prior to last reboot stored at %s", logcat_last_path
|
||||
)
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,311 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact
|
||||
from mvt.android.utils import (
|
||||
DANGEROUS_PERMISSIONS,
|
||||
DANGEROUS_PERMISSIONS_THRESHOLD,
|
||||
ROOT_PACKAGES,
|
||||
SECURITY_PACKAGES,
|
||||
SYSTEM_UPDATE_PACKAGES,
|
||||
)
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
|
||||
class Packages(AndroidExtraction):
|
||||
"""This module extracts the list of installed packages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self._user_needed = False
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
|
||||
timestamps = [
|
||||
{"event": "package_install", "timestamp": record["timestamp"]},
|
||||
{
|
||||
"event": "package_first_install",
|
||||
"timestamp": record["first_install_time"],
|
||||
},
|
||||
{"event": "package_last_update", "timestamp": record["last_update_time"]},
|
||||
]
|
||||
|
||||
for timestamp in timestamps:
|
||||
records.append(
|
||||
{
|
||||
"timestamp": timestamp["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": timestamp["event"],
|
||||
"data": f"{record['package_name']} (system: {record['system']},"
|
||||
f" third party: {record['third_party']})",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for result in self.results:
|
||||
if result["package_name"] in ROOT_PACKAGES:
|
||||
self.log.warning(
|
||||
'Found an installed package related to rooting/jailbreaking: "%s"',
|
||||
result["package_name"],
|
||||
)
|
||||
self.detected.append(result)
|
||||
continue
|
||||
|
||||
if result["package_name"] in SECURITY_PACKAGES and result["disabled"]:
|
||||
self.log.warning(
|
||||
'Found a security package disabled: "%s"', result["package_name"]
|
||||
)
|
||||
|
||||
if result["package_name"] in SYSTEM_UPDATE_PACKAGES and result["disabled"]:
|
||||
self.log.warning(
|
||||
'System OTA update package "%s" disabled on the phone',
|
||||
result["package_name"],
|
||||
)
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(result["package_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
for package_file in result.get("files", []):
|
||||
ioc_match = self.indicators.check_file_hash(package_file["sha256"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
# @staticmethod
|
||||
# def check_virustotal(packages: list) -> None:
|
||||
# hashes = []
|
||||
# for package in packages:
|
||||
# for file in package.get("files", []):
|
||||
# if file["sha256"] not in hashes:
|
||||
# hashes.append(file["sha256"])
|
||||
|
||||
# total_hashes = len(hashes)
|
||||
# detections = {}
|
||||
|
||||
# progress_desc = f"Looking up {total_hashes} files..."
|
||||
# for i in track(range(total_hashes), description=progress_desc):
|
||||
# try:
|
||||
# results = virustotal_lookup(hashes[i])
|
||||
# except VTNoKey:
|
||||
# return
|
||||
# except VTQuotaExceeded as exc:
|
||||
# print("Unable to continue: %s", exc)
|
||||
# break
|
||||
|
||||
# if not results:
|
||||
# continue
|
||||
|
||||
# positives = results["attributes"]["last_analysis_stats"]["malicious"]
|
||||
# total = len(results["attributes"]["last_analysis_results"])
|
||||
|
||||
# detections[hashes[i]] = f"{positives}/{total}"
|
||||
|
||||
# table = Table(title="VirusTotal Packages Detections")
|
||||
# table.add_column("Package name")
|
||||
# table.add_column("File path")
|
||||
# table.add_column("Detections")
|
||||
|
||||
# for package in packages:
|
||||
# for file in package.get("files", []):
|
||||
# row = [package["package_name"], file["path"]]
|
||||
|
||||
# if file["sha256"] in detections:
|
||||
# detection = detections[file["sha256"]]
|
||||
# positives = detection.split("/")[0]
|
||||
# if int(positives) > 0:
|
||||
# row.append(Text(detection, "red bold"))
|
||||
# else:
|
||||
# row.append(detection)
|
||||
# else:
|
||||
# row.append("not found")
|
||||
|
||||
# table.add_row(*row)
|
||||
|
||||
# console = Console()
|
||||
# console.print(table)
|
||||
|
||||
@staticmethod
|
||||
def parse_package_for_details(output: str) -> dict:
|
||||
lines = []
|
||||
in_packages = False
|
||||
for line in output.splitlines():
|
||||
if in_packages:
|
||||
if line.strip() == "":
|
||||
break
|
||||
lines.append(line)
|
||||
if line.strip() == "Packages:":
|
||||
in_packages = True
|
||||
|
||||
return DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
|
||||
"\n".join(lines)
|
||||
)
|
||||
|
||||
def _get_files_for_package(self, package_name: str) -> list:
|
||||
command = f"pm path {package_name}"
|
||||
if self._user_needed:
|
||||
command += " --user 0"
|
||||
output = self._adb_command(command)
|
||||
output = output.strip().replace("package:", "")
|
||||
if not output:
|
||||
return []
|
||||
|
||||
package_files = []
|
||||
for file_path in output.splitlines():
|
||||
file_path = file_path.strip()
|
||||
|
||||
md5 = self._adb_command(f"md5sum {file_path}").split(" ", maxsplit=1)[0]
|
||||
sha1 = self._adb_command(f"sha1sum {file_path}").split(" ", maxsplit=1)[0]
|
||||
sha256 = self._adb_command(f"sha256sum {file_path}").split(" ", maxsplit=1)[
|
||||
0
|
||||
]
|
||||
sha512 = self._adb_command(f"sha512sum {file_path}").split(" ", maxsplit=1)[
|
||||
0
|
||||
]
|
||||
|
||||
package_files.append(
|
||||
{
|
||||
"path": file_path,
|
||||
"md5": md5,
|
||||
"sha1": sha1,
|
||||
"sha256": sha256,
|
||||
"sha512": sha512,
|
||||
}
|
||||
)
|
||||
|
||||
return package_files
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
packages = self._adb_command("pm list packages -u -i -f")
|
||||
if "java.lang.SecurityException" in packages or packages.strip() == "":
|
||||
self._user_needed = True
|
||||
packages = self._adb_command("pm list packages -u -i -f --user 0")
|
||||
|
||||
for line in packages.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("package:"):
|
||||
continue
|
||||
|
||||
fields = line.split()
|
||||
file_name, package_name = fields[0].split(":")[1].rsplit("=", 1)
|
||||
|
||||
try:
|
||||
installer = fields[1].split("=")[1].strip()
|
||||
except IndexError:
|
||||
installer = None
|
||||
else:
|
||||
if installer == "null":
|
||||
installer = None
|
||||
|
||||
package_files = self._get_files_for_package(package_name)
|
||||
new_package = {
|
||||
"package_name": package_name,
|
||||
"file_name": file_name,
|
||||
"installer": installer,
|
||||
"disabled": False,
|
||||
"system": False,
|
||||
"third_party": False,
|
||||
"files": package_files,
|
||||
}
|
||||
|
||||
dumpsys_package = self._adb_command(f"dumpsys package {package_name}")
|
||||
package_details = self.parse_package_for_details(dumpsys_package)
|
||||
new_package.update(package_details)
|
||||
|
||||
self.results.append(new_package)
|
||||
|
||||
cmds = [
|
||||
{"field": "disabled", "arg": "-d"},
|
||||
{"field": "system", "arg": "-s"},
|
||||
{"field": "third_party", "arg": "-3"},
|
||||
]
|
||||
for cmd in cmds:
|
||||
command = f"pm list packages {cmd['arg']}"
|
||||
if self._user_needed:
|
||||
command += " --user 0"
|
||||
output = self._adb_command(command)
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("package:"):
|
||||
continue
|
||||
|
||||
package_name = line.split(":", 1)[1]
|
||||
for i, result in enumerate(self.results):
|
||||
if result["package_name"] == package_name:
|
||||
self.results[i][cmd["field"]] = True
|
||||
|
||||
for result in self.results:
|
||||
if not result["third_party"]:
|
||||
continue
|
||||
|
||||
dangerous_permissions_count = 0
|
||||
for perm in result["requested_permissions"]:
|
||||
if perm in DANGEROUS_PERMISSIONS:
|
||||
dangerous_permissions_count += 1
|
||||
|
||||
if dangerous_permissions_count >= DANGEROUS_PERMISSIONS_THRESHOLD:
|
||||
self.log.info(
|
||||
'Third-party package "%s" requested %d '
|
||||
"potentially dangerous permissions",
|
||||
result["package_name"],
|
||||
dangerous_permissions_count,
|
||||
)
|
||||
|
||||
packages_to_lookup = []
|
||||
for result in self.results:
|
||||
if result["system"]:
|
||||
continue
|
||||
|
||||
packages_to_lookup.append(result)
|
||||
self.log.info(
|
||||
'Found non-system package with name "%s" installed by "%s" on %s',
|
||||
result["package_name"],
|
||||
result["installer"],
|
||||
result["timestamp"],
|
||||
)
|
||||
|
||||
if not self.module_options.get("fast_mode", None):
|
||||
self.check_virustotal(packages_to_lookup)
|
||||
|
||||
self.log.info(
|
||||
"Extracted at total of %d installed package names", len(self.results)
|
||||
)
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,43 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.processes import Processes as ProcessesArtifact
|
||||
|
||||
from .base import AndroidExtraction
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class Processes(ProcessesArtifact, AndroidExtraction):
|
||||
"""This module extracts details on running processes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
output = self._adb_command("ps -A")
|
||||
self.parse(output)
|
||||
self._adb_disconnect()
|
||||
|
||||
self.log.info("Extracted records on a total of %d processes", len(self.results))
|
||||
@@ -0,0 +1,75 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
|
||||
class RootBinaries(AndroidExtraction):
|
||||
"""This module extracts the list of installed packages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for root_binary in self.results:
|
||||
self.alertstore.high(
|
||||
f'Found root binary "{root_binary}"',
|
||||
"",
|
||||
root_binary,
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
root_binaries = [
|
||||
"su",
|
||||
"busybox",
|
||||
"supersu",
|
||||
"Superuser.apk",
|
||||
"KingoUser.apk",
|
||||
"SuperSu.apk",
|
||||
"magisk",
|
||||
"magiskhide",
|
||||
"magiskinit",
|
||||
"magiskpolicy",
|
||||
]
|
||||
|
||||
self._adb_connect()
|
||||
|
||||
for root_binary in root_binaries:
|
||||
root_binary = root_binary.strip()
|
||||
if not root_binary:
|
||||
continue
|
||||
|
||||
output = self._adb_command(f"which -a {root_binary}")
|
||||
output = output.strip()
|
||||
|
||||
if not output:
|
||||
continue
|
||||
|
||||
if "which: not found" in output:
|
||||
continue
|
||||
|
||||
self.results.append(root_binary)
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,50 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
|
||||
class SELinuxStatus(AndroidExtraction):
|
||||
"""This module checks if SELinux is being enforced."""
|
||||
|
||||
slug = "selinux_status"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.results: dict = {}
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
output = self._adb_command("getenforce")
|
||||
self._adb_disconnect()
|
||||
|
||||
status = output.lower().strip()
|
||||
self.results["status"] = status
|
||||
|
||||
if status == "enforcing":
|
||||
self.log.info("SELinux is being regularly enforced")
|
||||
else:
|
||||
self.log.warning('SELinux status is "%s"!', status)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
|
||||
class Settings(SettingsArtifact, AndroidExtraction):
|
||||
"""This module extracts Android system settings."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.results = {} if not results else results
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
for namespace in ["system", "secure", "global"]:
|
||||
out = self._adb_command(f"cmd settings list {namespace}")
|
||||
if not out:
|
||||
continue
|
||||
|
||||
self.results[namespace] = {}
|
||||
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line == "":
|
||||
continue
|
||||
|
||||
fields = line.split("=", 1)
|
||||
try:
|
||||
self.results[namespace][fields[0]] = fields[1]
|
||||
except IndexError:
|
||||
continue
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,186 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.parsers.backup import AndroidBackupParsingError, parse_tar_for_sms
|
||||
from mvt.common.module import InsufficientPrivileges
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import check_for_links, convert_unix_to_iso
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
SMS_BUGLE_PATH = "data/data/com.google.android.apps.messaging/databases/bugle_db"
|
||||
SMS_BUGLE_QUERY = """
|
||||
SELECT
|
||||
ppl.normalized_destination AS address,
|
||||
p.timestamp AS timestamp,
|
||||
CASE WHEN m.sender_id IN
|
||||
(SELECT _id FROM participants WHERE contact_id=-1)
|
||||
THEN 2 ELSE 1 END incoming, p.text AS body
|
||||
FROM messages m, conversations c, parts p,
|
||||
participants ppl, conversation_participants cp
|
||||
WHERE (m.conversation_id = c._id)
|
||||
AND (m._id = p.message_id)
|
||||
AND (cp.conversation_id = c._id)
|
||||
AND (cp.participant_id = ppl._id);
|
||||
"""
|
||||
|
||||
SMS_MMSSMS_PATH = "data/data/com.android.providers.telephony/databases/mmssms.db"
|
||||
SMS_MMSMS_QUERY = """
|
||||
SELECT
|
||||
address AS address,
|
||||
date_sent AS timestamp,
|
||||
type as incoming,
|
||||
body AS body
|
||||
FROM sms;
|
||||
"""
|
||||
|
||||
|
||||
class SMS(AndroidExtraction):
|
||||
"""This module extracts all SMS messages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.sms_db_type = 0
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
body = record["body"].replace("\n", "\\n")
|
||||
return {
|
||||
"timestamp": record["isodate"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": f"sms_{record['direction']}",
|
||||
"data": f'{record.get("address", "unknown source")}: "{body}"',
|
||||
}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for message in self.results:
|
||||
if "body" not in message:
|
||||
continue
|
||||
|
||||
message_links = message.get("links", [])
|
||||
if message_links == []:
|
||||
message_links = check_for_links(message["body"])
|
||||
|
||||
ioc_match = self.indicators.check_urls(message_links)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
def _parse_db(self, db_path: str) -> None:
|
||||
"""Parse an Android bugle_db SMS database file.
|
||||
|
||||
:param db_path: Path to the Android SMS database file to process
|
||||
|
||||
"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cur = conn.cursor()
|
||||
|
||||
if self.sms_db_type == 1:
|
||||
cur.execute(SMS_BUGLE_QUERY)
|
||||
elif self.sms_db_type == 2:
|
||||
cur.execute(SMS_MMSMS_QUERY)
|
||||
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
for item in cur:
|
||||
message = {}
|
||||
for index, value in enumerate(item):
|
||||
message[names[index]] = value
|
||||
|
||||
message["direction"] = "received" if message["incoming"] == 1 else "sent"
|
||||
message["isodate"] = convert_unix_to_iso(message["timestamp"])
|
||||
|
||||
# Extract links in the message body
|
||||
body = message.get("body", None)
|
||||
if body:
|
||||
links = check_for_links(message["body"])
|
||||
message["links"] = links
|
||||
|
||||
self.results.append(message)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
self.log.info("Extracted a total of %d SMS messages", len(self.results))
|
||||
|
||||
def _extract_sms_adb(self) -> None:
|
||||
"""Use the Android backup command to extract SMS data from the native
|
||||
SMS app.
|
||||
|
||||
It is crucial to use the under-documented "-nocompress" flag to disable
|
||||
the non-standard Java compression algorithm. This module only supports
|
||||
an unencrypted ADB backup.
|
||||
"""
|
||||
backup_tar = self._generate_backup("com.android.providers.telephony")
|
||||
if not backup_tar:
|
||||
return
|
||||
|
||||
try:
|
||||
self.results = parse_tar_for_sms(backup_tar)
|
||||
except AndroidBackupParsingError:
|
||||
self.log.info(
|
||||
"Impossible to read SMS from the Android Backup, "
|
||||
"please extract the SMS and try extracting it with "
|
||||
"Android Backup Extractor"
|
||||
)
|
||||
return
|
||||
|
||||
self.log.info("Extracted a total of %d SMS messages", len(self.results))
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
try:
|
||||
if self._adb_check_file_exists(os.path.join("/", SMS_BUGLE_PATH)):
|
||||
self.sms_db_type = 1
|
||||
self._adb_process_file(
|
||||
os.path.join("/", SMS_BUGLE_PATH), self._parse_db
|
||||
)
|
||||
elif self._adb_check_file_exists(os.path.join("/", SMS_MMSSMS_PATH)):
|
||||
self.sms_db_type = 2
|
||||
self._adb_process_file(
|
||||
os.path.join("/", SMS_MMSSMS_PATH), self._parse_db
|
||||
)
|
||||
|
||||
self._adb_disconnect()
|
||||
return
|
||||
except InsufficientPrivileges:
|
||||
pass
|
||||
|
||||
self.log.info(
|
||||
"No SMS database found. Trying extraction of SMS data "
|
||||
"using Android backup feature."
|
||||
)
|
||||
self._extract_sms_adb()
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,121 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 base64
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import check_for_links, convert_unix_to_iso
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
WHATSAPP_PATH = "data/data/com.whatsapp/databases/msgstore.db"
|
||||
|
||||
|
||||
class Whatsapp(AndroidExtraction):
|
||||
"""This module extracts all WhatsApp messages containing links."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
text = record["data"].replace("\n", "\\n")
|
||||
return {
|
||||
"timestamp": record["isodate"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": f"whatsapp_msg_{record['direction']}",
|
||||
"data": f'"{text}"',
|
||||
}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for message in self.results:
|
||||
if "data" not in message:
|
||||
continue
|
||||
|
||||
message_links = check_for_links(message["data"])
|
||||
ioc_match = self.indicators.check_urls(message_links)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
continue
|
||||
|
||||
def _parse_db(self, db_path: str) -> None:
|
||||
"""Parse an Android msgstore.db WhatsApp database file.
|
||||
|
||||
:param db_path: Path to the Android WhatsApp database file to process
|
||||
|
||||
"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT * FROM messages;
|
||||
"""
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
messages = []
|
||||
for item in cur:
|
||||
message = {}
|
||||
for index, value in enumerate(item):
|
||||
message[names[index]] = value
|
||||
|
||||
if not message["data"]:
|
||||
continue
|
||||
|
||||
message["direction"] = "send" if message["key_from_me"] == 1 else "received"
|
||||
message["isodate"] = convert_unix_to_iso(message["timestamp"])
|
||||
|
||||
# If we find links in the messages or if they are empty we add them
|
||||
# to the list.
|
||||
if check_for_links(message["data"]) or message["data"].strip() == "":
|
||||
if message.get("thumb_image"):
|
||||
message["thumb_image"] = base64.b64encode(message["thumb_image"])
|
||||
|
||||
messages.append(message)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
self.log.info(
|
||||
"Extracted a total of %d WhatsApp messages containing links", len(messages)
|
||||
)
|
||||
self.results = messages
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
try:
|
||||
self._adb_process_file(os.path.join("/", WHATSAPP_PATH), self._parse_db)
|
||||
except Exception as exc:
|
||||
self.log.error(exc)
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -92,6 +92,7 @@ class AQFFiles(AndroidQFModule):
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
# NOTE: Update with final path used for Android collector.
|
||||
@@ -106,6 +107,7 @@ class AQFFiles(AndroidQFModule):
|
||||
|
||||
msg = f'Found {file_type}file at suspicious path "{result["path"]}"'
|
||||
self.alertstore.high(msg, "", result)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if result.get("sha256", "") == "":
|
||||
continue
|
||||
|
||||
@@ -49,6 +49,7 @@ class AQFPackages(AndroidQFModule):
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
# Detections for apps installed via unusual methods.
|
||||
@@ -58,36 +59,41 @@ class AQFPackages(AndroidQFModule):
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
elif result["installer"] in BROWSER_INSTALLERS:
|
||||
self.alertstore.medium(
|
||||
f'Found a package installed via a browser (installer="{result["installer"]}"): "{result["name"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
elif result["installer"] == "null" and result["system"] is False:
|
||||
self.alertstore.medium(
|
||||
self.alertstore.high(
|
||||
f'Found a non-system package installed via adb or another method: "{result["name"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
elif result["installer"] in PLAY_STORE_INSTALLERS:
|
||||
pass
|
||||
|
||||
# Check for disabled security or software update packages.
|
||||
package_disabled = result.get("disabled", None)
|
||||
if result["name"] in SECURITY_PACKAGES and package_disabled:
|
||||
self.alertstore.medium(
|
||||
self.alertstore.high(
|
||||
f'Security package "{result["name"]}" disabled on the phone',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if result["name"] in SYSTEM_UPDATE_PACKAGES and package_disabled:
|
||||
self.alertstore.medium(
|
||||
self.alertstore.high(
|
||||
f'System OTA update package "{result["name"]}" disabled on the phone',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
@@ -97,6 +103,7 @@ class AQFPackages(AndroidQFModule):
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
for package_file in result.get("files", []):
|
||||
ioc_match = self.indicators.check_file_hash(
|
||||
@@ -106,6 +113,7 @@ class AQFPackages(AndroidQFModule):
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if "certificate" not in package_file:
|
||||
continue
|
||||
@@ -123,6 +131,7 @@ class AQFPackages(AndroidQFModule):
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
break
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
@@ -51,6 +51,7 @@ class RootBinaries(AndroidQFModule):
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if self.results:
|
||||
self.log.warning(
|
||||
|
||||
@@ -41,14 +41,10 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
receiver_name = self.results[result][0]["receiver"]
|
||||
|
||||
# return IoC if the stix2 process name a substring of the receiver name
|
||||
ioc_match = self.indicators.check_receiver_prefix(receiver_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
self.results[result][0],
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
ioc = self.indicators.check_receiver_prefix(receiver_name)
|
||||
if ioc:
|
||||
self.results[result][0]["matched_indicator"] = ioc
|
||||
self.detected.append(result)
|
||||
continue
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# sources: tombstone.proto
|
||||
# plugin: python-betterproto2
|
||||
# plugin: python-betterproto
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
import betterproto2
|
||||
import betterproto
|
||||
|
||||
|
||||
class Architecture(betterproto2.Enum):
|
||||
class Architecture(betterproto.Enum):
|
||||
ARM32 = 0
|
||||
ARM64 = 1
|
||||
X86 = 2
|
||||
@@ -15,12 +16,12 @@ class Architecture(betterproto2.Enum):
|
||||
NONE = 5
|
||||
|
||||
|
||||
class MemoryErrorTool(betterproto2.Enum):
|
||||
class MemoryErrorTool(betterproto.Enum):
|
||||
GWP_ASAN = 0
|
||||
SCUDO = 1
|
||||
|
||||
|
||||
class MemoryErrorType(betterproto2.Enum):
|
||||
class MemoryErrorType(betterproto.Enum):
|
||||
UNKNOWN = 0
|
||||
USE_AFTER_FREE = 1
|
||||
DOUBLE_FREE = 2
|
||||
@@ -29,179 +30,179 @@ class MemoryErrorType(betterproto2.Enum):
|
||||
BUFFER_UNDERFLOW = 5
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class CrashDetail(betterproto2.Message):
|
||||
@dataclass
|
||||
class CrashDetail(betterproto.Message):
|
||||
"""
|
||||
NOTE TO OEMS: If you add custom fields to this proto, do not use numbers in
|
||||
the reserved range.
|
||||
"""
|
||||
|
||||
name: "bytes" = betterproto2.field(1, betterproto2.TYPE_BYTES)
|
||||
data: "bytes" = betterproto2.field(2, betterproto2.TYPE_BYTES)
|
||||
name: bytes = betterproto.bytes_field(1)
|
||||
data: bytes = betterproto.bytes_field(2)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class StackHistoryBufferEntry(betterproto2.Message):
|
||||
addr: "BacktraceFrame | None" = betterproto2.field(1, betterproto2.TYPE_MESSAGE, optional=True)
|
||||
fp: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
tag: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class StackHistoryBufferEntry(betterproto.Message):
|
||||
addr: "BacktraceFrame" = betterproto.message_field(1)
|
||||
fp: int = betterproto.uint64_field(2)
|
||||
tag: int = betterproto.uint64_field(3)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class StackHistoryBuffer(betterproto2.Message):
|
||||
tid: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64)
|
||||
entries: "list[StackHistoryBufferEntry]" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
@dataclass
|
||||
class StackHistoryBuffer(betterproto.Message):
|
||||
tid: int = betterproto.uint64_field(1)
|
||||
entries: List["StackHistoryBufferEntry"] = betterproto.message_field(2)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Tombstone(betterproto2.Message):
|
||||
arch: "Architecture" = betterproto2.field(1, betterproto2.TYPE_ENUM, default_factory=lambda: Architecture(0))
|
||||
guest_arch: "Architecture" = betterproto2.field(24, betterproto2.TYPE_ENUM, default_factory=lambda: Architecture(0))
|
||||
build_fingerprint: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
revision: "str" = betterproto2.field(3, betterproto2.TYPE_STRING)
|
||||
timestamp: "str" = betterproto2.field(4, betterproto2.TYPE_STRING)
|
||||
pid: "int" = betterproto2.field(5, betterproto2.TYPE_UINT32)
|
||||
tid: "int" = betterproto2.field(6, betterproto2.TYPE_UINT32)
|
||||
uid: "int" = betterproto2.field(7, betterproto2.TYPE_UINT32)
|
||||
selinux_label: "str" = betterproto2.field(8, betterproto2.TYPE_STRING)
|
||||
command_line: "list[str]" = betterproto2.field(9, betterproto2.TYPE_STRING, repeated=True)
|
||||
@dataclass
|
||||
class Tombstone(betterproto.Message):
|
||||
arch: "Architecture" = betterproto.enum_field(1)
|
||||
guest_arch: "Architecture" = betterproto.enum_field(24)
|
||||
build_fingerprint: str = betterproto.string_field(2)
|
||||
revision: str = betterproto.string_field(3)
|
||||
timestamp: str = betterproto.string_field(4)
|
||||
pid: int = betterproto.uint32_field(5)
|
||||
tid: int = betterproto.uint32_field(6)
|
||||
uid: int = betterproto.uint32_field(7)
|
||||
selinux_label: str = betterproto.string_field(8)
|
||||
command_line: List[str] = betterproto.string_field(9)
|
||||
# Process uptime in seconds.
|
||||
process_uptime: "int" = betterproto2.field(20, betterproto2.TYPE_UINT32)
|
||||
signal_info: "Signal | None" = betterproto2.field(10, betterproto2.TYPE_MESSAGE, optional=True)
|
||||
abort_message: "str" = betterproto2.field(14, betterproto2.TYPE_STRING)
|
||||
crash_details: "list[CrashDetail]" = betterproto2.field(21, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
causes: "list[Cause]" = betterproto2.field(15, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
threads: "dict[int, Thread]" = betterproto2.field(
|
||||
16, betterproto2.TYPE_MAP, map_meta=betterproto2.map_meta(betterproto2.TYPE_UINT32, betterproto2.TYPE_MESSAGE)
|
||||
process_uptime: int = betterproto.uint32_field(20)
|
||||
signal_info: "Signal" = betterproto.message_field(10)
|
||||
abort_message: str = betterproto.string_field(14)
|
||||
crash_details: List["CrashDetail"] = betterproto.message_field(21)
|
||||
causes: List["Cause"] = betterproto.message_field(15)
|
||||
threads: Dict[int, "Thread"] = betterproto.map_field(
|
||||
16, betterproto.TYPE_UINT32, betterproto.TYPE_MESSAGE
|
||||
)
|
||||
guest_threads: "dict[int, Thread]" = betterproto2.field(
|
||||
25, betterproto2.TYPE_MAP, map_meta=betterproto2.map_meta(betterproto2.TYPE_UINT32, betterproto2.TYPE_MESSAGE)
|
||||
guest_threads: Dict[int, "Thread"] = betterproto.map_field(
|
||||
25, betterproto.TYPE_UINT32, betterproto.TYPE_MESSAGE
|
||||
)
|
||||
memory_mappings: "list[MemoryMapping]" = betterproto2.field(17, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
log_buffers: "list[LogBuffer]" = betterproto2.field(18, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
open_fds: "list[FD]" = betterproto2.field(19, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
page_size: "int" = betterproto2.field(22, betterproto2.TYPE_UINT32)
|
||||
has_been_16kb_mode: "bool" = betterproto2.field(23, betterproto2.TYPE_BOOL)
|
||||
stack_history_buffer: "StackHistoryBuffer | None" = betterproto2.field(26, betterproto2.TYPE_MESSAGE, optional=True)
|
||||
memory_mappings: List["MemoryMapping"] = betterproto.message_field(17)
|
||||
log_buffers: List["LogBuffer"] = betterproto.message_field(18)
|
||||
open_fds: List["FD"] = betterproto.message_field(19)
|
||||
page_size: int = betterproto.uint32_field(22)
|
||||
has_been_16kb_mode: bool = betterproto.bool_field(23)
|
||||
stack_history_buffer: "StackHistoryBuffer" = betterproto.message_field(26)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Signal(betterproto2.Message):
|
||||
number: "int" = betterproto2.field(1, betterproto2.TYPE_INT32)
|
||||
name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
code: "int" = betterproto2.field(3, betterproto2.TYPE_INT32)
|
||||
code_name: "str" = betterproto2.field(4, betterproto2.TYPE_STRING)
|
||||
has_sender: "bool" = betterproto2.field(5, betterproto2.TYPE_BOOL)
|
||||
sender_uid: "int" = betterproto2.field(6, betterproto2.TYPE_INT32)
|
||||
sender_pid: "int" = betterproto2.field(7, betterproto2.TYPE_INT32)
|
||||
has_fault_address: "bool" = betterproto2.field(8, betterproto2.TYPE_BOOL)
|
||||
fault_address: "int" = betterproto2.field(9, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class Signal(betterproto.Message):
|
||||
number: int = betterproto.int32_field(1)
|
||||
name: str = betterproto.string_field(2)
|
||||
code: int = betterproto.int32_field(3)
|
||||
code_name: str = betterproto.string_field(4)
|
||||
has_sender: bool = betterproto.bool_field(5)
|
||||
sender_uid: int = betterproto.int32_field(6)
|
||||
sender_pid: int = betterproto.int32_field(7)
|
||||
has_fault_address: bool = betterproto.bool_field(8)
|
||||
fault_address: int = betterproto.uint64_field(9)
|
||||
# Note, may or may not contain the dump of the actual memory contents.
|
||||
# Currently, on arm64, we only include metadata, and not the contents.
|
||||
fault_adjacent_metadata: "MemoryDump | None" = betterproto2.field(10, betterproto2.TYPE_MESSAGE, optional=True)
|
||||
fault_adjacent_metadata: "MemoryDump" = betterproto.message_field(10)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class HeapObject(betterproto2.Message):
|
||||
address: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64)
|
||||
size: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
allocation_tid: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
allocation_backtrace: "list[BacktraceFrame]" = betterproto2.field(4, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
deallocation_tid: "int" = betterproto2.field(5, betterproto2.TYPE_UINT64)
|
||||
deallocation_backtrace: "list[BacktraceFrame]" = betterproto2.field(6, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
@dataclass
|
||||
class HeapObject(betterproto.Message):
|
||||
address: int = betterproto.uint64_field(1)
|
||||
size: int = betterproto.uint64_field(2)
|
||||
allocation_tid: int = betterproto.uint64_field(3)
|
||||
allocation_backtrace: List["BacktraceFrame"] = betterproto.message_field(4)
|
||||
deallocation_tid: int = betterproto.uint64_field(5)
|
||||
deallocation_backtrace: List["BacktraceFrame"] = betterproto.message_field(6)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class MemoryError(betterproto2.Message):
|
||||
tool: "MemoryErrorTool" = betterproto2.field(1, betterproto2.TYPE_ENUM, default_factory=lambda: MemoryErrorTool(0))
|
||||
type: "MemoryErrorType" = betterproto2.field(2, betterproto2.TYPE_ENUM, default_factory=lambda: MemoryErrorType(0))
|
||||
heap: "HeapObject | None" = betterproto2.field(3, betterproto2.TYPE_MESSAGE, optional=True, group="location")
|
||||
@dataclass
|
||||
class MemoryError(betterproto.Message):
|
||||
tool: "MemoryErrorTool" = betterproto.enum_field(1)
|
||||
type: "MemoryErrorType" = betterproto.enum_field(2)
|
||||
heap: "HeapObject" = betterproto.message_field(3, group="location")
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Cause(betterproto2.Message):
|
||||
human_readable: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
memory_error: "MemoryError | None" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, optional=True, group="details")
|
||||
@dataclass
|
||||
class Cause(betterproto.Message):
|
||||
human_readable: str = betterproto.string_field(1)
|
||||
memory_error: "MemoryError" = betterproto.message_field(2, group="details")
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Register(betterproto2.Message):
|
||||
name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
u64: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class Register(betterproto.Message):
|
||||
name: str = betterproto.string_field(1)
|
||||
u64: int = betterproto.uint64_field(2)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Thread(betterproto2.Message):
|
||||
id: "int" = betterproto2.field(1, betterproto2.TYPE_INT32)
|
||||
name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
registers: "list[Register]" = betterproto2.field(3, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
backtrace_note: "list[str]" = betterproto2.field(7, betterproto2.TYPE_STRING, repeated=True)
|
||||
unreadable_elf_files: "list[str]" = betterproto2.field(9, betterproto2.TYPE_STRING, repeated=True)
|
||||
current_backtrace: "list[BacktraceFrame]" = betterproto2.field(4, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
memory_dump: "list[MemoryDump]" = betterproto2.field(5, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
tagged_addr_ctrl: "int" = betterproto2.field(6, betterproto2.TYPE_INT64)
|
||||
pac_enabled_keys: "int" = betterproto2.field(8, betterproto2.TYPE_INT64)
|
||||
@dataclass
|
||||
class Thread(betterproto.Message):
|
||||
id: int = betterproto.int32_field(1)
|
||||
name: str = betterproto.string_field(2)
|
||||
registers: List["Register"] = betterproto.message_field(3)
|
||||
backtrace_note: List[str] = betterproto.string_field(7)
|
||||
unreadable_elf_files: List[str] = betterproto.string_field(9)
|
||||
current_backtrace: List["BacktraceFrame"] = betterproto.message_field(4)
|
||||
memory_dump: List["MemoryDump"] = betterproto.message_field(5)
|
||||
tagged_addr_ctrl: int = betterproto.int64_field(6)
|
||||
pac_enabled_keys: int = betterproto.int64_field(8)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class BacktraceFrame(betterproto2.Message):
|
||||
rel_pc: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64)
|
||||
pc: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
sp: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
function_name: "str" = betterproto2.field(4, betterproto2.TYPE_STRING)
|
||||
function_offset: "int" = betterproto2.field(5, betterproto2.TYPE_UINT64)
|
||||
file_name: "str" = betterproto2.field(6, betterproto2.TYPE_STRING)
|
||||
file_map_offset: "int" = betterproto2.field(7, betterproto2.TYPE_UINT64)
|
||||
build_id: "str" = betterproto2.field(8, betterproto2.TYPE_STRING)
|
||||
@dataclass
|
||||
class BacktraceFrame(betterproto.Message):
|
||||
rel_pc: int = betterproto.uint64_field(1)
|
||||
pc: int = betterproto.uint64_field(2)
|
||||
sp: int = betterproto.uint64_field(3)
|
||||
function_name: str = betterproto.string_field(4)
|
||||
function_offset: int = betterproto.uint64_field(5)
|
||||
file_name: str = betterproto.string_field(6)
|
||||
file_map_offset: int = betterproto.uint64_field(7)
|
||||
build_id: str = betterproto.string_field(8)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class ArmMTEMetadata(betterproto2.Message):
|
||||
@dataclass
|
||||
class ArmMTEMetadata(betterproto.Message):
|
||||
# One memory tag per granule (e.g. every 16 bytes) of regular memory.
|
||||
memory_tags: "bytes" = betterproto2.field(1, betterproto2.TYPE_BYTES)
|
||||
memory_tags: bytes = betterproto.bytes_field(1)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class MemoryDump(betterproto2.Message):
|
||||
register_name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
mapping_name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
begin_address: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
memory: "bytes" = betterproto2.field(4, betterproto2.TYPE_BYTES)
|
||||
arm_mte_metadata: "ArmMTEMetadata | None" = betterproto2.field(6, betterproto2.TYPE_MESSAGE, optional=True, group="metadata")
|
||||
@dataclass
|
||||
class MemoryDump(betterproto.Message):
|
||||
register_name: str = betterproto.string_field(1)
|
||||
mapping_name: str = betterproto.string_field(2)
|
||||
begin_address: int = betterproto.uint64_field(3)
|
||||
memory: bytes = betterproto.bytes_field(4)
|
||||
arm_mte_metadata: "ArmMTEMetadata" = betterproto.message_field(6, group="metadata")
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class MemoryMapping(betterproto2.Message):
|
||||
begin_address: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64)
|
||||
end_address: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
offset: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
read: "bool" = betterproto2.field(4, betterproto2.TYPE_BOOL)
|
||||
write: "bool" = betterproto2.field(5, betterproto2.TYPE_BOOL)
|
||||
execute: "bool" = betterproto2.field(6, betterproto2.TYPE_BOOL)
|
||||
mapping_name: "str" = betterproto2.field(7, betterproto2.TYPE_STRING)
|
||||
build_id: "str" = betterproto2.field(8, betterproto2.TYPE_STRING)
|
||||
load_bias: "int" = betterproto2.field(9, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class MemoryMapping(betterproto.Message):
|
||||
begin_address: int = betterproto.uint64_field(1)
|
||||
end_address: int = betterproto.uint64_field(2)
|
||||
offset: int = betterproto.uint64_field(3)
|
||||
read: bool = betterproto.bool_field(4)
|
||||
write: bool = betterproto.bool_field(5)
|
||||
execute: bool = betterproto.bool_field(6)
|
||||
mapping_name: str = betterproto.string_field(7)
|
||||
build_id: str = betterproto.string_field(8)
|
||||
load_bias: int = betterproto.uint64_field(9)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class FD(betterproto2.Message):
|
||||
fd: "int" = betterproto2.field(1, betterproto2.TYPE_INT32)
|
||||
path: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
owner: "str" = betterproto2.field(3, betterproto2.TYPE_STRING)
|
||||
tag: "int" = betterproto2.field(4, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class FD(betterproto.Message):
|
||||
fd: int = betterproto.int32_field(1)
|
||||
path: str = betterproto.string_field(2)
|
||||
owner: str = betterproto.string_field(3)
|
||||
tag: int = betterproto.uint64_field(4)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class LogBuffer(betterproto2.Message):
|
||||
name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
logs: "list[LogMessage]" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
@dataclass
|
||||
class LogBuffer(betterproto.Message):
|
||||
name: str = betterproto.string_field(1)
|
||||
logs: List["LogMessage"] = betterproto.message_field(2)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class LogMessage(betterproto2.Message):
|
||||
timestamp: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
pid: "int" = betterproto2.field(2, betterproto2.TYPE_UINT32)
|
||||
tid: "int" = betterproto2.field(3, betterproto2.TYPE_UINT32)
|
||||
priority: "int" = betterproto2.field(4, betterproto2.TYPE_UINT32)
|
||||
tag: "str" = betterproto2.field(5, betterproto2.TYPE_STRING)
|
||||
message: "str" = betterproto2.field(6, betterproto2.TYPE_STRING)
|
||||
@dataclass
|
||||
class LogMessage(betterproto.Message):
|
||||
timestamp: str = betterproto.string_field(1)
|
||||
pid: int = betterproto.uint32_field(2)
|
||||
tid: int = betterproto.uint32_field(3)
|
||||
priority: int = betterproto.uint32_field(4)
|
||||
tag: str = betterproto.string_field(5)
|
||||
message: str = betterproto.string_field(6)
|
||||
@@ -207,13 +207,6 @@ class AlertStore:
|
||||
alert_dict = asdict(alert)
|
||||
# This is required because an Enum is not JSON serializable.
|
||||
alert_dict["level"] = alert.level.name
|
||||
if isinstance(alert_dict.get("event"), dict):
|
||||
event_matched_indicator = alert_dict["event"].pop(
|
||||
"matched_indicator", None
|
||||
)
|
||||
if alert_dict["matched_indicator"] is None:
|
||||
alert_dict["matched_indicator"] = event_matched_indicator
|
||||
|
||||
alerts.append(alert_dict)
|
||||
|
||||
return alerts
|
||||
|
||||
@@ -17,9 +17,8 @@ from rich.text import Text
|
||||
from .alerts import AlertLevel, AlertStore
|
||||
from .config import settings
|
||||
from .indicators import Indicators
|
||||
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
|
||||
from .module import MVTModule, run_module, save_timeline
|
||||
from .utils import (
|
||||
CustomJSONEncoder,
|
||||
convert_datetime_to_iso,
|
||||
generate_hashes_from_path,
|
||||
get_sha256_from_file_path,
|
||||
@@ -142,7 +141,7 @@ class Command:
|
||||
|
||||
alerts_path = os.path.join(self.results_path, "alerts.json")
|
||||
with open(alerts_path, "w+", encoding="utf-8") as handle:
|
||||
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
|
||||
json.dump(alerts, handle, indent=4)
|
||||
|
||||
def _store_alerts_timeline(self) -> None:
|
||||
if not self.results_path:
|
||||
@@ -295,14 +294,7 @@ class Command:
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
try:
|
||||
run_module(m)
|
||||
except EncryptedBackupError:
|
||||
self.log.critical(
|
||||
"The backup appears to be encrypted. "
|
||||
"Please decrypt it first using `mvt-ios decrypt-backup`."
|
||||
)
|
||||
return
|
||||
run_module(m)
|
||||
|
||||
self.executed.append(m)
|
||||
self.timeline.extend(m.timeline)
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
from typing import Any, Dict, Iterator, List, Optional, Union
|
||||
|
||||
import ahocorasick
|
||||
from appdirs import user_data_dir
|
||||
@@ -115,17 +115,6 @@ class Indicators:
|
||||
key, value = indicator.get("pattern", "").strip("[]").split("=")
|
||||
key = key.strip()
|
||||
|
||||
# Normalize hash algorithm keys so that both the STIX2-spec-compliant
|
||||
# form (e.g. file:hashes.'SHA-256', which requires quotes around
|
||||
# algorithm names that contain hyphens) and the non-standard lowercase
|
||||
# form (e.g. file:hashes.sha256) are accepted. Strip single quotes and
|
||||
# hyphens from the algorithm name only, then lowercase it.
|
||||
for sep in ("hashes.", "cert."):
|
||||
if sep in key:
|
||||
prefix, _, algo = key.partition(sep)
|
||||
key = prefix + sep + algo.replace("'", "").replace("-", "").lower()
|
||||
break
|
||||
|
||||
if key == "domain-name:value":
|
||||
# We force domain names to lower case.
|
||||
self._add_indicator(
|
||||
@@ -727,32 +716,30 @@ class Indicators:
|
||||
|
||||
return None
|
||||
|
||||
def check_receiver_prefix(
|
||||
self, receiver_name: str
|
||||
) -> Optional[IndicatorMatch]:
|
||||
def check_receiver_prefix(self, receiver_name: str) -> Union[dict, None]:
|
||||
"""Check the provided receiver name against the list of indicators.
|
||||
An IoC match is detected when a substring of the receiver matches the indicator.
|
||||
|
||||
:param receiver_name: Receiver name to check against app ID indicators
|
||||
:type receiver_name: str
|
||||
:returns: IndicatorMatch if matched, otherwise None
|
||||
An IoC match is detected when a substring of the receiver matches the indicator
|
||||
:param app_id: App ID to check against the list of indicators
|
||||
:type app_id: str
|
||||
:returns: Indicator details if matched, otherwise None
|
||||
|
||||
"""
|
||||
if not receiver_name:
|
||||
return None
|
||||
|
||||
for ioc in self.get_iocs("app_ids"):
|
||||
if ioc.value.lower() in receiver_name.lower():
|
||||
return IndicatorMatch(
|
||||
ioc=ioc,
|
||||
message=f'Found a known suspicious receiver with name "{receiver_name}" matching indicators from "{ioc.name}"',
|
||||
if ioc["value"].lower() in receiver_name.lower():
|
||||
self.log.warning(
|
||||
'Found a known suspicious receiver with name "%s" '
|
||||
'matching indicators from "%s"',
|
||||
receiver_name,
|
||||
ioc["name"],
|
||||
)
|
||||
return ioc
|
||||
|
||||
return None
|
||||
|
||||
def check_android_property_name(
|
||||
self, property_name: str
|
||||
) -> Optional[IndicatorMatch]:
|
||||
def check_android_property_name(self, property_name: str) -> Optional[dict]:
|
||||
"""Check the android property name against the list of indicators.
|
||||
|
||||
:param property_name: Name of the Android property
|
||||
|
||||
@@ -30,10 +30,6 @@ class DatabaseCorruptedError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EncryptedBackupError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InsufficientPrivileges(Exception):
|
||||
pass
|
||||
|
||||
@@ -109,13 +105,10 @@ class MVTModule:
|
||||
name = self.get_slug()
|
||||
|
||||
if self.results:
|
||||
if isinstance(self.results, dict):
|
||||
converted_results = self.results
|
||||
else:
|
||||
converted_results = [
|
||||
asdict(result) if is_dataclass(result) else result
|
||||
for result in self.results
|
||||
]
|
||||
converted_results = [
|
||||
asdict(result) if is_dataclass(result) else result
|
||||
for result in self.results
|
||||
]
|
||||
results_file_name = f"{name}.json"
|
||||
results_json_path = os.path.join(self.results_path, results_file_name)
|
||||
with open(results_json_path, "w", encoding="utf-8") as handle:
|
||||
@@ -136,7 +129,7 @@ class MVTModule:
|
||||
detected_json_path = os.path.join(self.results_path, detected_file_name)
|
||||
with open(detected_json_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(
|
||||
self.alertstore.as_json(), handle, indent=4, cls=CustomJSONEncoder
|
||||
self.alertstore.alerts, handle, indent=4, cls=CustomJSONEncoder
|
||||
)
|
||||
|
||||
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
@@ -188,8 +181,6 @@ def run_module(module: MVTModule) -> None:
|
||||
|
||||
try:
|
||||
exec_or_profile("module.run()", globals(), locals())
|
||||
except EncryptedBackupError:
|
||||
raise
|
||||
except NotImplementedError:
|
||||
module.log.exception(
|
||||
"The run() procedure of module %s was not implemented yet!",
|
||||
|
||||
@@ -10,7 +10,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Iterator, Union
|
||||
|
||||
from .log import MVTLogHandler
|
||||
@@ -31,9 +30,6 @@ class CustomJSONEncoder(json.JSONEncoder):
|
||||
"""
|
||||
|
||||
def default(self, o):
|
||||
# Unwrap dataclass instances (such as Indicator) to dict. Skip class itself.
|
||||
if is_dataclass(o) and not isinstance(o, type):
|
||||
return asdict(o)
|
||||
if isinstance(o, bytes):
|
||||
# Decode as utf-8, replace any invalid UTF-8 bytes with escaped hex
|
||||
return o.decode("utf-8", errors="backslashreplace")
|
||||
@@ -239,7 +235,7 @@ def init_logging(verbose: bool = False):
|
||||
Initialise logging for the MVT module
|
||||
"""
|
||||
log = logging.getLogger("mvt")
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.setLevel(logging.INFO)
|
||||
consoleHandler = MVTLogHandler()
|
||||
consoleHandler.setFormatter(logging.Formatter("%(message)s"))
|
||||
if verbose:
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
MVT_VERSION = "2026.4.28"
|
||||
MVT_VERSION = "3.0.0"
|
||||
@@ -1200,10 +1200,6 @@
|
||||
"version": "18.7.7",
|
||||
"build": "22H333"
|
||||
},
|
||||
{
|
||||
"version": "18.7.8",
|
||||
"build": "22H352"
|
||||
},
|
||||
{
|
||||
"version": "26",
|
||||
"build": "23A341"
|
||||
@@ -1235,9 +1231,5 @@
|
||||
{
|
||||
"version": "26.4",
|
||||
"build": "23E246"
|
||||
},
|
||||
{
|
||||
"version": "26.4.2",
|
||||
"build": "23E261"
|
||||
}
|
||||
]
|
||||
@@ -46,16 +46,14 @@ class DecryptBackup:
|
||||
|
||||
"""
|
||||
conn = sqlite3.connect(os.path.join(backup_path, "Manifest.db"))
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT fileID FROM Files LIMIT 1;")
|
||||
except sqlite3.DatabaseError:
|
||||
return True
|
||||
else:
|
||||
log.critical("The backup does not seem encrypted!")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _process_file(
|
||||
self, relative_path: str, domain: str, item, file_id: str, item_folder: str
|
||||
|
||||
@@ -73,9 +73,11 @@ class ConfigurationProfiles(IOSExtraction):
|
||||
)
|
||||
if ioc_match:
|
||||
warning_message = f'Found a known malicious configuration profile "{result["plist"]["PayloadDisplayName"]}" with UUID "{result["plist"]["PayloadUUID"]}"'
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
warning_message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
# Highlight suspicious configuration profiles which may be used
|
||||
@@ -83,6 +85,7 @@ class ConfigurationProfiles(IOSExtraction):
|
||||
if payload_content["PayloadType"] in ["com.apple.notificationsettings"]:
|
||||
warning_message = f'Found a potentially suspicious configuration profile "{result["plist"]["PayloadDisplayName"]}" with payload type {payload_content["PayloadType"]}'
|
||||
self.alertstore.medium(warning_message, "", result)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
@@ -8,10 +8,9 @@ import io
|
||||
import logging
|
||||
import os
|
||||
import plistlib
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module import DatabaseNotFoundError, EncryptedBackupError
|
||||
from mvt.common.module import DatabaseNotFoundError
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
@@ -103,6 +102,7 @@ class Manifest(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_file_path("/" + result["relative_path"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.high(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
@@ -118,6 +118,7 @@ class Manifest(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_url(part)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.high(
|
||||
f'Found mention of domain "{ioc_match.ioc.value}" in a backup file with path: {rel_path}',
|
||||
"",
|
||||
@@ -137,14 +138,7 @@ class Manifest(IOSExtraction):
|
||||
conn = self._open_sqlite_db(manifest_db_path)
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
cur.execute("SELECT * FROM Files;")
|
||||
except sqlite3.DatabaseError:
|
||||
conn.close()
|
||||
raise EncryptedBackupError(
|
||||
"Manifest.db is not a valid SQLite database. "
|
||||
"The backup may be encrypted."
|
||||
)
|
||||
cur.execute("SELECT * FROM Files;")
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
for file_entry in cur:
|
||||
|
||||
@@ -59,6 +59,7 @@ class ProfileEvents(IOSExtraction):
|
||||
for result in self.results:
|
||||
message = f'On {result.get("timestamp")} process "{result.get("process")}" started operation "{result.get("operation")}" of profile "{result.get("profile_id")}"'
|
||||
self.alertstore.low(message, result.get("timestamp") or "", result)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if not self.indicators:
|
||||
return
|
||||
@@ -66,6 +67,7 @@ class ProfileEvents(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_process(result.get("process") or "")
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
@@ -73,6 +75,7 @@ class ProfileEvents(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_profile(result.get("profile_id") or "")
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -122,8 +122,6 @@ class IOSExtraction(MVTModule):
|
||||
|
||||
base_sql = "SELECT fileID, domain, relativePath FROM Files WHERE "
|
||||
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
cur: Optional[sqlite3.Cursor] = None
|
||||
try:
|
||||
conn = self._open_sqlite_db(manifest_db_path)
|
||||
cur = conn.cursor()
|
||||
@@ -143,23 +141,15 @@ class IOSExtraction(MVTModule):
|
||||
cur.execute(f"{base_sql} relativePath = ?;", (relative_path,))
|
||||
elif domain:
|
||||
cur.execute(f"{base_sql} domain = ?;", (domain,))
|
||||
records = [
|
||||
{
|
||||
"file_id": row[0],
|
||||
"domain": row[1],
|
||||
"relative_path": row[2],
|
||||
}
|
||||
for row in cur
|
||||
]
|
||||
except Exception as exc:
|
||||
raise DatabaseCorruptedError(f"failed to query Manifest.db: {exc}") from exc
|
||||
finally:
|
||||
if cur:
|
||||
cur.close()
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
return iter(records)
|
||||
for row in cur:
|
||||
yield {
|
||||
"file_id": row[0],
|
||||
"domain": row[1],
|
||||
"relative_path": row[2],
|
||||
}
|
||||
|
||||
def _get_backup_file_from_id(self, file_id: str) -> Union[str, None]:
|
||||
if not self.target_path:
|
||||
|
||||
@@ -67,14 +67,17 @@ class Analytics(IOSExtraction):
|
||||
if ioc_match:
|
||||
warning_message = f'Found mention of a malicious process "{value}" in {result["artifact"]} file at {result["isodate"]}'
|
||||
new_result = copy.copy(result)
|
||||
new_result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
warning_message, "", new_result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_url(value)
|
||||
if ioc_match:
|
||||
new_result = copy.copy(result)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
|
||||
@@ -54,17 +54,19 @@ class CacheFiles(IOSExtraction):
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
self.alertstore.alerts = {}
|
||||
for key, values in self.results.items():
|
||||
for value in values:
|
||||
ioc_match = self.indicators.check_url(value["url"])
|
||||
if ioc_match:
|
||||
value["cache_file"] = key
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
value.get("isodate", ""),
|
||||
value,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
value["matched_indicator"] = ioc_match.ioc
|
||||
# XXX: Finish converting this method
|
||||
if key not in self.alertstore.alerts:
|
||||
self.alertstore.alerts[key] = [
|
||||
value,
|
||||
]
|
||||
else:
|
||||
self.alertstore.alerts[key].append(value)
|
||||
|
||||
def _process_cache_file(self, file_path):
|
||||
self.log.info("Processing cache file at path: %s", file_path)
|
||||
@@ -73,29 +75,25 @@ class CacheFiles(IOSExtraction):
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
try:
|
||||
cur.execute("SELECT * FROM cfurl_cache_response;")
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
cur.execute("SELECT * FROM cfurl_cache_response;")
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
|
||||
key_name = os.path.relpath(file_path, self.target_path)
|
||||
if key_name not in self.results:
|
||||
self.results[key_name] = []
|
||||
key_name = os.path.relpath(file_path, self.target_path)
|
||||
if key_name not in self.results:
|
||||
self.results[key_name] = []
|
||||
|
||||
for row in cur:
|
||||
self.results[key_name].append(
|
||||
{
|
||||
"entry_id": row[0],
|
||||
"version": row[1],
|
||||
"hash_value": row[2],
|
||||
"storage_policy": row[3],
|
||||
"url": row[4],
|
||||
"isodate": row[5],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
for row in cur:
|
||||
self.results[key_name].append(
|
||||
{
|
||||
"entry_id": row[0],
|
||||
"version": row[1],
|
||||
"hash_value": row[2],
|
||||
"storage_policy": row[3],
|
||||
"url": row[4],
|
||||
"isodate": row[5],
|
||||
}
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
self.results: dict = {}
|
||||
|
||||
@@ -58,9 +58,8 @@ class Filesystem(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_file_path(result["path"])
|
||||
if ioc_match:
|
||||
self.alertstore.high(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.high(self.get_slug(), ioc_match.message, "", result)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
# If we are instructed to run fast, we skip the rest.
|
||||
if self.module_options.get("fast_mode", None):
|
||||
@@ -68,9 +67,8 @@ class Filesystem(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_file_path_process(result["path"])
|
||||
if ioc_match:
|
||||
self.alertstore.high(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.high(self.get_slug(), ioc_match.message, "", result)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
def run(self) -> None:
|
||||
for root, dirs, files in os.walk(self.target_path):
|
||||
|
||||
@@ -65,6 +65,7 @@ class SafariFavicon(IOSExtraction):
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
def _process_favicon_db(self, file_path):
|
||||
conn = self._open_sqlite_db(file_path)
|
||||
|
||||
@@ -60,17 +60,20 @@ class ShutdownLog(IOSExtraction):
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
for ioc in self.indicators.get_iocs("processes"):
|
||||
parts = result["client"].split("/")
|
||||
if ioc.value in parts:
|
||||
result["matched_indicator"] = ioc
|
||||
self.alertstore.critical(
|
||||
f'Found mention of a known malicious process "{ioc.value}" in shutdown.log',
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
def process_shutdownlog(self, content):
|
||||
|
||||
@@ -20,6 +20,7 @@ class WebkitBase(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_url(result["url"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ class Applications(IOSExtraction):
|
||||
for result in self.results:
|
||||
if self.indicators:
|
||||
if "softwareVersionBundleId" not in result:
|
||||
self.alertstore.medium(
|
||||
self.alertstore.high(
|
||||
"Suspicious application identified without softwareVersionBundleId",
|
||||
"",
|
||||
result,
|
||||
@@ -77,6 +77,7 @@ class Applications(IOSExtraction):
|
||||
result["softwareVersionBundleId"]
|
||||
)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
f"Malicious application {result['softwareVersionBundleId']} identified",
|
||||
"",
|
||||
@@ -89,6 +90,7 @@ class Applications(IOSExtraction):
|
||||
result["softwareVersionBundleId"]
|
||||
)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
f"Malicious application {result['softwareVersionBundleId']} identified",
|
||||
"",
|
||||
|
||||
@@ -71,6 +71,7 @@ class Calendar(IOSExtraction):
|
||||
if result["participant_email"] and self.indicators:
|
||||
ioc_match = self.indicators.check_email(result["participant_email"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
@@ -83,6 +84,7 @@ class Calendar(IOSExtraction):
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
def _parse_calendar_db(self):
|
||||
"""
|
||||
|
||||
@@ -61,6 +61,7 @@ class ChromeFavicon(IOSExtraction):
|
||||
ioc_match = self.indicators.check_url(result["icon_url"])
|
||||
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -62,6 +62,7 @@ class ChromeHistory(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_url(result["url"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -51,31 +51,30 @@ class Contacts(IOSExtraction):
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
multi.value, person.first, person.middle, person.last,
|
||||
person.organization
|
||||
FROM ABPerson person, ABMultiValue multi
|
||||
WHERE person.rowid = multi.record_id and multi.value not null
|
||||
ORDER by person.rowid ASC;
|
||||
cur.execute(
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.info("Error while reading the contact table: %s", e)
|
||||
return None
|
||||
names = [description[0] for description in cur.description]
|
||||
SELECT
|
||||
multi.value, person.first, person.middle, person.last,
|
||||
person.organization
|
||||
FROM ABPerson person, ABMultiValue multi
|
||||
WHERE person.rowid = multi.record_id and multi.value not null
|
||||
ORDER by person.rowid ASC;
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.info("Error while reading the contact table: %s", e)
|
||||
return None
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
for row in cur:
|
||||
new_contact = {}
|
||||
for index, value in enumerate(row):
|
||||
new_contact[names[index]] = value
|
||||
for row in cur:
|
||||
new_contact = {}
|
||||
for index, value in enumerate(row):
|
||||
new_contact[names[index]] = value
|
||||
|
||||
self.results.append(new_contact)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
self.results.append(new_contact)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
self.log.info(
|
||||
"Extracted a total of %d contacts from the address book", len(self.results)
|
||||
|
||||
@@ -63,6 +63,7 @@ class FirefoxFavicon(IOSExtraction):
|
||||
ioc_match = self.indicators.check_url(result.get("history_url", ""))
|
||||
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ class FirefoxHistory(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_url(result["url"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -65,6 +65,7 @@ class IDStatusCache(IOSExtraction):
|
||||
email = result["user"][7:].strip("'")
|
||||
ioc_match = self.indicators.check_email(email)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -291,38 +291,37 @@ class InteractionC(IOSExtraction):
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
cur.execute(QUERIES[0])
|
||||
except sqlite3.OperationalError:
|
||||
try:
|
||||
cur.execute(QUERIES[0])
|
||||
cur.execute(QUERIES[1])
|
||||
except sqlite3.OperationalError:
|
||||
try:
|
||||
cur.execute(QUERIES[1])
|
||||
cur.execute(QUERIES[2])
|
||||
except sqlite3.OperationalError:
|
||||
try:
|
||||
cur.execute(QUERIES[2])
|
||||
except sqlite3.OperationalError:
|
||||
try:
|
||||
cur.execute(QUERIES[3])
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.info(
|
||||
"Error while reading the InteractionC table: %s", e
|
||||
)
|
||||
return None
|
||||
cur.execute(QUERIES[3])
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.info(
|
||||
"Error while reading the InteractionC table: %s", e
|
||||
)
|
||||
return None
|
||||
|
||||
names = [description[0] for description in cur.description]
|
||||
for item in cur:
|
||||
entry = {}
|
||||
for index, value in enumerate(item):
|
||||
if names[index] in self.timestamps:
|
||||
if value is None or isinstance(value, str):
|
||||
entry[names[index]] = value
|
||||
else:
|
||||
entry[names[index]] = convert_mactime_to_iso(value)
|
||||
else:
|
||||
names = [description[0] for description in cur.description]
|
||||
for item in cur:
|
||||
entry = {}
|
||||
for index, value in enumerate(item):
|
||||
if names[index] in self.timestamps:
|
||||
if value is None or isinstance(value, str):
|
||||
entry[names[index]] = value
|
||||
else:
|
||||
entry[names[index]] = convert_mactime_to_iso(value)
|
||||
else:
|
||||
entry[names[index]] = value
|
||||
|
||||
self.results.append(entry)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
self.results.append(entry)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
self.log.info("Extracted a total of %d InteractionC events", len(self.results))
|
||||
@@ -84,44 +84,48 @@ class LocationdClients(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_process(proc_name)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.high(
|
||||
f"Found a suspicious process name in LocationD entry {result['package']}",
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
if "BundleId" in result:
|
||||
ioc_match = self.indicators.check_process(result["BundleId"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.high(
|
||||
f"Found a suspicious process name in LocationD entry {result['package']}",
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if "BundlePath" in result:
|
||||
ioc_match = self.indicators.check_file_path(result["BundlePath"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.high(
|
||||
f"Found a known malicious domain in LocationD entry {result['package']}",
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
if "Executable" in result:
|
||||
ioc_match = self.indicators.check_file_path(result["Executable"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.high(
|
||||
f"Found a suspicious file path in LocationD entry {result['Executable']}",
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
if "Registered" in result:
|
||||
@@ -131,12 +135,13 @@ class LocationdClients(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_file_path(result["Registered"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.high(
|
||||
f"Found a suspicious file path in LocationD entry {result['Registered']}",
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
def _extract_locationd_entries(self, file_path):
|
||||
|
||||
@@ -64,6 +64,7 @@ class OSAnalyticsADDaily(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_process(result["package"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -65,6 +65,7 @@ class SafariBrowserState(IOSExtraction):
|
||||
if "tab_url" in result:
|
||||
ioc_match = self.indicators.check_url(result["tab_url"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
@@ -77,6 +78,7 @@ class SafariBrowserState(IOSExtraction):
|
||||
if "entry_url" in session_entry:
|
||||
ioc_match = self.indicators.check_url(session_entry["entry_url"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
@@ -90,87 +92,82 @@ class SafariBrowserState(IOSExtraction):
|
||||
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
tabs.title,
|
||||
tabs.url,
|
||||
tabs.user_visible_url,
|
||||
tabs.last_viewed_time,
|
||||
tab_sessions.session_data
|
||||
FROM tabs
|
||||
JOIN tab_sessions ON tabs.uuid = tab_sessions.tab_uuid
|
||||
ORDER BY tabs.last_viewed_time;
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
# Old version iOS <12 likely
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
tabs.title,
|
||||
tabs.url,
|
||||
tabs.user_visible_url,
|
||||
tabs.last_viewed_time,
|
||||
tab_sessions.session_data
|
||||
FROM tabs
|
||||
JOIN tab_sessions ON tabs.uuid = tab_sessions.tab_uuid
|
||||
ORDER BY tabs.last_viewed_time;
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
# Old version iOS <12 likely
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
title, url, user_visible_url, last_viewed_time, session_data
|
||||
title, url, user_visible_url, last_viewed_time, session_data
|
||||
FROM tabs
|
||||
ORDER BY last_viewed_time;
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.error(f"Error executing query: {e}")
|
||||
return
|
||||
)
|
||||
except sqlite3.OperationalError as e:
|
||||
self.log.error(f"Error executing query: {e}")
|
||||
|
||||
for row in cur:
|
||||
session_entries = []
|
||||
for row in cur:
|
||||
session_entries = []
|
||||
|
||||
if row[4]:
|
||||
# Skip a 4 byte header before the plist content.
|
||||
session_plist = row[4][4:]
|
||||
session_data = {}
|
||||
try:
|
||||
session_data = plistlib.load(io.BytesIO(session_plist))
|
||||
session_data = keys_bytes_to_string(session_data)
|
||||
except plistlib.InvalidFileException:
|
||||
pass
|
||||
if row[4]:
|
||||
# Skip a 4 byte header before the plist content.
|
||||
session_plist = row[4][4:]
|
||||
session_data = {}
|
||||
try:
|
||||
session_data = plistlib.load(io.BytesIO(session_plist))
|
||||
session_data = keys_bytes_to_string(session_data)
|
||||
except plistlib.InvalidFileException:
|
||||
pass
|
||||
|
||||
if "SessionHistoryEntries" in session_data.get("SessionHistory", {}):
|
||||
for session_entry in session_data["SessionHistory"].get(
|
||||
"SessionHistoryEntries"
|
||||
):
|
||||
self._session_history_count += 1
|
||||
if "SessionHistoryEntries" in session_data.get("SessionHistory", {}):
|
||||
for session_entry in session_data["SessionHistory"].get(
|
||||
"SessionHistoryEntries"
|
||||
):
|
||||
self._session_history_count += 1
|
||||
|
||||
data_length = 0
|
||||
if "SessionHistoryEntryData" in session_entry:
|
||||
data_length = len(
|
||||
session_entry.get("SessionHistoryEntryData")
|
||||
)
|
||||
|
||||
session_entries.append(
|
||||
{
|
||||
"entry_title": session_entry.get(
|
||||
"SessionHistoryEntryOriginalURL"
|
||||
),
|
||||
"entry_url": session_entry.get(
|
||||
"SessionHistoryEntryURL"
|
||||
),
|
||||
"data_length": data_length,
|
||||
}
|
||||
data_length = 0
|
||||
if "SessionHistoryEntryData" in session_entry:
|
||||
data_length = len(
|
||||
session_entry.get("SessionHistoryEntryData")
|
||||
)
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"tab_title": row[0],
|
||||
"tab_url": row[1],
|
||||
"tab_visible_url": row[2],
|
||||
"last_viewed_timestamp": convert_mactime_to_iso(row[3]),
|
||||
"session_data": session_entries,
|
||||
"safari_browser_state_db": os.path.relpath(
|
||||
db_path, self.target_path
|
||||
),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
session_entries.append(
|
||||
{
|
||||
"entry_title": session_entry.get(
|
||||
"SessionHistoryEntryOriginalURL"
|
||||
),
|
||||
"entry_url": session_entry.get(
|
||||
"SessionHistoryEntryURL"
|
||||
),
|
||||
"data_length": data_length,
|
||||
}
|
||||
)
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"tab_title": row[0],
|
||||
"tab_url": row[1],
|
||||
"tab_visible_url": row[2],
|
||||
"last_viewed_timestamp": convert_mactime_to_iso(row[3]),
|
||||
"session_data": session_entries,
|
||||
"safari_browser_state_db": os.path.relpath(
|
||||
db_path, self.target_path
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
if self.is_backup:
|
||||
|
||||
@@ -101,8 +101,9 @@ class SafariHistory(IOSExtraction):
|
||||
|
||||
if elapsed_time.seconds == 0:
|
||||
self.alertstore.medium(
|
||||
self.get_slug(),
|
||||
f"Redirect took less than a second! ({elapsed_ms} milliseconds)",
|
||||
convert_mactime_to_iso(result["timestamp"]),
|
||||
result["timestamp"],
|
||||
result,
|
||||
)
|
||||
|
||||
@@ -115,6 +116,7 @@ class SafariHistory(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_url(result["url"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -79,6 +79,7 @@ class Shortcuts(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_urls(result["action_urls"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -77,10 +77,12 @@ class SMS(IOSExtraction):
|
||||
"text", ""
|
||||
).startswith(alert_new):
|
||||
self.alertstore.high(
|
||||
f"Apple warning about state-sponsored attack received on {message['isodate']}",
|
||||
self.get_slug(),
|
||||
f"Apple warning about state-sponsored attack received on the {message['isodate']}",
|
||||
message["isodate"],
|
||||
message,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if not self.indicators:
|
||||
return
|
||||
@@ -92,6 +94,7 @@ class SMS(IOSExtraction):
|
||||
message_links = check_for_links(result.get("text", ""))
|
||||
ioc_match = self.indicators.check_urls(message_links)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
@@ -134,11 +137,6 @@ class SMS(IOSExtraction):
|
||||
"""
|
||||
)
|
||||
items = list(cur)
|
||||
elif "no such table" in str(exc):
|
||||
self.log.info(
|
||||
"No SMS tables found in the database, skipping: %s", exc
|
||||
)
|
||||
return
|
||||
else:
|
||||
raise exc
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from base64 import b64encode
|
||||
from typing import Optional
|
||||
|
||||
@@ -65,12 +64,8 @@ class SMSAttachments(IOSExtraction):
|
||||
if self.indicators:
|
||||
ioc_match = self.indicators.check_file_path(attachment["filename"])
|
||||
if ioc_match:
|
||||
self.alertstore.high(
|
||||
ioc_match.message,
|
||||
"",
|
||||
attachment,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
attachment["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.high(ioc_match.message, "", attachment)
|
||||
|
||||
if (
|
||||
attachment["filename"].startswith("/var/tmp/")
|
||||
@@ -78,6 +73,7 @@ class SMSAttachments(IOSExtraction):
|
||||
and attachment["direction"] == "received"
|
||||
):
|
||||
self.alertstore.medium(
|
||||
self.get_slug(),
|
||||
f"Suspicious iMessage attachment {attachment['filename']} on {attachment['isodate']}",
|
||||
attachment["isodate"],
|
||||
attachment,
|
||||
@@ -91,29 +87,21 @@ class SMSAttachments(IOSExtraction):
|
||||
return
|
||||
conn = self._open_sqlite_db(self.file_path)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
attachment.ROWID as "attachment_id",
|
||||
attachment.*,
|
||||
message.service as "service",
|
||||
handle.id as "phone_number"
|
||||
FROM attachment
|
||||
LEFT JOIN message_attachment_join ON
|
||||
message_attachment_join.attachment_id = attachment.ROWID
|
||||
LEFT JOIN message ON
|
||||
message.ROWID = message_attachment_join.message_id
|
||||
LEFT JOIN handle ON handle.ROWID = message.handle_id;
|
||||
cur.execute(
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
self.log.info(
|
||||
"No SMS attachment tables found in the database, skipping: %s", exc
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
return
|
||||
SELECT
|
||||
attachment.ROWID as "attachment_id",
|
||||
attachment.*,
|
||||
message.service as "service",
|
||||
handle.id as "phone_number"
|
||||
FROM attachment
|
||||
LEFT JOIN message_attachment_join ON
|
||||
message_attachment_join.attachment_id = attachment.ROWID
|
||||
LEFT JOIN message ON
|
||||
message.ROWID = message_attachment_join.message_id
|
||||
LEFT JOIN handle ON handle.ROWID = message.handle_id;
|
||||
"""
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
for item in cur:
|
||||
|
||||
@@ -68,9 +68,11 @@ class WebkitResourceLoadStatistics(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_url(result["registrable_domain"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
def _process_observations_db(self, db_path: str, domain: str, path: str) -> None:
|
||||
self.log.info(
|
||||
@@ -84,36 +86,32 @@ class WebkitResourceLoadStatistics(IOSExtraction):
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
try:
|
||||
# FIXME: table contains extra fields with timestamp here
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
domainID,
|
||||
registrableDomain,
|
||||
lastSeen,
|
||||
hadUserInteraction
|
||||
from ObservedDomains;
|
||||
# FIXME: table contains extra fields with timestamp here
|
||||
cur.execute(
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
SELECT
|
||||
domainID,
|
||||
registrableDomain,
|
||||
lastSeen,
|
||||
hadUserInteraction
|
||||
from ObservedDomains;
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
return
|
||||
|
||||
for row in cur:
|
||||
self.results.append(
|
||||
{
|
||||
"domain_id": row[0],
|
||||
"registrable_domain": row[1],
|
||||
"last_seen": row[2],
|
||||
"had_user_interaction": bool(row[3]),
|
||||
"last_seen_isodate": convert_unix_to_iso(row[2]),
|
||||
"domain": domain,
|
||||
"path": path,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
for row in cur:
|
||||
self.results.append(
|
||||
{
|
||||
"domain_id": row[0],
|
||||
"registrable_domain": row[1],
|
||||
"last_seen": row[2],
|
||||
"had_user_interaction": bool(row[3]),
|
||||
"last_seen_isodate": convert_unix_to_iso(row[2]),
|
||||
"domain": domain,
|
||||
"path": path,
|
||||
}
|
||||
)
|
||||
|
||||
if len(self.results) > 0:
|
||||
self.log.info(
|
||||
|
||||
@@ -89,6 +89,7 @@ class WebkitSessionResourceLog(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_urls(all_origins)
|
||||
if ioc_match:
|
||||
entry["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", entry, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ class Whatsapp(IOSExtraction):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_urls(result.get("links", []))
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
@@ -238,7 +238,7 @@ class NetBase(IOSExtraction):
|
||||
)
|
||||
|
||||
self.alertstore.medium(
|
||||
msg, proc["live_isodate"], proc
|
||||
self.get_slug(), msg, proc["live_isodate"], proc
|
||||
)
|
||||
|
||||
if not proc["live_proc_id"]:
|
||||
@@ -260,15 +260,18 @@ class NetBase(IOSExtraction):
|
||||
if result["live_proc_id"] not in missing_process_cache:
|
||||
missing_process_cache.add(result["live_proc_id"])
|
||||
self.alertstore.high(
|
||||
self.get_slug(),
|
||||
f"Found manipulated process entry {result['live_proc_id']}. Entry on {result['live_isodate']}",
|
||||
result["live_isodate"],
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
# Set manipulated proc timestamp so it appears in timeline.
|
||||
result["first_isodate"] = result["isodate"] = result["live_isodate"]
|
||||
result["proc_name"] = "MANIPULATED [process record deleted]"
|
||||
self.alertstore.high(
|
||||
self.get_slug(),
|
||||
f"Found manipulated process entry {result['live_proc_id']}/",
|
||||
result["first_isodate"],
|
||||
result,
|
||||
@@ -291,10 +294,12 @@ class NetBase(IOSExtraction):
|
||||
if proc_id not in all_proc_id:
|
||||
previous_proc = results_by_proc[last_proc_id]
|
||||
self.alertstore.low(
|
||||
self.get_slug(),
|
||||
f'Missing process {proc_id}. Previous process at "{previous_proc["first_isodate"]}" ({previous_proc["proc_name"]})',
|
||||
previous_proc["first_isodate"],
|
||||
previous_proc,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
missing_procs[proc_id] = {
|
||||
"proc_id": proc_id,
|
||||
@@ -346,9 +351,7 @@ class NetBase(IOSExtraction):
|
||||
|
||||
ioc_match = self.indicators.check_process(proc_name)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
result["first_isodate"],
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
self.get_slug(), ioc_match.message, result["first_isodate"], result
|
||||
)
|
||||
@@ -7,7 +7,7 @@ import pkgutil
|
||||
from logging import Logger
|
||||
from typing import Dict, Optional
|
||||
|
||||
from packaging import version as packaging_version
|
||||
import packaging
|
||||
|
||||
IPHONE_MODELS = json.loads(pkgutil.get_data("mvt", "ios/data/ios_models.json") or b"[]")
|
||||
IPHONE_IOS_VERSIONS = json.loads(
|
||||
@@ -49,8 +49,8 @@ def is_ios_version_outdated(version: str, log: Optional[Logger] = None) -> bool:
|
||||
if version == "":
|
||||
return False
|
||||
|
||||
latest_parsed = packaging_version.parse(latest_ios_version()["version"])
|
||||
current_parsed = packaging_version.parse(version)
|
||||
latest_parsed = packaging.version.parse(latest_ios_version()["version"])
|
||||
current_parsed = packaging.version.parse(version)
|
||||
if current_parsed < latest_parsed:
|
||||
if log:
|
||||
log.warning(
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import logging
|
||||
|
||||
from mvt.android.artifacts.dumpsys_battery_daily import DumpsysBatteryDailyArtifact
|
||||
from mvt.common.alerts import AlertLevel
|
||||
from mvt.common.indicators import Indicators
|
||||
|
||||
from ..utils import get_artifact
|
||||
@@ -36,36 +35,3 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
assert len(dba.alertstore.alerts) == 0
|
||||
dba.check_indicators()
|
||||
assert len(dba.alertstore.alerts) == 1
|
||||
|
||||
def test_uninstall_and_downgrade_create_medium_alerts(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
"""
|
||||
Daily from 2022-08-16-15-56-39 to 2022-08-17-01-15-45:
|
||||
Update com.example.app vers=10
|
||||
Update com.example.removed vers=0
|
||||
Daily from 2022-08-17-15-56-39 to 2022-08-18-01-15-45:
|
||||
Update com.example.app vers=9
|
||||
"""
|
||||
)
|
||||
|
||||
assert len(dba.results) == 3
|
||||
assert len(dba.alertstore.alerts) == 2
|
||||
|
||||
uninstall_alert, downgrade_alert = dba.alertstore.alerts
|
||||
assert uninstall_alert.level == AlertLevel.MEDIUM
|
||||
assert uninstall_alert.message == (
|
||||
"Detected uninstall of package com.example.removed (vers 0)"
|
||||
)
|
||||
assert uninstall_alert.event_time == "2022-08-16"
|
||||
assert uninstall_alert.event["package_name"] == "com.example.removed"
|
||||
assert uninstall_alert.event["vers"] == "0"
|
||||
|
||||
assert downgrade_alert.level == AlertLevel.MEDIUM
|
||||
assert downgrade_alert.message == (
|
||||
"Detected downgrade of package com.example.app from vers 10 to vers 9"
|
||||
)
|
||||
assert downgrade_alert.event_time == "2022-08-17"
|
||||
assert downgrade_alert.event["package_name"] == "com.example.app"
|
||||
assert downgrade_alert.event["action"] == "downgrade"
|
||||
assert downgrade_alert.event["previous_vers"] == "10"
|
||||
@@ -6,7 +6,6 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from mvt.common.indicators import Indicator, IndicatorMatch
|
||||
from mvt.common.module import run_module
|
||||
|
||||
from ..utils import get_android_androidqf, list_files
|
||||
@@ -73,38 +72,6 @@ class TestAndroidqfMountsArtifact:
|
||||
(("by-name/data" in s or "/data" in s) and "rw" in s) for s in concatenated
|
||||
), f"No data-like tokens (data + rw) found in parsed results: {concatenated}"
|
||||
|
||||
def test_mount_ioc_alert_uses_indicator(self):
|
||||
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
|
||||
|
||||
indicator = Indicator(
|
||||
value="/system",
|
||||
type="file_path",
|
||||
name="TestMalware",
|
||||
stix2_file_name="indicators.stix2",
|
||||
)
|
||||
m = MountsArtifact()
|
||||
m.indicators = type(
|
||||
"MountIndicators",
|
||||
(),
|
||||
{
|
||||
"check_file_path": lambda self, path: IndicatorMatch(
|
||||
ioc=indicator, message="matched file path"
|
||||
)
|
||||
if path == "/system"
|
||||
else None
|
||||
},
|
||||
)()
|
||||
|
||||
m.parse("/dev/block/by-name/system on /system type ext4 (rw,seclabel)")
|
||||
m.check_indicators()
|
||||
|
||||
indicator_alerts = [
|
||||
alert for alert in m.alertstore.alerts if alert.matched_indicator
|
||||
]
|
||||
assert len(indicator_alerts) == 1
|
||||
assert indicator_alerts[0].matched_indicator == indicator
|
||||
assert indicator_alerts[0].message == "matched file path"
|
||||
|
||||
|
||||
class TestAndroidqfMountsModule:
|
||||
def test_androidqf_module_no_mounts_file(self):
|
||||
|
||||
@@ -82,7 +82,7 @@ def generate_test_stix_file(file_path):
|
||||
for h in sha256:
|
||||
i = Indicator(
|
||||
indicator_types=["malicious-activity"],
|
||||
pattern="[file:hashes.'SHA-256'='{}']".format(h),
|
||||
pattern="[file:hashes.sha256='{}']".format(h),
|
||||
pattern_type="stix",
|
||||
)
|
||||
res.append(i)
|
||||
@@ -91,7 +91,7 @@ def generate_test_stix_file(file_path):
|
||||
for h in sha1:
|
||||
i = Indicator(
|
||||
indicator_types=["malicious-activity"],
|
||||
pattern="[file:hashes.'SHA-1'='{}']".format(h),
|
||||
pattern="[file:hashes.sha1='{}']".format(h),
|
||||
pattern_type="stix",
|
||||
)
|
||||
res.append(i)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2026 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/
|
||||
|
||||
from mvt.common.alerts import Alert, AlertLevel, AlertStore
|
||||
|
||||
|
||||
def test_as_json_promotes_nested_matched_indicator():
|
||||
indicator = {"value": "com.apple.weather", "type": "processes"}
|
||||
alertstore = AlertStore()
|
||||
alertstore.add(
|
||||
Alert(
|
||||
level=AlertLevel.CRITICAL,
|
||||
module="datausage",
|
||||
message="Matched indicator",
|
||||
event_time="2026-01-01 00:00:00",
|
||||
event={
|
||||
"proc_name": "WeatherWidget/com.apple.weather",
|
||||
"matched_indicator": indicator,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
alert = alertstore.as_json()[0]
|
||||
|
||||
assert alert["matched_indicator"] == indicator
|
||||
assert "matched_indicator" not in alert["event"]
|
||||
|
||||
|
||||
def test_as_json_removes_nested_matched_indicator_when_parent_exists():
|
||||
event_indicator = {"value": "nested", "type": "processes"}
|
||||
alert_indicator = {"value": "parent", "type": "processes"}
|
||||
alertstore = AlertStore()
|
||||
alertstore.add(
|
||||
Alert(
|
||||
level=AlertLevel.CRITICAL,
|
||||
module="manifest",
|
||||
message="Matched indicator",
|
||||
event_time="2026-01-01 00:00:00",
|
||||
event={"path": "/tmp/example", "matched_indicator": event_indicator},
|
||||
matched_indicator=alert_indicator,
|
||||
)
|
||||
)
|
||||
|
||||
alert = alertstore.as_json()[0]
|
||||
|
||||
assert alert["matched_indicator"] == alert_indicator
|
||||
assert "matched_indicator" not in alert["event"]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 json
|
||||
|
||||
from mvt.common.command import Command
|
||||
|
||||
|
||||
class TestCommand:
|
||||
def test_store_alerts_handles_bytes(self, tmp_path):
|
||||
cmd = Command(results_path=str(tmp_path))
|
||||
cmd.alertstore.medium(
|
||||
"bytes event",
|
||||
"",
|
||||
{"payload": b"\xa8\xa9"},
|
||||
)
|
||||
|
||||
cmd._store_alerts()
|
||||
|
||||
alerts = json.loads((tmp_path / "alerts.json").read_text())
|
||||
assert alerts[0]["event"]["payload"] == "\\xa8\\xa9"
|
||||
@@ -94,78 +94,6 @@ class TestIndicators:
|
||||
)
|
||||
assert ind.check_file_hash("da0611a300a9ce9aa7a09d1212f203fca5856794")
|
||||
|
||||
def test_parse_stix2_hash_key_variants(self, tmp_path):
|
||||
"""STIX2 spec requires single-quoted algorithm names that contain hyphens,
|
||||
e.g. file:hashes.'SHA-256'. Verify MVT accepts both spec-compliant and
|
||||
non-standard lowercase spellings for MD5, SHA-1 and SHA-256."""
|
||||
import json
|
||||
|
||||
sha256_hash = "570cd76bf49cf52e0cb347a68bdcf0590b2eaece134e1b1eba7e8d66261bdbe6"
|
||||
sha1_hash = "da0611a300a9ce9aa7a09d1212f203fca5856794"
|
||||
md5_hash = "d41d8cd98f00b204e9800998ecf8427e"
|
||||
|
||||
variants = [
|
||||
# (pattern_key, expected_bucket)
|
||||
("file:hashes.'SHA-256'", "files_sha256"),
|
||||
("file:hashes.SHA-256", "files_sha256"),
|
||||
("file:hashes.SHA256", "files_sha256"),
|
||||
("file:hashes.sha256", "files_sha256"),
|
||||
("file:hashes.'SHA-1'", "files_sha1"),
|
||||
("file:hashes.SHA-1", "files_sha1"),
|
||||
("file:hashes.SHA1", "files_sha1"),
|
||||
("file:hashes.sha1", "files_sha1"),
|
||||
("file:hashes.MD5", "files_md5"),
|
||||
("file:hashes.'MD5'", "files_md5"),
|
||||
("file:hashes.md5", "files_md5"),
|
||||
]
|
||||
|
||||
hash_for = {
|
||||
"files_sha256": sha256_hash,
|
||||
"files_sha1": sha1_hash,
|
||||
"files_md5": md5_hash,
|
||||
}
|
||||
|
||||
for pattern_key, bucket in variants:
|
||||
h = hash_for[bucket]
|
||||
stix = {
|
||||
"type": "bundle",
|
||||
"id": "bundle--test",
|
||||
"objects": [
|
||||
{
|
||||
"type": "malware",
|
||||
"id": "malware--test",
|
||||
"name": "TestMalware",
|
||||
"is_family": False,
|
||||
},
|
||||
{
|
||||
"type": "indicator",
|
||||
"id": "indicator--test",
|
||||
"indicator_types": ["malicious-activity"],
|
||||
"pattern": f"[{pattern_key}='{h}']",
|
||||
"pattern_type": "stix",
|
||||
"valid_from": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"type": "relationship",
|
||||
"id": "relationship--test",
|
||||
"relationship_type": "indicates",
|
||||
"source_ref": "indicator--test",
|
||||
"target_ref": "malware--test",
|
||||
},
|
||||
],
|
||||
}
|
||||
stix_file = tmp_path / "test.stix2"
|
||||
stix_file.write_text(json.dumps(stix))
|
||||
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([str(stix_file)], load_default=False)
|
||||
assert len(ind.ioc_collections[0][bucket]) == 1, (
|
||||
f"Pattern key '{pattern_key}' was not parsed into '{bucket}'"
|
||||
)
|
||||
assert ind.check_file_hash(h) is not None, (
|
||||
f"check_file_hash failed for pattern key '{pattern_key}'"
|
||||
)
|
||||
|
||||
def test_check_android_property(self, indicator_file):
|
||||
ind = Indicators(log=logging)
|
||||
ind.load_indicators_files([indicator_file], load_default=False)
|
||||
|
||||
@@ -36,15 +36,3 @@ class TestDatausageModule:
|
||||
alert for alert in m.alertstore.alerts if alert.level == AlertLevel.CRITICAL
|
||||
]
|
||||
assert len(critical_alerts) == 2
|
||||
assert all(
|
||||
"matched_indicator" not in alert.event for alert in critical_alerts
|
||||
)
|
||||
serialized_alerts = [
|
||||
alert
|
||||
for alert in m.alertstore.as_json()
|
||||
if alert["matched_indicator"] is not None
|
||||
]
|
||||
assert len(serialized_alerts) == 2
|
||||
assert all(
|
||||
"matched_indicator" not in alert["event"] for alert in serialized_alerts
|
||||
)
|
||||
@@ -3,36 +3,15 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import gc
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import run_module
|
||||
from mvt.ios.modules.base import IOSExtraction
|
||||
from mvt.ios.modules.backup.manifest import Manifest
|
||||
|
||||
from ..utils import get_ios_backup_folder
|
||||
|
||||
|
||||
class TestIOSExtraction:
|
||||
def test_get_backup_files_from_manifest_closes_connection(self):
|
||||
m = IOSExtraction(target_path=get_ios_backup_folder())
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always", ResourceWarning)
|
||||
files = list(m._get_backup_files_from_manifest(domain="CameraRollDomain"))
|
||||
gc.collect()
|
||||
|
||||
assert files
|
||||
assert not [
|
||||
warning
|
||||
for warning in caught
|
||||
if issubclass(warning.category, ResourceWarning)
|
||||
and "unclosed database" in str(warning.message)
|
||||
]
|
||||
|
||||
|
||||
class TestManifestModule:
|
||||
def test_manifest(self):
|
||||
m = Manifest(target_path=get_ios_backup_folder())
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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 logging
|
||||
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.ios.modules.fs.cache_files import CacheFiles
|
||||
|
||||
|
||||
class TestCacheFiles:
|
||||
def test_detection(self, indicator_file):
|
||||
m = CacheFiles(
|
||||
results={
|
||||
"Library/Caches/example/Cache.db": [
|
||||
{
|
||||
"entry_id": 1,
|
||||
"version": 1,
|
||||
"hash_value": 123,
|
||||
"storage_policy": 0,
|
||||
"url": "http://example.com/thisisbad",
|
||||
"isodate": "2026-01-01 00:00:00.000000",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
ind = Indicators(log=logging.getLogger())
|
||||
ind.parse_stix2(indicator_file)
|
||||
m.indicators = ind
|
||||
|
||||
m.check_indicators()
|
||||
|
||||
assert len(m.alertstore.alerts) == 1
|
||||
alert = m.alertstore.alerts[0]
|
||||
assert alert.event["cache_file"] == "Library/Caches/example/Cache.db"
|
||||
assert alert.event["url"] == "http://example.com/thisisbad"
|
||||
assert alert.matched_indicator is not None
|
||||
@@ -1,16 +0,0 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# 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/
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from mvt.android.cli import check_adb
|
||||
|
||||
|
||||
class TestCheckAndroidADBRemovedCommand:
|
||||
def test_check_adb_exits_nonzero(self):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_adb)
|
||||
|
||||
assert result.exit_code == 1
|
||||
Reference in new issue
Block a user