Compare commits

..
Author SHA1 Message Date
Donncha Ó Cearbhaill e9e621640b Close open archive (zip/tar) file handles 2025-10-06 10:07:16 +02:00
Donncha Ó Cearbhaill 05ad7d274c Fix profile events log line 2025-10-06 09:50:43 +02:00
Donncha Ó Cearbhaill 70d646af78 Quote STIX path in log line 2025-10-06 09:50:24 +02:00
Donncha Ó Cearbhaill 2d547662f8 Rework old detections tracking into stuctured alert levels 2025-02-19 23:46:03 +01:00
Donncha Ó Cearbhaill ca0bc46f11 Fix up, remove ADB module base 2025-02-16 00:30:45 +01:00
Donncha Ó Cearbhaill 1b03002a00 Major refactor to add structured alerting and typed indicators
This commit makes a structural change to MVT by changing binary
detected/not detected logic into a structured multi-level system
of alerts. This gives far more power to extend MVT and manage
alerts.

This commit also begins the process of adding proper typing for
key objects used in MVT including Indicators, IndicatorMatches,
and ModuleResults. This will also be keep to programmatically using
the output of MVT.
2025-02-16 00:16:34 +01:00
Donncha Ó Cearbhaill 6bac787cb5 Remove check-apk code and old dependencies 2025-02-16 00:00:09 +01:00
Donncha Ó Cearbhaill 064b9fbeb9 Remove check-adb command and update docs 2025-02-15 22:47:42 +01:00
Donncha Ó Cearbhaill 4c1cdf5129 Raise the proper NoAndroidQFBackup exception when a back-up isn't found 2025-02-11 15:04:48 +01:00
Donncha Ó Cearbhaill a08c24b02a Deduplicate modules which are run by the sub-commands. 2025-02-10 20:32:51 +01:00
Donncha Ó Cearbhaill 5d696350dc Run bugreport and backup modules during check-androidqf
Adding support to automatically run ADB backup and bugreport modules
automatically when running the check-androidqf command. This is a first
step to deduplicate the code for Android modules.
2025-02-10 19:28:20 +01:00
185 changed files with 2670 additions and 8385 deletions

No files matched your search

-13
View File
@@ -1,13 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "uv" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
cooldown:
default-days: 7
+8 -13
View File
@@ -1,8 +1,5 @@
name: Mypy name: Mypy
on: on: workflow_dispatch
pull_request:
branches: [main]
workflow_dispatch:
jobs: jobs:
mypy_py3: mypy_py3:
@@ -10,19 +7,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v6 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: 3.9
- name: Install uv cache: 'pip'
uses: astral-sh/setup-uv@v7 - name: Checkout
with: uses: actions/checkout@master
enable-cache: true
- name: Install Dependencies - name: Install Dependencies
run: | run: |
uv sync --locked --group dev pip install mypy
- name: mypy - name: mypy
run: | run: |
make mypy make mypy
+11 -29
View File
@@ -4,8 +4,6 @@ name: Create and publish a Docker image
# Configures this workflow to run every time a release is published. # Configures this workflow to run every time a release is published.
on: on:
workflow_dispatch: workflow_dispatch:
push:
branches: [main]
release: release:
types: [published] types: [published]
@@ -25,18 +23,9 @@ jobs:
attestations: write attestations: write
id-token: write id-token: write
# #
strategy:
matrix:
platform:
- dockerfile: "Dockerfile"
tag-suffix: ""
- dockerfile: "Dockerfile.ios"
tag-suffix: "-ios"
- dockerfile: "Dockerfile.android"
tag-suffix: "-android"
steps: steps:
- name: Checkout repository - 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. # 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 - name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 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. # 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 - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 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. # 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 `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. # 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 - name: Build and push Docker image
id: push id: push
uses: docker/build-push-action@v6 uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with: with:
file: ${{ matrix.platform.dockerfile }}
context: . context: .
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} 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
+7 -9
View File
@@ -11,19 +11,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v6 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: 3.9
- name: Install uv cache: 'pip'
uses: astral-sh/setup-uv@v7 - name: Checkout
with: uses: actions/checkout@master
enable-cache: true
- name: Install Dependencies - name: Install Dependencies
run: | run: |
uv sync --locked --group dev pip install ruff
- name: ruff - name: ruff
run: | run: |
make ruff make ruff
+6 -9
View File
@@ -12,21 +12,18 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] python-version: ['3.8', '3.9', '3.10'] # , '3.11']
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Python dependencies - name: Install Python dependencies
run: | run: |
uv sync --locked --group dev --python ${{ matrix.python-version }} make install
make test-requirements
- name: Test with pytest - name: Test with pytest
run: | run: |
set -o pipefail set -o pipefail
@@ -38,4 +35,4 @@ jobs:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
with: with:
pytest-coverage-path: ./pytest-coverage.txt pytest-coverage-path: ./pytest-coverage.txt
junitxml-path: ./pytest.xml junitxml-path: ./pytest.xml
+2 -3
View File
@@ -16,15 +16,14 @@ jobs:
- name: Run script to fetch latest iOS releases from Apple RSS feed. - name: Run script to fetch latest iOS releases from Apple RSS feed.
run: python3 .github/workflows/scripts/update-ios-releases.py run: python3 .github/workflows/scripts/update-ios-releases.py
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v8 uses: peter-evans/create-pull-request@v5
with: with:
title: '[auto] Update iOS releases and versions' title: '[auto] Update iOS releases and versions'
commit-message: Add new iOS versions and build numbers commit-message: Add new iOS versions and build numbers
branch: auto/add-new-ios-releases branch: auto/add-new-ios-releases
draft: true
body: | body: |
This is an automated pull request to update the iOS releases and version numbers. This is an automated pull request to update the iOS releases and version numbers.
add-paths: | add-paths: |
*.json *.json
labels: | labels: |
automated pr automated pr
+1 -4
View File
@@ -86,9 +86,6 @@ ipython_config.py
# pyenv # pyenv
.python-version .python-version
# uv project Python version
!.python-version
# pipenv # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # 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 # However, in case of collaboration, if having platform-specific dependencies or dependencies
@@ -141,4 +138,4 @@ dmypy.json
.idea .idea
# Sublime Text project files # Sublime Text project files
*.sublime* *.sublime*
-1
View File
@@ -1 +0,0 @@
3.10
+4 -6
View File
@@ -6,16 +6,14 @@
version: 2 version: 2
build: build:
os: "ubuntu-24.04" os: "ubuntu-22.04"
tools: tools:
python: "3.11" python: "3.11"
mkdocs: mkdocs:
configuration: mkdocs.yml configuration: mkdocs.yml
# Optionally set the version of Python and requirements required to build your docs
python: python:
install: install:
- method: uv - requirements: docs/requirements.txt
command: sync
groups:
- docs
+15 -11
View File
@@ -1,6 +1,6 @@
# Base image for building libraries # Base image for building libraries
# --------------------------------- # ---------------------------------
FROM ubuntu:22.04 AS build-base FROM ubuntu:22.04 as build-base
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -22,7 +22,7 @@ RUN apt-get update \
# libplist # libplist
# -------- # --------
FROM build-base AS build-libplist FROM build-base as build-libplist
# Build # Build
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \ 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 # libimobiledevice-glue
# --------------------- # ---------------------
FROM build-base AS build-libimobiledevice-glue FROM build-base as build-libimobiledevice-glue
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -45,7 +45,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd li
# libtatsu # libtatsu
# -------- # --------
FROM build-base AS build-libtatsu FROM build-base as build-libtatsu
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -58,7 +58,7 @@ RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
# libusbmuxd # libusbmuxd
# ---------- # ----------
FROM build-base AS build-libusbmuxd FROM build-base as build-libusbmuxd
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -72,7 +72,7 @@ RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
# libimobiledevice # libimobiledevice
# ---------------- # ----------------
FROM build-base AS build-libimobiledevice FROM build-base as build-libimobiledevice
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -88,7 +88,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimob
# usbmuxd # usbmuxd
# ------- # -------
FROM build-base AS build-usbmuxd FROM build-base as build-usbmuxd
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -103,8 +103,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
# Create main image # Create main image
FROM ubuntu:24.04 AS main FROM ubuntu:22.04 as main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re" LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re" LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -134,8 +133,13 @@ COPY --from=build-usbmuxd /build /
# Install mvt using the locally checked out source # Install mvt using the locally checked out source
COPY . mvt/ COPY . mvt/
RUN uv pip install --system --break-system-packages --no-cache ./mvt \ RUN apt-get update \
&& rm -rf mvt && apt-get install -y git python3-pip \
&& PIP_NO_CACHE_DIR=1 pip3 install --upgrade pip \
&& PIP_NO_CACHE_DIR=1 pip3 install ./mvt \
&& apt-get remove -y python3-pip git && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf mvt
# Installing ABE # Installing ABE
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
+2 -3
View File
@@ -1,6 +1,5 @@
# Create main image # Create main image
FROM python:3.10.14-alpine3.20 AS main FROM python:3.10.14-alpine3.20 as main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re" LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re" LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -21,7 +20,7 @@ RUN apk add --no-cache \
# Install mvt # Install mvt
COPY ./ mvt COPY ./ mvt
RUN apk add --no-cache --virtual .build-deps gcc musl-dev \ 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 && apk del .build-deps gcc musl-dev && rm -rf ./mvt
# Installing ABE # Installing ABE
+9 -10
View File
@@ -1,6 +1,6 @@
# Base image for building libraries # Base image for building libraries
# --------------------------------- # ---------------------------------
FROM ubuntu:22.04 AS build-base FROM ubuntu:22.04 as build-base
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -22,7 +22,7 @@ RUN apt-get update \
# libplist # libplist
# -------- # --------
FROM build-base AS build-libplist FROM build-base as build-libplist
# Build # Build
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \ 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 # libimobiledevice-glue
# --------------------- # ---------------------
FROM build-base AS build-libimobiledevice-glue FROM build-base as build-libimobiledevice-glue
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -45,7 +45,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd li
# libtatsu # libtatsu
# -------- # --------
FROM build-base AS build-libtatsu FROM build-base as build-libtatsu
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -58,7 +58,7 @@ RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
# libusbmuxd # libusbmuxd
# ---------- # ----------
FROM build-base AS build-libusbmuxd FROM build-base as build-libusbmuxd
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -72,7 +72,7 @@ RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
# libimobiledevice # libimobiledevice
# ---------------- # ----------------
FROM build-base AS build-libimobiledevice FROM build-base as build-libimobiledevice
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -88,7 +88,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimob
# usbmuxd # usbmuxd
# ------- # -------
FROM build-base AS build-usbmuxd FROM build-base as build-usbmuxd
# Install dependencies # Install dependencies
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
@@ -104,8 +104,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
# Main image # Main image
# ---------- # ----------
FROM python:3.10.14-alpine3.20 AS main FROM python:3.10.14-alpine3.20 as main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re" LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.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 # Install mvt using the locally checked out source
COPY ./ mvt COPY ./ mvt
RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \ 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 && apk del .build-deps git gcc musl-dev && rm -rf ./mvt
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ] ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
+16 -11
View File
@@ -1,39 +1,44 @@
PWD = $(shell pwd) PWD = $(shell pwd)
UV ?= uv
autofix:
ruff format .
ruff check --fix .
check: ruff mypy check: ruff mypy
ruff: ruff:
$(UV) run ruff check . ruff format --check .
ruff check -q .
mypy: mypy:
$(UV) run mypy mypy
test: test:
$(UV) run pytest python3 -m pytest
test-ci: test-ci:
$(UV) run pytest -v python3 -m pytest -v
install: install:
$(UV) sync python3 -m pip install --upgrade -e .
test-requirements: test-requirements:
$(UV) sync --group dev python3 -m pip install --upgrade -r test-requirements.txt
generate-proto-parsers: generate-proto-parsers:
# Generate python parsers for protobuf files # Generate python parsers for protobuf files
PROTO_FILES=$$(find src/mvt/android/parsers/proto/ -iname "*.proto"); \ 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: clean:
rm -rf $(PWD)/build $(PWD)/dist $(PWD)/src/mvt.egg-info rm -rf $(PWD)/build $(PWD)/dist $(PWD)/src/mvt.egg-info
dist: dist:
$(UV) build python3 -m pip install --upgrade build
python3 -m build
upload: upload:
$(UV) tool run twine upload dist/* python3 -m twine upload dist/*
test-upload: test-upload:
$(UV) tool run twine upload --repository testpypi dist/* python3 -m twine upload --repository testpypi dist/*
-20
View File
@@ -4,9 +4,6 @@
# Mobile Verification Toolkit # 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://img.shields.io/pypi/v/mvt)](https://pypi.org/project/mvt/) [![](https://img.shields.io/pypi/v/mvt)](https://pypi.org/project/mvt/)
[![Documentation Status](https://readthedocs.org/projects/mvt/badge/?version=latest)](https://docs.mvt.re/en/latest/?badge=latest) [![Documentation Status](https://readthedocs.org/projects/mvt/badge/?version=latest)](https://docs.mvt.re/en/latest/?badge=latest)
[![CI](https://github.com/mvt-project/mvt/actions/workflows/tests.yml/badge.svg)](https://github.com/mvt-project/mvt/actions/workflows/tests.yml) [![CI](https://github.com/mvt-project/mvt/actions/workflows/tests.yml/badge.svg)](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 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). 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).
@@ -60,11 +45,6 @@ For alternative installation options and known issues, please refer to the [docu
MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/) MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
Module-running `check-*` commands can load custom Python modules with
`--load-module PATH` or from a folder set in `MVT_CUSTOM_MODULES`. See the
[development documentation](https://docs.mvt.re/en/latest/development/) for
details.
## License ## License
+1 -58
View File
@@ -2,61 +2,4 @@
Thank you for your interest in reporting security issues and vulnerabilities! Security research is of utmost importance and we take all reports seriously. If you discover an issue please report it to us right away! Thank you for your interest in reporting security issues and vulnerabilities! Security research is of utmost importance and we take all reports seriously. If you discover an issue please report it to us right away!
Please DO NOT file a public issue, instead send your report privately to the MVT maintainers at Amnesty International via `security [at] amnesty [dot] tech`. Please DO NOT file a public issue, instead send your report privately to *nex [at] nex [dot] sx*. You can also write PGP-encrypted emails to [this key](https://keybase.io/nex/pgp_keys.asc?fingerprint=05216f3b86848a303c2fe37dd166f1667359d880).
You can also write PGP-encrypted emails to key `CFBF9698DCA8EB2A80F48ADEA035A030FA04ED13`. The corresponding PGP public key is lited below.
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGlFPwsBEADQ+d7SeHrFPYv3wPOjWs2oMpp0DPdfIyGbg+iYWOC36FegZhKY
+WeK96GqJWt8wD6kwFUVwQI795WZrjSd1q4a7wR+kj/h7xlRB6ZfVICA6O5DOOm6
GNMvqy7ESm8g1XZDpb2u1BXmSS9X8f6rjB0e86kYsF1mB5/2USTM63jgDs0GGTkZ
Q1z4Mq4gYyqH32b3gvXkbb68LeQmONUIM3cgmec9q8/pNc1l7fcoLWhOVADRj17Q
plisa/EUf/SYqdtk9w7EHGggNenKNwVM235mkPcMqmE72bTpjT6XCxvZY3ByG5yi
7L+tHJU45ZuXtt62EvX03azxThVfSmH/WbRk8lH8+CW8XMmiWZphG4ydPWqgVKCB
2UOXm+6CQnKA+7Dt1AeK2t5ciATrv9LvwgSxk5WKc3288XFLA6eGMrTdQygYlLjJ
+42RSdK/7fCt/qk4q13oUw8ZTVcCia98uZFi704XuuYTH6NrntIB7j/0oucIS4Y9
cTWNO5LBerez4v8VI4YHcYESPeIWGFkXhvJzo0VMg1zidBLtiPoGF2JKZGwaK7/p
yY1xALskLp4H+5OY4eB1kf8kl4vGsEK8xA/NNzOiapVmwBXpvVvmXIQJE2k+olNf
sAuyB8+aO1Ws7tFYt3D+olC7iaprOdK7uA4GCgmYYhq6QQPg+cxfczgHfwARAQAB
tD1TZWN1cml0eSBMYWIgYXQgQW1uZXN0eSBJbnRlcm5hdGlvbmFsIDxzZWN1cml0
eUBhbW5lc3R5LnRlY2g+iQJRBBMBCAA7FiEEz7+WmNyo6yqA9IreoDWgMPoE7RMF
AmlFPwsCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQoDWgMPoE7RNr
2w//a88uP90uSN6lgeIwKsHr1ri27QIBbzCV6hLN/gZBFR2uaiOn/xfFDbnR0Cjo
5nMCJCT1k4nrPbMTlfmWLCD+YKELBzVqWlw4J2SOg3nznPl2JrL8QBKjwts0sF+h
QbRWDsT54wBZnl6ZJJ79eLShNTokBbKnQ7071dMrENr5e2P2sClQXyiIc51ga4FM
fHyhsx+GsrdiZNd2AH8912ljW1GuEi3epTO7KMZprmr37mjpZSUToiV59Yhl1Gbo
2pixkYJqi62DG02/gTpCjq9NH3cEMxcxjh4E7yCA8ggLG6+IN6woIvPIdOsnQ+Yj
d3H4rMNBjPSKoL+bdHILkCnp5HokcbVjNY3QAyOAF4qWhk4GtgpTshwxUmb4Tbay
tWLJC2bzjuUBxLkGzMVFfU3B96sVS4Fi0sBaEMBtHskl2f45X8LJhSq//Lw/2L/8
34uP/RxDSn+DPvj/yqMpekdCcmeFSTX1A19xkPcc0rVhMRde4VL338R86vzh0gMI
1LySDAhXZyVWzrQ5s3n6N3EvCaHCn3qu7ieyFJifCSR7gZqevCEznMQRVpkMTzUt
rk13Z6NOOb4IlTW7HFoY3omJG8Z5jV4kMIE7n6nb0qpNYQiG+YvjenQ3VrMoISyh
lpS2De8+oOtwrxBVX3+qKWvQqzufeE3416kw2Z+5mxH7bx25Ag0EaUU/CwEQALyZ
b+kwLN1yHObTm2yDBEn5HbCT3H1GremvPNmbAaTnfrjUngoKa8MuWWzbX5ptgmZR
UpYY/ylOYcgGydz58vUNrPlhIZT9UhmiifPgZLEXyd0uFpr/NsbRajHMkK10iEZf
h5bHNobiB7pGCu4Uj9e1cMiIZ4yEaYeyXYUoNHf6ISP39mJhHy6ov5yIpm9q0wzm
tGUQPupxGXmEZlOPr3lxqXQ3Ekdv6cWDY5r/oOq71QJ/HUQ13QUuGFIbhnMbT8zd
zaS6f/v772YKsWPc4NNUhtlf25VnQ4FuUtjCe3p6iYP4OVD8gJm0GvXyvyTuiQbL
CSk/378JiNT7nZzYXxrWchMwvEoMIU55+/UaBc50HI5xvDQ858CX7PYGiimcdsO1
EkQzhVxRfjlILfWrC2lgt+H5qhTn4Fah250Xe1PnLjXGHVUQnY/f3MFeiWQgf92b
02+MfvOeC5OKttP1z5lcx6RFWCIa1E/u8Nj7YrH9hk0ZBRAnBaeAncDFY8dfX2zX
VMoc0dV16gM7RrZ6i7D3CG3eLLkQlX0jbW9dzTuG/3f098EWB1p8vOfS/RbNCBRX
jqGiqacL/aFF3Ci3nQ4O5tSv1XipbgrUhvXnwm9pxrLPS/45iaO59WN4RRGWLLQ7
LHmeBxoa9avv0SdBYUL+eBxY46GXb/j5VLzHYhSnABEBAAGJAjYEGAEIACAWIQTP
v5aY3KjrKoD0it6gNaAw+gTtEwUCaUU/CwIbDAAKCRCgNaAw+gTtEyvsEACnyFFD
alOZTrrJTXNnUejuiExLh+qTO3T91p5bte597jpwCZnYGwkxEfffsqqhlY6ftEOf
d5tNWE5isai4v8XCbplWomz4KBpepxcn2b+9o5dSyr1vohEFuCJziZDsta1J2DX5
IE9U48kTgLDfdIBhuOyHNRkvXRHP2OVLCaiw4d9q+hlrraR8pehHt2BJSxh+QZoe
n0iHvIZCBIUA45zLEGmXFpNTGeEf2dKPp3xOkAXOhAMPptE0V1itkF3R7kEW4aFO
SZo8L3C1aWSz/gQ4/vvW5t1IJxirNMUgTMQFvqEkAwX3fm6GCxlgRSvTTRXdcrS8
6qyFdH1nkCNsavPahN3N2RGGIlWtODEMTO1Hjy0kZtTYdW+JH9sendliCoJES+yN
DjM125SgdAgrqlSYm/g8n9knWpxZv1QM6jU/sVz1J+l6/ixugL2i+CAL2d6uv4tT
QmXnu7Ei4/2kHBUu3Lf59MNgmLHm6F7AhOWErszSeoJKsp+3yA1oTT/npz67sRzY
VVyxz4NBIollna59a1lz0RhlWzNKqNB27jhylyM4ltdzHB7r4VMAVJyttozmIIOC
35ucYxl5BHLuapaRSaYHdUId1LOccYyaOOFF/PSyCu9dKzXk7zEz2HNcIboWSkAE
8ZDExMYM4WVpVCOj+frdsaBvzItHacRWuijtkw==
=JAXX
-----END PGP PUBLIC KEY BLOCK-----
```
+16 -19
View File
@@ -1,29 +1,26 @@
# ADB Analysis Removed # Deprecation of ADB command in MVT
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. !!! warning
Use [AndroidQF](https://github.com/mvt-project/androidqf) to collect forensic artifacts from an Android device, then analyze the collected output with: The `mvt-android check-adb` command has been deprecated and removed from MVT.
```bash The ability to analyze Android devices over ADB (`mvt-android check-adb`) has been removed from MVT due to several technical and forensic limitations.
mvt-android check-androidqf /path/to/androidqf-output
```
For a standalone Android bug report, use: ## Reasons for Deprecation
```bash
mvt-android check-bugreport /path/to/bugreport.zip
```
## Reasons for Removal
1. **Inconsistent Data Collection Across Devices** 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** 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** 4. **Code Duplication and Difficulty Ensuring Consistent Behavior Across Sources**
Similar artifacts were parsed through separate ADB, AndroidQF, and bugreport modules, which made behavior harder to keep consistent. 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** 5. **Alignment with iOS Workflow**
MVT now focuses on analyzing acquired artifacts rather than interacting with live devices directly. 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)
+28
View File
@@ -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
```
-82
View File
@@ -1,82 +0,0 @@
# Check Android Intrusion Logs
Recent versions of Android can produce structured *Intrusion Logs* — newline-delimited JSON records derived from the platform's [SecurityLog API](https://developer.android.com/reference/android/app/admin/SecurityLog). Intrusion Logging is offered as a new option under Android's **Advanced Protection Mode**, which users can opt into on their device; no MDM or device-policy configuration is required. When enabled, these logs provide a high-fidelity record of process starts, DNS queries, outbound network connections, ADB activity, keyguard events, and other security-relevant operations. The initial Intrusion Logging feature was released for Android 16 in May 2026. The feature and supported events is likely to be expanded over time.
For background on how this data source was introduced and why it is forensically valuable, see the Amnesty International Security Lab announcement: [Android Intrusion Logging as a new source of data for consensual forensic analysis](https://securitylab.amnesty.org/latest/2026/05/android-intrusion-logging-as-a-new-source-of-data-for-consensual-forensic-analysis/).
## Recommended workflow: collect with AndroidQF
[AndroidQF](https://github.com/mvt-project/androidqf) is the recommended way to acquire data from an Android device for analysis with MVT. During acquisition AndroidQF will prompt the user to also collect intrusion logs from the device, and writes them into an `intrusion-logs/` subdirectory of the acquisition output.
When you analyse such an acquisition with `mvt-android check-androidqf`, MVT automatically detects the `intrusion-logs/` directory and runs the same intrusion-log checks described below — there is no need to invoke a separate command:
```bash
mvt-android check-androidqf --output /path/to/results/ /path/to/androidqf-output/
```
The device timezone is read from the AndroidQF acquisition (`getprop.txt`) and applied to event timestamps automatically.
## Standalone command: `check-intrusion-logs`
The `mvt-android check-intrusion-logs` command runs the intrusion-log analysis directly against a set of log files. Prefer the AndroidQF workflow above; use the standalone command when the intrusion logs were collected outside of an AndroidQF acquisition, or when re-analysing only a set of intrusion logs.
## Expected input
`check-intrusion-logs` accepts either:
- a **directory** containing one or more `.txt` files (recursively), or
- a **`.zip` archive** containing such `.txt` files (nested `.zip` archives are also walked).
Each `.txt` file is expected to contain newline-delimited JSON, with one JSON object per line. Each object wraps a single event under a top-level key indicating its type, for example:
```json
{"dns_event": {"event_time": 1746979200000, "hostname": "example.com", "ip_addresses": ["93.184.216.34"], "package_name": "com.example.app"}}
{"connect_event": {"event_time": 1746979201000, "ip_address": "93.184.216.34", "port": 443, "package_name": "com.example.app"}}
{"security_event": {"event_time": 1746979202000, "tag": 210005, "data": ["..."]}}
```
Identical events that appear across multiple overlapping log files (e.g. daily rotations) are de-duplicated on a first-seen basis.
## Running the analysis
```bash
mvt-android check-intrusion-logs --output /path/to/results/ /path/to/intrusion-logs/
```
A `.zip` archive can be passed directly in place of the directory:
```bash
mvt-android check-intrusion-logs --output /path/to/results/ /path/to/intrusion-logs.zip
```
### Options
| Option | Description |
| --- | --- |
| `-i, --iocs PATH` | Path to a STIX2 indicator file. May be passed multiple times. |
| `-o, --output PATH` | Directory where JSON results and the timeline CSV will be written. |
| `-l, --list-modules` | List the available intrusion-log modules and exit. |
| `-m, --module NAME` | Run a single module (e.g. `DnsEvent`) instead of all of them. |
| `-t, --timezone TZ` | IANA timezone name for the device (e.g. `Europe/Paris`). When set, event timestamps are converted to the device's local time instead of UTC. |
| `-v, --verbose` | Verbose logging. |
## Modules
The command runs the following modules over the parsed events:
- **`DnsEvent`** — DNS resolution events. Hostnames and resolved IP addresses are checked against domain indicators, and the requesting `package_name` is checked against app-identifier indicators.
- **`ConnectEvent`** — Outbound network connection events. Destination IPs (with localhost addresses skipped) are checked against domain indicators, and `package_name` is checked against app-identifier indicators.
- **`SecurityEvent`** — Security log events keyed by Android `SecurityLog` tag IDs (e.g. `app_process_start`, `adb_shell_cmd`, `keyguard_dismissed`, `os_startup`, `cert_*` events). These are surfaced in the timeline to help reconstruct device activity around suspected events.
All three modules share a single pre-parsing pass over the input, so adding more modules in the future does not multiply I/O cost. Additional modules will be added in the future to support new event types which are generated by the Intrusion Logging feature.
## Interpreting results
A successful IOC match raises a `CRITICAL` alert that includes the matched indicator, the offending event, and the event timestamp. Alerts are summarised at the end of the run and persisted alongside the per-module JSON results.
When `--timezone` is provided, timestamps in the timeline and JSON output reflect the device's local wall-clock time. Otherwise timestamps are in UTC, consistent with the rest of MVT.
## Limitations
- This page assumes the intrusion logs have already been collected from the device. The recommended collection path is via AndroidQF (see above); intrusion logging itself must have been enabled on the device beforehand by opting into Android's Advanced Protection mode and also enabling the optional Intrusion Logging feature (see the [Amnesty blog post](https://securitylab.amnesty.org/latest/2026/05/android-intrusion-logging-as-a-new-source-of-data-for-consensual-forensic-analysis/) for details).
- As with all IOC-based analysis, public indicators alone are not sufficient to conclude that a device is uncompromised. See the [Indicators of Compromise](../iocs.md) page for context.
+3 -9
View File
@@ -38,17 +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). For more information, refer to the [AndroidQF project documentation](https://github.com/mvt-project/androidqf).
## Android Intrusion Logs ## Check the device over Android Debug Bridge
On devices where the user has opted into Android's [**Advanced Protection Mode**](https://support.google.com/android/answer/16339980) and turned on the optional Intrusion Logging featrue, Android can create and archive structured *Intrusion Logs* in an encrypted format. These logs record DNS queries, outbound network connections, process starts, ADB activity and other security-relevant events, and are a high-fidelity complement to the rest of an AndroidQF acquisition. The logs are generated on-device and encrypted before being stored in the Google account associated with the device. The encryption key is protected by the user device PIN. The intrusion log data is not accessible to Google. The ability to analyze Android devices over ADB (`mvt-android check-adb`) has been removed from MVT.
AndroidQF will prompt the user to download, decrypt and collect device intrusion logs as part of an acquisition. When they are present, `mvt-android check-androidqf` will automatically run the intrusion-log checks alongside the other AndroidQF modules — no extra command is required. This is the recommended workflow for Android forensic analysis with MVT. See the [Android ADB documentation](./adb.md) for more information.
For cases where intrusion logs were collected outside of an AndroidQF acquisition, the standalone `mvt-android check-intrusion-logs` command can analyse them directly. See [Check Android Intrusion Logs](intrusion_logs.md) for details, and the [feature announcment from Amnesty International's Security Lab](https://securitylab.amnesty.org/latest/2026/05/android-intrusion-logging-as-a-new-source-of-data-for-consensual-forensic-analysis/) for background on the data source.
## Android Debug Bridge analysis removed
The ability to analyze Android devices directly over ADB has been removed from MVT. Direct extraction of data from ADB was error-prone and frequently resulted in inconsistent data collection between ADB and AndroidQF acquisitions. Use AndroidQF for device acquisition and `mvt-android check-androidqf` for analysis.
## Check an Android Backup (SMS messages) ## Check an Android Backup (SMS messages)
+2 -104
View File
@@ -2,17 +2,9 @@
The Mobile Verification Toolkit team welcomes contributions of new forensic modules or other contributions which help improve the software. 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 ## 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: be run automatically with:
```bash ```bash
@@ -21,100 +13,6 @@ make check
Run these tests before making new commits or opening pull requests. Run these tests before making new commits or opening pull requests.
## Module dependencies
Modules can require other modules to run first by declaring their classes in
`dependencies`. The command runner uses a stable topological ordering, so the
existing module list order is preserved wherever dependency constraints allow.
```python
class DependentModule(MVTModule):
dependencies = (PrerequisiteModule,)
def run(self):
prerequisite_results = self.get_dependency_results(PrerequisiteModule)
```
Selecting a single module also runs its transitive dependencies. If a dependency
is unavailable or the dependency graph contains a cycle, the command logs a
warning and does not run any modules.
## Custom modules
Module-running `check-*` commands can load custom modules from Python files that
are not installed as part of MVT. Load one file with:
```bash
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
```
You can also load a folder. MVT loads non-hidden top-level `*.py` files in
sorted order and skips `__init__.py`:
```bash
mvt-ios check-fs --load-module ./custom_modules ./filesystem-dump
```
Set `MVT_CUSTOM_MODULES` to load a folder for every module-running command. This
folder is loaded before any `--load-module` path:
```bash
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
```
Custom modules are normal `MVTModule` subclasses:
```python
from mvt.common.module import MVTModule
class ExampleCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"), ("ios", "check-fs"))
slug = "example_custom_module"
def run(self):
self.results = [{"message": "custom module ran"}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
Use `supported_commands` to restrict a module to specific platform/command
pairs. Missing or empty `supported_commands` means the module is available to
all commands, which keeps older modules compatible. Supported pairs are:
```python
("ios", "check-backup")
("ios", "check-fs")
("ios", "check-iocs")
("android", "check-backup")
("android", "check-bugreport")
("android", "check-androidqf")
("android", "check-intrusion-logs")
("android", "check-iocs")
```
Custom modules can depend on existing MVT module classes. Dependencies are
resolved with the same ordering logic as built-in modules, and custom modules
are appended after built-ins before ordering:
```python
from mvt.common.module import MVTModule
from mvt.ios.modules.backup.manifest import Manifest
class DependentCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"),)
dependencies = (Manifest,)
def run(self):
manifest_results = self.get_dependency_results(Manifest)
self.results = [{"manifest_entries": len(manifest_results)}]
```
## Profiling ## Profiling
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
@@ -126,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 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.
+1 -19
View File
@@ -34,25 +34,6 @@ It is also possible to load STIX2 files automatically from the environment varia
export MVT_STIX2="/home/user/IOC1.stix2:/home/user/IOC2.stix2" export MVT_STIX2="/home/user/IOC1.stix2:/home/user/IOC2.stix2"
``` ```
## Network Access
When checking URL indicators, MVT follows recognized shortened URLs with an
HTTP `HEAD` request. The following environment variables control these
requests:
- `MVT_NETWORK_ACCESS_ALLOWED` enables or disables network requests. It defaults
to `true`. Set it to `false` to prevent MVT from attempting to resolve
shortened URLs.
- `MVT_NETWORK_TIMEOUT` sets the request timeout in seconds. It defaults to
`15`.
For example, to run IOC checks without resolving shortened URLs:
```bash
MVT_NETWORK_ACCESS_ALLOWED=false mvt-ios check-iocs \
--iocs ~/iocs/malware.stix2 /path/to/iphone/output/
```
## STIX2 Support ## STIX2 Support
So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis-open.org/cti/stix/v2.1/csprd01/stix-v2.1-csprd01.html): So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis-open.org/cti/stix/v2.1/csprd01/stix-v2.1-csprd01.html):
@@ -74,3 +55,4 @@ You can automaticallly download the latest public indicator files with the comma
Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs. Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs.
+5
View File
@@ -0,0 +1,5 @@
mkdocs==1.6.1
mkdocs-autorefs==1.2.0
mkdocs-material==9.5.42
mkdocs-material-extensions==1.3.1
mkdocstrings==0.23.0
+2 -1
View File
@@ -42,8 +42,9 @@ nav:
- Records extracted by mvt-ios: "ios/records.md" - Records extracted by mvt-ios: "ios/records.md"
- MVT for Android: - MVT for Android:
- Android Forensic Methodology: "android/methodology.md" - Android Forensic Methodology: "android/methodology.md"
- Check over ADB: "android/adb.md"
- Check an Android Backup (SMS messages): "android/backup.md" - Check an Android Backup (SMS messages): "android/backup.md"
- Check Android Intrusion Logs: "android/intrusion_logs.md" - Download APKs: "android/download_apks.md"
- Indicators of Compromise: "iocs.md" - Indicators of Compromise: "iocs.md"
- Development: "development.md" - Development: "development.md"
- License: "license.md" - License: "license.md"
+42 -62
View File
@@ -1,11 +1,13 @@
[project] [project]
name = "mvt" name = "mvt"
dynamic = ["version"] dynamic = ["version"]
authors = [{ name = "Claudio Guarnieri", email = "nex@nex.sx" }] authors = [
{name = "Claudio Guarnieri", email = "nex@nex.sx"}
]
maintainers = [ maintainers = [
{ name = "Etienne Maynier", email = "tek@randhome.io" }, {name = "Etienne Maynier", email = "tek@randhome.io"},
{ name = "Donncha Ó Cearbhaill", email = "donncha.ocearbhaill@amnesty.org" }, {name = "Donncha Ó Cearbhaill", email = "donncha.ocearbhaill@amnesty.org"},
{ name = "Rory Flynn", email = "rory.flynn@amnesty.org" }, {name = "Rory Flynn", email = "rory.flynn@amnesty.org"}
] ]
description = "Mobile Verification Toolkit" description = "Mobile Verification Toolkit"
readme = "README.md" readme = "README.md"
@@ -14,86 +16,64 @@ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Information Technology", "Intended Audience :: Information Technology",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python", "Programming Language :: Python"
] ]
dependencies = [ dependencies = [
"click==8.3.3", "click >=8.1.3",
"rich==14.3.3", "rich >=12.6.0",
"tld==0.13.2", "tld >=0.12.6",
"requests==2.33.1", "requests >=2.28.1",
"simplejson==3.20.2", "simplejson >=3.17.6",
"packaging==26.2", "packaging >=21.3",
"appdirs==1.4.4", "appdirs >=1.4.4",
"iOSbackup==0.9.925", "iOSbackup >=0.9.923",
"adb-shell[usb]==0.4.4", "cryptography >=42.0.5",
"libusb1==3.3.1", "pyyaml >=6.0",
"cryptography==47.0.0", "pyahocorasick >= 2.0.0",
"PyYAML>=6.0.2", "betterproto >=1.2.0",
"pyahocorasick==2.3.1", "pydantic >= 2.10.0",
"betterproto2==0.9.1", "pydantic-settings >= 2.7.0",
"pydantic==2.13.3", 'backports.zoneinfo; python_version < "3.9"',
"pydantic-settings==2.14.0",
"NSKeyedUnArchiver==1.5.2",
"python-dateutil==2.9.0.post0",
"tzdata==2026.2",
] ]
requires-python = ">= 3.10" requires-python = ">= 3.8"
[project.urls] [project.urls]
homepage = "https://docs.mvt.re/en/latest/" homepage = "https://docs.mvt.re/en/latest/"
repository = "https://github.com/mvt-project/mvt" repository = "https://github.com/mvt-project/mvt"
[project.scripts] [project.scripts]
mvt-ios = "mvt.ios:cli" mvt-ios = "mvt.ios:cli"
mvt-android = "mvt.android:cli" mvt-android = "mvt.android:cli"
[dependency-groups]
dev = [
"requests>=2.31.0",
"pytest>=7.4.3",
"pytest-cov>=4.1.0",
"pytest-github-actions-annotate-failures>=0.2.0",
"pytest-mock>=3.14.0",
"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",
]
[build-system] [build-system]
requires = ["setuptools>=61.0"] requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.coverage.run] [tool.coverage.run]
omit = ["tests/*"] omit = [
"tests/*",
]
[tool.coverage.html] [tool.coverage.html]
directory = "htmlcov" directory= "htmlcov"
[tool.mypy] [tool.mypy]
install_types = false install_types = true
non_interactive = true
ignore_missing_imports = true ignore_missing_imports = true
packages = "src" packages = "src"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-ra -q --cov=mvt --cov-report html --junitxml=pytest.xml --cov-report=term-missing:skip-covered" addopts = "-ra -q --cov=mvt --cov-report html --junitxml=pytest.xml --cov-report=term-missing:skip-covered"
testpaths = ["tests"] testpaths = [
"tests"
]
[tool.ruff] [tool.ruff.lint]
lint.select = ["C90", "E", "F", "W"] # flake8 default set select = ["C90", "E", "F", "W"] # flake8 default set
lint.ignore = [ ignore = [
"E501", # don't enforce line length violations "E501", # don't enforce line length violations
"C901", # complex-structure "C901", # complex-structure
# These were previously ignored but don't seem to be required: # These were previously ignored but don't seem to be required:
# "E265", # no-space-after-block-comment # "E265", # no-space-after-block-comment
@@ -105,14 +85,14 @@ lint.ignore = [
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # unused-import "__init__.py" = ["F401"] # unused-import
[tool.ruff.lint.mccabe] [tool.ruff.lint.mccabe]
max-complexity = 10 max-complexity = 10
[tool.setuptools] [tool.setuptools]
include-package-data = true include-package-data = true
package-dir = { "" = "src" } package-dir = {"" = "src"}
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
@@ -121,4 +101,4 @@ where = ["src"]
mvt = ["ios/data/*.json"] mvt = ["ios/data/*.json"]
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = { attr = "mvt.common.version.MVT_VERSION" } version = {attr = "mvt.common.version.MVT_VERSION"}
+13 -29
View File
@@ -20,39 +20,23 @@ class AndroidArtifact(Artifact):
:param binary: whether the dumpsys should be pared as binary or not (bool) :param binary: whether the dumpsys should be pared as binary or not (bool)
:return: section extracted (string or bytes) :return: section extracted (string or bytes)
""" """
lines = []
in_section = False in_section = False
delimiter_str = "------------------------------------------------------------------------------" delimiter = "------------------------------------------------------------------------------"
delimiter_bytes = b"------------------------------------------------------------------------------"
if binary: if binary:
lines_bytes = [] delimiter = delimiter.encode("utf-8")
for line in dumpsys.splitlines(): # type: ignore[union-attr]
if line.strip() == separator: # type: ignore[arg-type]
in_section = True
continue
if not in_section: for line in dumpsys.splitlines():
continue if line.strip() == separator:
in_section = True
continue
if line.strip().startswith(delimiter_bytes): # type: ignore[arg-type] if not in_section:
break continue
lines_bytes.append(line) # type: ignore[arg-type] if line.strip().startswith(delimiter):
break
return b"\n".join(lines_bytes) # type: ignore[return-value,arg-type] lines.append(line)
else:
lines_str = []
for line in dumpsys.splitlines(): # type: ignore[union-attr]
if line.strip() == separator: # type: ignore[arg-type]
in_section = True
continue
if not in_section: return b"\n".join(lines) if binary else "\n".join(lines)
continue
if line.strip().startswith(delimiter_str): # type: ignore[arg-type]
break
lines_str.append(line) # type: ignore[arg-type]
return "\n".join(lines_str) # type: ignore[return-value,arg-type]
@@ -10,20 +10,15 @@ from .artifact import AndroidArtifact
class DumpsysAccessibilityArtifact(AndroidArtifact): class DumpsysAccessibilityArtifact(AndroidArtifact):
def check_indicators(self) -> None: def check_indicators(self) -> None:
for result in self.results: if not self.indicators:
if self.indicators: return
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
)
continue
self.alertstore.medium( for result in self.results:
f'Found accessibility service: "{result["service"]}"', ioc_match = self.indicators.check_app_id(result["package_name"])
"", if ioc_match:
result, result["matched_indicator"] = ioc_match.ioc
) self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
continue
def parse(self, content: str) -> None: def parse(self, content: str) -> None:
""" """
+10 -23
View File
@@ -4,7 +4,6 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import base64 import base64
import binascii
import hashlib import hashlib
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
@@ -84,22 +83,17 @@ class DumpsysADBArtifact(AndroidArtifact):
return keystore return keystore
@staticmethod @staticmethod
def calculate_key_info(user_key: bytes) -> dict: def calculate_key_info(user_key: bytes) -> str:
if b" " in user_key: if b" " in user_key:
key_base64, user = user_key.split(b" ", 1) key_base64, user = user_key.split(b" ", 1)
else: else:
key_base64, user = user_key, b"" key_base64, user = user_key, b""
try: key_raw = base64.b64decode(key_base64)
key_raw = base64.b64decode(key_base64) key_fingerprint = hashlib.md5(key_raw).hexdigest().upper()
key_fingerprint = hashlib.md5(key_raw).hexdigest().upper() key_fingerprint_colon = ":".join(
key_fingerprint_colon = ":".join( [key_fingerprint[i : i + 2] for i in range(0, len(key_fingerprint), 2)]
[key_fingerprint[i : i + 2] for i in range(0, len(key_fingerprint), 2)] )
)
except binascii.Error:
# Impossible to parse base64
key_fingerprint_colon = ""
return { return {
"user": user.decode("utf-8"), "user": user.decode("utf-8"),
"fingerprint": key_fingerprint_colon, "fingerprint": key_fingerprint_colon,
@@ -131,17 +125,10 @@ class DumpsysADBArtifact(AndroidArtifact):
) )
return return
start_of_json = content.find(b"\n{") # TODO: Parse AdbDebuggingManager line in output.
if start_of_json == -1: start_of_json = content.find(b"\n{") + 2
self.log.error("Unable to find ADB manager state in dumpsys output") end_of_json = content.rfind(b"}\n") - 2
return json_content = content[start_of_json:end_of_json].rstrip()
end_of_json = content.rfind(b"}\n")
if end_of_json == -1 or end_of_json <= start_of_json:
self.log.error("Unable to find complete ADB manager state in dumpsys output")
return
json_content = content[start_of_json + 2 : end_of_json - 2].rstrip()
parsed = self.indented_dump_parser(json_content) parsed = self.indented_dump_parser(json_content)
if parsed.get("debugging_manager") is None: if parsed.get("debugging_manager") is None:
+9 -6
View File
@@ -4,13 +4,13 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from datetime import datetime from datetime import datetime
from typing import Any
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
RISKY_PERMISSIONS = ["REQUEST_INSTALL_PACKAGES"] RISKY_PERMISSIONS = ["REQUEST_INSTALL_PACKAGES"]
RISKY_PACKAGES = ["com.android.shell"] RISKY_PACKAGES = ["com.android.shell"]
@@ -45,8 +45,9 @@ class DumpsysAppopsArtifact(AndroidArtifact):
if self.indicators: if self.indicators:
ioc_match = self.indicators.check_app_id(result.get("package_name")) ioc_match = self.indicators.check_app_id(result.get("package_name"))
if ioc_match: if ioc_match:
result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.get_slug(), ioc_match.message, "", result
) )
continue continue
@@ -65,6 +66,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
cleaned_result = result.copy() cleaned_result = result.copy()
cleaned_result["permissions"] = [perm] cleaned_result["permissions"] = [perm]
self.alertstore.medium( self.alertstore.medium(
self.get_slug(),
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}", f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
entry["timestamp"], entry["timestamp"],
cleaned_result, cleaned_result,
@@ -78,6 +80,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
cleaned_result = result.copy() cleaned_result = result.copy()
cleaned_result["permissions"] = [perm] cleaned_result["permissions"] = [perm]
self.alertstore.medium( self.alertstore.medium(
self.get_slug(),
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}", f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
entry["timestamp"], entry["timestamp"],
cleaned_result, cleaned_result,
@@ -85,9 +88,9 @@ class DumpsysAppopsArtifact(AndroidArtifact):
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
# self.results: List[Dict[str, Any]] = [] # self.results: List[Dict[str, Any]] = []
perm: dict[str, Any] = {} perm = {}
package: dict[str, Any] = {} package = {}
entry: dict[str, Any] = {} entry = {}
uid = None uid = None
in_packages = False in_packages = False
@@ -3,11 +3,9 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from typing import Any
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
from mvt.common.module_types import ModuleSerializedResult, ModuleAtomicResult
class DumpsysBatteryDailyArtifact(AndroidArtifact): class DumpsysBatteryDailyArtifact(AndroidArtifact):
@@ -16,23 +14,12 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
""" """
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
action = record.get("action", "update")
package_name = record["package_name"]
vers = record["vers"]
if vers == "0":
data = f"Recorded uninstall of package {package_name} (vers 0)"
elif action == "downgrade":
prev_vers = record.get("previous_vers", "unknown")
data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}"
else:
data = f"Recorded update of package {package_name} with vers {vers}"
return { return {
"timestamp": record["from"], "timestamp": record["from"],
"module": self.__class__.__name__, "module": self.__class__.__name__,
"event": "battery_daily", "event": "battery_daily",
"data": data, "data": f"Recorded update of package {record['package_name']} "
f"with vers {record['vers']}",
} }
def check_indicators(self) -> None: def check_indicators(self) -> None:
@@ -42,19 +29,17 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc_match = self.indicators.check_app_id(result["package_name"])
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
)
continue continue
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
daily = None daily = None
daily_updates: list[dict[str, Any]] = [] daily_updates = []
records: list[dict[str, Any]] = []
for line in output.splitlines(): for line in output.splitlines():
if line.startswith(" Daily from "): if line.startswith(" Daily from "):
if len(daily_updates) > 0: if len(daily_updates) > 0:
records.extend(daily_updates) self.results.extend(daily_updates)
daily_updates = [] daily_updates = []
timeframe = line[13:].strip() timeframe = line[13:].strip()
@@ -79,61 +64,15 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
break break
if not already_seen: if not already_seen:
update_record: dict[str, Any] = { daily_updates.append(
"action": "update", {
"from": daily["from"], "action": "update",
"to": daily["to"], "from": daily["from"],
"package_name": package_name, "to": daily["to"],
"vers": vers_nr, "package_name": package_name,
} "vers": vers_nr,
}
daily_updates.append(update_record) )
if len(daily_updates) > 0: if len(daily_updates) > 0:
records.extend(daily_updates) self.results.extend(daily_updates)
self._detect_uninstalls_and_downgrades(records)
self.results.extend(records)
def _detect_uninstalls_and_downgrades(
self, records: list[dict[str, Any]]
) -> None:
package_versions: dict[str, int] = {}
for record in sorted(
records,
key=lambda record: (
record["from"],
record["to"],
record["package_name"],
),
):
package_name = record["package_name"]
vers_nr = record["vers"]
if vers_nr == "0":
self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)",
record["from"],
record,
)
package_versions.pop(package_name, None)
continue
try:
current_vers = int(vers_nr)
except ValueError:
continue
previous_vers = package_versions.get(package_name)
if previous_vers is not None and current_vers < previous_vers:
record["action"] = "downgrade"
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}",
record["from"],
record,
)
package_versions[package_name] = current_vers
@@ -18,9 +18,8 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact):
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc_match = self.indicators.check_app_id(result["package_name"])
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
)
continue continue
def parse(self, data: str) -> None: def parse(self, data: str) -> None:
+2 -1
View File
@@ -22,8 +22,9 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
for part in path.split("/"): for part in path.split("/"):
ioc_match = self.indicators.check_app_id(part) ioc_match = self.indicators.check_app_id(part)
if ioc_match: if ioc_match:
result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.get_slug(), ioc_match.message, "", result
) )
continue continue
@@ -14,8 +14,9 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
for activity in self.results: for activity in self.results:
ioc_match = self.indicators.check_app_id(activity["package_name"]) ioc_match = self.indicators.check_app_id(activity["package_name"])
if ioc_match: if ioc_match:
activity["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", activity, matched_indicator=ioc_match.ioc self.get_slug(), ioc_match.message, "", activity
) )
continue continue
+12 -13
View File
@@ -7,24 +7,23 @@ import re
from typing import Any, Dict, List from typing import Any, Dict, List
from mvt.android.utils import ROOT_PACKAGES from mvt.android.utils import ROOT_PACKAGES
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
class DumpsysPackagesArtifact(AndroidArtifact): class DumpsysPackagesArtifact(AndroidArtifact):
def check_indicators(self) -> None: def check_indicators(self) -> None:
alerted_root_packages = set()
for result in self.results: for result in self.results:
# XXX: De-duplication Package detections
if result["package_name"] in ROOT_PACKAGES: if result["package_name"] in ROOT_PACKAGES:
if result["package_name"] in alerted_root_packages:
continue
alerted_root_packages.add(result["package_name"])
self.alertstore.medium( self.alertstore.medium(
self.get_slug(),
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"', f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
"", "",
result, result,
) )
self.alertstore.log_latest()
continue continue
if not self.indicators: if not self.indicators:
@@ -32,9 +31,9 @@ class DumpsysPackagesArtifact(AndroidArtifact):
ioc_match = self.indicators.check_app_id(result.get("package_name", "")) ioc_match = self.indicators.check_app_id(result.get("package_name", ""))
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
) self.alertstore.log_latest()
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
records = [] records = []
@@ -64,15 +63,15 @@ class DumpsysPackagesArtifact(AndroidArtifact):
""" """
Parse one entry of a dumpsys package information Parse one entry of a dumpsys package information
""" """
details: Dict[str, Any] = { details = {
"uid": "", "uid": "",
"version_name": "", "version_name": "",
"version_code": "", "version_code": "",
"timestamp": "", "timestamp": "",
"first_install_time": "", "first_install_time": "",
"last_update_time": "", "last_update_time": "",
"permissions": list(), "permissions": [],
"requested_permissions": list(), "requested_permissions": [],
} }
in_install_permissions = False in_install_permissions = False
in_runtime_permissions = False in_runtime_permissions = False
@@ -150,7 +149,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
results = [] results = []
package_name = None package_name = None
package = {} package = {}
lines: list[str] = [] lines = []
for line in output.splitlines(): for line in output.splitlines():
if line.startswith(" Package ["): if line.startswith(" Package ["):
if len(lines) > 0: if len(lines) > 0:
@@ -191,7 +190,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
package = [] package = []
in_package_list = False in_package_list = False
for line in content.splitlines(): for line in content.split("\n"):
if line.startswith("Packages:"): if line.startswith("Packages:"):
in_package_list = True in_package_list = True
continue continue
@@ -18,9 +18,8 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc_match = self.indicators.check_app_id(result["package_name"])
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
)
continue continue
def parse(self, data: str) -> None: def parse(self, data: str) -> None:
@@ -52,16 +52,14 @@ class DumpsysReceiversArtifact(AndroidArtifact):
ioc_match = self.indicators.check_app_id(receiver["package_name"]) ioc_match = self.indicators.check_app_id(receiver["package_name"])
if ioc_match: if ioc_match:
receiver["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, self.get_slug(), ioc_match.message, "", {intent: receiver}
"",
{intent: receiver},
matched_indicator=ioc_match.ioc,
) )
continue continue
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
self.results: dict[str, list[dict[str, str]]] = {} self.results = {}
in_receiver_resolver_table = False in_receiver_resolver_table = False
in_non_data_actions = False in_non_data_actions = False
+6 -8
View File
@@ -39,10 +39,10 @@ class GetProp(AndroidArtifact):
if not matches or len(matches[0]) != 2: if not matches or len(matches[0]) != 2:
continue continue
prop_entry = {"name": matches[0][0], "value": matches[0][1]} entry = {"name": matches[0][0], "value": matches[0][1]}
self.results.append(prop_entry) self.results.append(entry)
def get_device_timezone(self) -> str | None: def get_device_timezone(self) -> str:
""" """
Get the device timezone from the getprop results Get the device timezone from the getprop results
@@ -60,8 +60,7 @@ class GetProp(AndroidArtifact):
if entry["name"] == "ro.build.version.security_patch": if entry["name"] == "ro.build.version.security_patch":
warning_message = warn_android_patch_level(entry["value"], self.log) warning_message = warn_android_patch_level(entry["value"], self.log)
if isinstance(warning_message, str): self.alertstore.medium(self.get_slug(), warning_message, "", entry)
self.alertstore.medium(warning_message, "", entry)
if not self.indicators: if not self.indicators:
return return
@@ -71,6 +70,5 @@ class GetProp(AndroidArtifact):
result.get("name", "") result.get("name", "")
) )
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
)
-197
View File
@@ -1,197 +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 typing import Any
from .artifact import AndroidArtifact
SUSPICIOUS_MOUNT_POINTS = [
"/system",
"/vendor",
"/product",
"/system_ext",
]
SUSPICIOUS_OPTIONS = [
"rw",
"remount",
"noatime",
"nodiratime",
]
ALLOWLIST_NOATIME = [
"/system_dlkm",
"/system_ext",
"/product",
"/vendor",
"/vendor_dlkm",
]
class Mounts(AndroidArtifact):
"""
This artifact parses mount information from /proc/mounts or similar mount data.
It can detect potentially suspicious mount configurations that may indicate
a rooted or compromised device.
"""
def parse(self, entry: str) -> None:
"""
Parse mount information from the provided entry.
Examples:
/dev/block/bootdevice/by-name/system /system ext4 ro,seclabel,relatime 0 0
/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)
"""
self.results: list[dict[str, Any]] = []
for line in entry.splitlines():
line = line.strip()
if not line:
continue
device = None
mount_point = None
filesystem_type = None
mount_options = ""
if " on " in line and " type " in line:
try:
# Format: device on mount_point type filesystem_type (options)
device_part, rest = line.split(" on ", 1)
device = device_part.strip()
# Split by 'type' to get mount_point and filesystem info
mount_part, fs_part = rest.split(" type ", 1)
mount_point = mount_part.strip()
# Parse filesystem and options
if "(" in fs_part and fs_part.endswith(")"):
# Format: filesystem_type (options)
fs_and_opts = fs_part.strip()
paren_idx = fs_and_opts.find("(")
filesystem_type = fs_and_opts[:paren_idx].strip()
mount_options = fs_and_opts[paren_idx + 1 : -1].strip()
else:
# No options in parentheses, just filesystem type
filesystem_type = fs_part.strip()
mount_options = ""
# Skip if we don't have essential info
if not device or not mount_point or not filesystem_type:
continue
# Parse options into list
options_list = (
[opt.strip() for opt in mount_options.split(",") if opt.strip()]
if mount_options
else []
)
# Check if it's a system partition
is_system_partition = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
mount_point.startswith(sp) for sp in SUSPICIOUS_MOUNT_POINTS
)
# Check if it's mounted read-write
is_read_write = "rw" in options_list
mount_entry = {
"device": device,
"mount_point": mount_point,
"filesystem_type": filesystem_type,
"mount_options": mount_options,
"options_list": options_list,
"is_system_partition": is_system_partition,
"is_read_write": is_read_write,
}
self.results.append(mount_entry)
except ValueError:
# If parsing fails, skip this line
continue
else:
# Skip lines that don't match expected format
continue
def check_indicators(self) -> None:
"""
Check for suspicious mount configurations that may indicate root access
or other security concerns.
"""
system_rw_mounts = []
suspicious_mounts = []
for mount in self.results:
mount_point = mount["mount_point"]
options = mount["options_list"]
# Check for system partitions mounted as read-write
if mount["is_system_partition"] and mount["is_read_write"]:
system_rw_mounts.append(mount)
if mount_point == "/system":
self.alertstore.high(
"Root detected /system partition is mounted as read-write (rw)",
"",
mount,
)
else:
self.alertstore.high(
f"System partition {mount_point} is mounted as read-write (rw). This may indicate system modifications.",
"",
mount,
)
# Check for other suspicious mount options
suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS]
if suspicious_opts and mount["is_system_partition"]:
if (
"noatime" in mount["mount_options"]
and mount["mount_point"] in ALLOWLIST_NOATIME
):
continue
suspicious_mounts.append(mount)
self.alertstore.medium(
f"Suspicious mount options found for {mount_point}: {', '.join(suspicious_opts)}",
"",
mount,
)
# Log interesting mount information
if mount_point == "/data" or mount_point.startswith("/sdcard"):
self.log.info(
"Data partition: %s mounted as %s with options: %s",
mount_point,
mount["filesystem_type"],
mount["mount_options"],
)
self.log.info("Parsed %d mount entries", len(self.results))
# Check indicators if available
if not self.indicators:
return
for mount in self.results:
# Check if any mount points match indicators
ioc = self.indicators.check_file_path(mount.get("mount_point", ""))
if ioc:
self.alertstore.critical(
ioc.message,
"",
mount,
matched_indicator=ioc.ioc,
)
# Check device paths for indicators
ioc = self.indicators.check_file_path(mount.get("device", ""))
if ioc:
self.alertstore.critical(
ioc.message,
"",
mount,
matched_indicator=ioc.ioc,
)
+5 -7
View File
@@ -8,7 +8,7 @@ from .artifact import AndroidArtifact
class Processes(AndroidArtifact): class Processes(AndroidArtifact):
def parse(self, entry: str) -> None: def parse(self, entry: str) -> None:
for line in entry.splitlines()[1:]: for line in entry.split("\n")[1:]:
proc = line.split() proc = line.split()
# Skip empty lines # Skip empty lines
@@ -60,13 +60,11 @@ class Processes(AndroidArtifact):
ioc_match = self.indicators.check_app_id(proc_name) ioc_match = self.indicators.check_app_id(proc_name)
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
)
continue continue
ioc_match = self.indicators.check_process(proc_name) ioc_match = self.indicators.check_process(proc_name)
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
)
+11 -9
View File
@@ -51,6 +51,11 @@ ANDROID_DANGEROUS_SETTINGS = [
"key": "send_action_app_error", "key": "send_action_app_error",
"safe_value": "1", "safe_value": "1",
}, },
{
"description": "enabled installation of non Google Play apps",
"key": "install_non_market_apps",
"safe_value": "0",
},
{ {
"description": "enabled accessibility services", "description": "enabled accessibility services",
"key": "accessibility_enabled", "key": "accessibility_enabled",
@@ -67,14 +72,11 @@ class Settings(AndroidArtifact):
# Check if one of the dangerous settings is using an unsafe # Check if one of the dangerous settings is using an unsafe
# value (different than the one specified). # value (different than the one specified).
if danger["key"] == key and danger["safe_value"] != value: if danger["key"] == key and danger["safe_value"] != value:
self.alertstore.medium( self.log.warning(
f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})', 'Found suspicious "%s" setting "%s = %s" (%s)',
"", namespace,
{ key,
"namespace": namespace, value,
"key": key, danger["description"],
"value": value,
"description": danger["description"],
},
) )
break break
+69 -64
View File
@@ -7,15 +7,14 @@ import datetime
from typing import List, Optional from typing import List, Optional
import pydantic import pydantic
import betterproto2 import betterproto
from dateutil import parser
from mvt.android.parsers.proto.tombstone import Tombstone
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.android.parsers.proto.tombstone import Tombstone
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
TOMBSTONE_DELIMITER = "*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***" TOMBSTONE_DELIMITER = "*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***"
# Map the legacy crash file keys to the new format. # Map the legacy crash file keys to the new format.
@@ -54,7 +53,7 @@ class TombstoneCrashResult(pydantic.BaseModel):
file_name: str file_name: str
file_timestamp: str # We store the timestamp as a string to avoid timezone issues file_timestamp: str # We store the timestamp as a string to avoid timezone issues
build_fingerprint: str build_fingerprint: str
revision: str revision: int
arch: Optional[str] = None arch: Optional[str] = None
timestamp: str # We store the timestamp as a string to avoid timezone issues timestamp: str # We store the timestamp as a string to avoid timezone issues
process_uptime: Optional[int] = None process_uptime: Optional[int] = None
@@ -64,14 +63,14 @@ class TombstoneCrashResult(pydantic.BaseModel):
process_name: Optional[str] = None process_name: Optional[str] = None
binary_path: Optional[str] = None binary_path: Optional[str] = None
selinux_label: Optional[str] = None selinux_label: Optional[str] = None
uid: int uid: Optional[int] = None
signal_info: SignalInfo signal_info: SignalInfo
cause: Optional[str] = None cause: Optional[str] = None
extra: Optional[str] = None extra: Optional[str] = None
class TombstoneCrashArtifact(AndroidArtifact): class TombstoneCrashArtifact(AndroidArtifact):
""" """ "
Parser for Android tombstone crash files. Parser for Android tombstone crash files.
This parser can parse both text and protobuf tombstone crash files. This parser can parse both text and protobuf tombstone crash files.
@@ -95,17 +94,17 @@ class TombstoneCrashArtifact(AndroidArtifact):
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_process(result["process_name"]) ioc_match = self.indicators.check_process(result["process_name"])
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
)
continue continue
if result.get("command_line", []): if result.get("command_line", []):
command_name = result["command_line"][0].split("/")[-1] command_name = result.get("command_line")[0].split("/")[-1]
ioc_match = self.indicators.check_process(command_name) ioc_match = self.indicators.check_process(command_name)
if ioc_match: if ioc_match:
result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.get_slug(), ioc_match.message, "", result
) )
continue continue
@@ -116,6 +115,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
] ]
if result["uid"] in SUSPICIOUS_UIDS: if result["uid"] in SUSPICIOUS_UIDS:
self.alertstore.medium( self.alertstore.medium(
self.get_slug(),
( (
f"Potentially suspicious crash in process '{result['process_name']}' " f"Potentially suspicious crash in process '{result['process_name']}' "
f"running as UID '{result['uid']}' in tombstone '{result['file_name']}' at {result['timestamp']}" f"running as UID '{result['uid']}' in tombstone '{result['file_name']}' at {result['timestamp']}"
@@ -127,11 +127,11 @@ class TombstoneCrashArtifact(AndroidArtifact):
def parse_protobuf( def parse_protobuf(
self, file_name: str, file_timestamp: datetime.datetime, data: bytes self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> None: ) -> None:
"""Parse Android tombstone crash files from a protobuf object.""" """
Parse Android tombstone crash files from a protobuf object.
"""
tombstone_pb = Tombstone().parse(data) tombstone_pb = Tombstone().parse(data)
tombstone_dict = tombstone_pb.to_dict( tombstone_dict = tombstone_pb.to_dict(betterproto.Casing.SNAKE)
casing=betterproto2.Casing.SNAKE, include_default_values=True
)
# Add some extra metadata # Add some extra metadata
tombstone_dict["timestamp"] = self._parse_timestamp_string( tombstone_dict["timestamp"] = self._parse_timestamp_string(
@@ -148,23 +148,21 @@ class TombstoneCrashArtifact(AndroidArtifact):
def parse( def parse(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> None: ) -> None:
"""Parse text Android tombstone crash files.""" """
Parse text Android tombstone crash files.
"""
# Split the tombstone file into a dictonary
tombstone_dict = { tombstone_dict = {
"file_name": file_name, "file_name": file_name,
"file_timestamp": convert_datetime_to_iso(file_timestamp), "file_timestamp": convert_datetime_to_iso(file_timestamp),
} }
lines = content.decode("utf-8").splitlines() lines = content.decode("utf-8").splitlines()
for line_num, line in enumerate(lines, 1): for line in lines:
if not line.strip() or TOMBSTONE_DELIMITER in line: if not line.strip() or TOMBSTONE_DELIMITER in line:
continue continue
try: for key, destination_key in TOMBSTONE_TEXT_KEY_MAPPINGS.items():
for key, destination_key in TOMBSTONE_TEXT_KEY_MAPPINGS.items(): self._parse_tombstone_line(line, key, destination_key, tombstone_dict)
if self._parse_tombstone_line(
line, key, destination_key, tombstone_dict
):
break
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {str(e)}")
# Validate the tombstone and add it to the results # Validate the tombstone and add it to the results
tombstone = TombstoneCrashResult.model_validate(tombstone_dict) tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
@@ -174,7 +172,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
self, line: str, key: str, destination_key: str, tombstone: dict self, line: str, key: str, destination_key: str, tombstone: dict
) -> bool: ) -> bool:
if not line.startswith(f"{key}"): if not line.startswith(f"{key}"):
return False return None
if key == "pid": if key == "pid":
return self._load_pid_line(line, tombstone) return self._load_pid_line(line, tombstone)
@@ -193,63 +191,64 @@ class TombstoneCrashArtifact(AndroidArtifact):
raise ValueError(f"Expected key {key}, got {line_key}") raise ValueError(f"Expected key {key}, got {line_key}")
value_clean = value.strip().strip("'") value_clean = value.strip().strip("'")
if destination_key == "uid": if destination_key in ["uid", "revision"]:
tombstone[destination_key] = int(value_clean) tombstone[destination_key] = int(value_clean)
elif destination_key == "process_uptime": elif destination_key == "process_uptime":
# eg. "Process uptime: 40s" # eg. "Process uptime: 40s"
tombstone[destination_key] = int(value_clean.rstrip("s")) tombstone[destination_key] = int(value_clean.rstrip("s"))
elif destination_key == "command_line": elif destination_key == "command_line":
# Wrap in list for consistency with protobuf format (repeated string). # XXX: Check if command line should be a single string in a list, or a list of strings.
tombstone[destination_key] = [value_clean] tombstone[destination_key] = [value_clean]
else: else:
tombstone[destination_key] = value_clean tombstone[destination_key] = value_clean
return True return True
def _load_pid_line(self, line: str, tombstone: dict) -> bool: def _load_pid_line(self, line: str, tombstone: dict) -> bool:
try: pid_part, tid_part, name_part = [part.strip() for part in line.split(",")]
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
process_info = parts[0]
# Parse pid, tid, name from process info pid_key, pid_value = pid_part.split(":", 1)
info_parts = [p.strip() for p in process_info.split(",")] if pid_key != "pid":
for info in info_parts: raise ValueError(f"Expected key pid, got {pid_key}")
key, value = info.split(":", 1) pid_value = int(pid_value.strip())
key = key.strip()
value = value.strip()
if key == "pid": tid_key, tid_value = tid_part.split(":", 1)
tombstone["pid"] = int(value) if tid_key != "tid":
elif key == "tid": raise ValueError(f"Expected key tid, got {tid_key}")
tombstone["tid"] = int(value) tid_value = int(tid_value.strip())
elif key == "name":
tombstone["process_name"] = value
# Extract binary path if it exists name_key, name_value = name_part.split(":", 1)
if len(parts) > 1: if name_key != "name":
tombstone["binary_path"] = parts[1].strip().rstrip(" <") raise ValueError(f"Expected key name, got {name_key}")
name_value = name_value.strip()
process_name, binary_path = self._parse_process_name(name_value, tombstone)
return True tombstone["pid"] = pid_value
tombstone["tid"] = tid_value
tombstone["process_name"] = process_name
tombstone["binary_path"] = binary_path
return True
except Exception as e: def _parse_process_name(self, process_name_part, tombstone: dict) -> bool:
raise ValueError(f"Failed to parse PID line: {str(e)}") process_name, process_path = process_name_part.split(">>>")
process_name = process_name.strip()
binary_path = process_path.strip().split(" ")[0]
return process_name, binary_path
def _load_signal_line(self, line: str, tombstone: dict) -> bool: def _load_signal_line(self, line: str, tombstone: dict) -> bool:
signal_part, code_part = map(str.strip, line.split(",")[:2]) signal, code, _ = [part.strip() for part in line.split(",", 2)]
signal = signal.split("signal ")[1]
signal_code, signal_name = signal.split(" ")
signal_name = signal_name.strip("()")
def parse_part(part: str, prefix: str) -> tuple[int, str]: code_part = code.split("code ")[1]
match = part.split(prefix)[1] code_number, code_name = code_part.split(" ")
number = int(match.split()[0]) code_name = code_name.strip("()")
name = match.split("(")[1].split(")")[0] if "(" in match else "UNKNOWN"
return number, name
signal_number, signal_name = parse_part(signal_part, "signal ")
code_number, code_name = parse_part(code_part, "code ")
tombstone["signal_info"] = { tombstone["signal_info"] = {
"code": code_number, "code": int(code_number),
"code_name": code_name, "code_name": code_name,
"name": signal_name, "name": signal_name,
"number": signal_number, "number": int(signal_code),
} }
return True return True
@@ -260,8 +259,14 @@ class TombstoneCrashArtifact(AndroidArtifact):
@staticmethod @staticmethod
def _parse_timestamp_string(timestamp: str) -> str: def _parse_timestamp_string(timestamp: str) -> str:
timestamp_parsed = parser.parse(timestamp) timestamp_date, timezone = timestamp.split("+")
# Preserve the source wall-clock time while returning the project-wide ISO format. # Truncate microseconds before parsing
timestamp_without_micro = timestamp_date.split(".")[0] + "+" + timezone
timestamp_parsed = datetime.datetime.strptime(
timestamp_without_micro, "%Y-%m-%d %H:%M:%S%z"
)
# HACK: Swap the local timestamp to UTC, so keep the original time and avoid timezone conversion.
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc) local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
return convert_datetime_to_iso(local_timestamp) return convert_datetime_to_iso(local_timestamp)
+17 -197
View File
@@ -9,41 +9,35 @@ import click
from mvt.common.cmd_check_iocs import CmdCheckIOCS from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.help import ( from mvt.common.help import (
HELP_MSG_VERSION,
HELP_MSG_OUTPUT,
HELP_MSG_VERBOSE,
HELP_MSG_IOC,
HELP_MSG_LIST_MODULES,
HELP_MSG_MODULE,
HELP_MSG_NONINTERACTIVE,
HELP_MSG_ANDROID_BACKUP_PASSWORD, HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_ADB_REMOVED, HELP_MSG_CHECK_ADB_REMOVED,
HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION, HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION,
HELP_MSG_CHECK_BUGREPORT,
HELP_MSG_CHECK_ANDROID_BACKUP, HELP_MSG_CHECK_ANDROID_BACKUP,
HELP_MSG_CHECK_ANDROIDQF, HELP_MSG_CHECK_ANDROIDQF,
HELP_MSG_CHECK_BUGREPORT,
HELP_MSG_CHECK_IOCS,
HELP_MSG_CHECK_INTRUSION_LOGS,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_HASHES, HELP_MSG_HASHES,
HELP_MSG_IOC, HELP_MSG_CHECK_IOCS,
HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE,
HELP_MSG_NONINTERACTIVE,
HELP_MSG_OUTPUT,
HELP_MSG_STIX2, HELP_MSG_STIX2,
HELP_MSG_VERBOSE,
HELP_MSG_VERSION,
) )
from mvt.common.logo import logo from mvt.common.logo import logo
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.updates import IndicatorsUpdates from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import init_logging, set_verbose_logging from mvt.common.utils import init_logging, set_verbose_logging
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
from .cmd_check_backup import CmdAndroidCheckBackup from .cmd_check_backup import CmdAndroidCheckBackup
from .cmd_check_bugreport import CmdAndroidCheckBugreport from .cmd_check_bugreport import CmdAndroidCheckBugreport
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from .modules.intrusion_logs import INTRUSION_LOGS_MODULES
from .modules.androidqf import ANDROIDQF_MODULES
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
from .modules.backup.helpers import cli_load_android_backup_password from .modules.backup.helpers import cli_load_android_backup_password
from .modules.bugreport import BUGREPORT_MODULES from .modules.bugreport import BUGREPORT_MODULES
from .modules.androidqf import ANDROIDQF_MODULES
init_logging() init_logging()
log = logging.getLogger("mvt") log = logging.getLogger("mvt")
@@ -51,44 +45,12 @@ log = logging.getLogger("mvt")
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def _get_disable_flags(ctx):
"""Helper function to safely get disable flags from context."""
if ctx.obj is None:
return False, False
return (
ctx.obj.get("disable_version_check", False),
ctx.obj.get("disable_indicator_check", False),
)
def _load_custom_modules(load_module):
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
raise click.ClickException(str(exc)) from exc
# ============================================================================== # ==============================================================================
# Main # Main
# ============================================================================== # ==============================================================================
@click.group(invoke_without_command=False) @click.group(invoke_without_command=False)
@click.option( def cli():
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK logo()
)
@click.option(
"--disable-indicator-update-check",
is_flag=True,
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
)
@click.pass_context
def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ============================================================================== # ==============================================================================
@@ -108,7 +70,6 @@ def version():
@click.pass_context @click.pass_context
def check_adb(ctx): def check_adb(ctx):
log.error(HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION) log.error(HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION)
ctx.exit(1)
# ============================================================================== # ==============================================================================
@@ -128,28 +89,11 @@ def check_adb(ctx):
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT) @click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True)) @click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_bugreport( def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_path):
ctx,
iocs,
output,
list_modules,
module,
load_module,
verbose,
bugreport_path,
):
set_verbose_logging(verbose) set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as bug reports are small. # Always generate hashes as bug reports are small.
cmd = CmdAndroidCheckBugreport( cmd = CmdAndroidCheckBugreport(
target_path=bugreport_path, target_path=bugreport_path,
@@ -157,9 +101,6 @@ def check_bugreport(
ioc_files=iocs, ioc_files=iocs,
module_name=module, module_name=module,
hashes=True, hashes=True,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
@@ -191,13 +132,6 @@ def check_bugreport(
) )
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT) @click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE) @click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD) @click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@@ -208,14 +142,12 @@ def check_backup(
iocs, iocs,
output, output,
list_modules, list_modules,
load_module,
non_interactive, non_interactive,
backup_password, backup_password,
verbose, verbose,
backup_path, backup_path,
): ):
set_verbose_logging(verbose) set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as backups are generally small. # Always generate hashes as backups are generally small.
cmd = CmdAndroidCheckBackup( cmd = CmdAndroidCheckBackup(
@@ -227,9 +159,6 @@ def check_backup(
"interactive": not non_interactive, "interactive": not non_interactive,
"backup_password": cli_load_android_backup_password(log, backup_password), "backup_password": cli_load_android_backup_password(log, backup_password),
}, },
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
@@ -260,13 +189,6 @@ def check_backup(
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT) @click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE) @click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD) @click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@@ -279,7 +201,6 @@ def check_androidqf(
output, output,
list_modules, list_modules,
module, module,
load_module,
hashes, hashes,
non_interactive, non_interactive,
backup_password, backup_password,
@@ -287,7 +208,6 @@ def check_androidqf(
androidqf_path, androidqf_path,
): ):
set_verbose_logging(verbose) set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
cmd = CmdAndroidCheckAndroidQF( cmd = CmdAndroidCheckAndroidQF(
target_path=androidqf_path, target_path=androidqf_path,
@@ -299,9 +219,6 @@ def check_androidqf(
"interactive": not non_interactive, "interactive": not non_interactive,
"backup_password": cli_load_android_backup_password(log, backup_password), "backup_password": cli_load_android_backup_password(log, backup_password),
}, },
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
@@ -316,85 +233,6 @@ def check_androidqf(
cmd.show_support_message() cmd.show_support_message()
# ==============================================================================
# Command: check-intrusion-logs
# ==============================================================================
@cli.command(
"check-intrusion-logs",
context_settings=CONTEXT_SETTINGS,
help=HELP_MSG_CHECK_INTRUSION_LOGS,
)
@click.option(
"--iocs",
"-i",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_IOC,
)
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option(
"--timezone",
"-t",
default=None,
help=(
"IANA timezone name for the device, for example 'Europe/Paris'. "
"When provided, event timestamps are expressed in the device's local "
"time instead of UTC."
),
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("LOGS_PATH", type=click.Path(exists=True))
@click.pass_context
def check_intrusion_logs(
ctx,
iocs,
output,
list_modules,
module,
load_module,
timezone,
verbose,
logs_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
module_options = {}
if timezone:
module_options["device_timezone"] = timezone
cmd = CmdAndroidCheckIntrusionLogs(
target_path=logs_path,
results_path=output,
ioc_files=iocs,
module_name=module,
module_options=module_options if module_options else None,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking intrusion logs at path: %s", logs_path)
cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message()
# ============================================================================== # ==============================================================================
# Command: check-iocs # Command: check-iocs
# ============================================================================== # ==============================================================================
@@ -409,29 +247,11 @@ def check_intrusion_logs(
) )
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.argument("FOLDER", type=click.Path(exists=True)) @click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_iocs(ctx, iocs, list_modules, module, load_module, folder): def check_iocs(ctx, iocs, list_modules, module, folder):
custom_modules = _load_custom_modules(load_module) cmd = CmdCheckIOCS(target_path=folder, ioc_files=iocs, module_name=module)
cmd = CmdCheckIOCS( cmd.modules = BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES
target_path=folder,
ioc_files=iocs,
module_name=module,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
platform="android",
)
cmd.modules = (
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
)
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
+12 -158
View File
@@ -5,19 +5,15 @@
import logging import logging
import os import os
import shutil
import tempfile
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from mvt.android.artifacts.getprop import GetProp
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup, InvalidAndroidBackup
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup
from .modules.androidqf import ANDROIDQF_MODULES from .modules.androidqf import ANDROIDQF_MODULES
from .modules.androidqf.base import AndroidQFModule from .modules.androidqf.base import AndroidQFModule
@@ -49,9 +45,6 @@ class CmdAndroidCheckAndroidQF(Command):
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
hashes: Optional[bool] = False, hashes: Optional[bool] = False,
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -64,12 +57,8 @@ class CmdAndroidCheckAndroidQF(Command):
hashes=hashes, hashes=hashes,
sub_command=sub_command, sub_command=sub_command,
log=log, log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "android"
self.name = "check-androidqf" self.name = "check-androidqf"
self.modules = ANDROIDQF_MODULES self.modules = ANDROIDQF_MODULES
@@ -147,57 +136,7 @@ class CmdAndroidCheckAndroidQF(Command):
raise NoAndroidQFBackup raise NoAndroidQFBackup
def _read_device_timezone(self) -> Optional[str]:
getprop_files = [
f for f in self.__files if f.replace("\\", "/").endswith("getprop.txt")
]
if not getprop_files:
self.log.warning(
"Could not find getprop.txt; intrusion log timestamps will use UTC."
)
return None
try:
content = self._get_file_content(getprop_files[0]).decode(
"utf-8", errors="ignore"
)
except Exception as exc:
self.log.warning("Could not read getprop.txt: %s", exc)
return None
props = GetProp()
props.parse(content)
timezone = props.get_device_timezone()
if timezone:
self.log.info(
"Device timezone identified from getprop.txt: %s",
timezone,
)
else:
self.log.warning(
"persist.sys.timezone not found in getprop.txt; "
"intrusion log timestamps will use UTC."
)
return timezone
def _get_file_content(self, file_path: str) -> bytes:
if self.__format == "zip" and self.__zip:
handle = self.__zip.open(file_path)
try:
return handle.read()
finally:
handle.close()
if self.__format == "dir" and self.target_path:
parent_path = Path(self.target_path).absolute().parent.as_posix()
with open(os.path.join(parent_path, file_path), "rb") as handle:
return handle.read()
raise FileNotFoundError(file_path)
def run_bugreport_cmd(self) -> bool: def run_bugreport_cmd(self) -> bool:
bugreport = None
try: try:
bugreport = self.load_bugreport() bugreport = self.load_bugreport()
except NoAndroidQFBugReport: except NoAndroidQFBugReport:
@@ -214,7 +153,6 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=self.module_options, module_options=self.module_options,
hashes=self.hashes, hashes=self.hashes,
sub_command=True, sub_command=True,
custom_modules=self.custom_modules,
) )
cmd.from_zip(bugreport) cmd.from_zip(bugreport)
cmd.run() cmd.run()
@@ -225,8 +163,6 @@ class CmdAndroidCheckAndroidQF(Command):
if bugreport: if bugreport:
bugreport.close() bugreport.close()
return True
def run_backup_cmd(self) -> bool: def run_backup_cmd(self) -> bool:
try: try:
backup = self.load_backup() backup = self.load_backup()
@@ -235,111 +171,29 @@ class CmdAndroidCheckAndroidQF(Command):
"Skipping backup modules as no backup.ab found in AndroidQF data." "Skipping backup modules as no backup.ab found in AndroidQF data."
) )
return False return False
else:
cmd = CmdAndroidCheckBackup( cmd = CmdAndroidCheckBackup(
target_path=None, target_path=None,
results_path=self.results_path,
ioc_files=self.ioc_files,
iocs=self.iocs,
module_options=self.module_options,
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
)
try:
cmd.from_ab(backup)
except InvalidAndroidBackup as exc:
self.log.warning(
"Skipping backup modules as backup.ab is malformed: %s", exc
)
return False
cmd.run()
self.timeline.extend(cmd.timeline)
self.alertstore.extend(cmd.alertstore.alerts)
return True
def run_intrusion_logs_cmd(self) -> bool:
intrusion_log_files = [
f
for f in self.__files
if "/intrusion_logs/" in f.replace("\\", "/")
or f.replace("\\", "/").startswith("intrusion_logs/")
]
if not intrusion_log_files:
self.log.info(
"No intrusion_logs folder found in AndroidQF data, "
"skipping intrusion logs analysis."
)
return False
self.log.info(
"Found intrusion_logs folder in AndroidQF data, running intrusion logs analysis."
)
intrusion_logs_path = None
temp_dir = None
try:
if self.__format == "dir" and self.target_path:
intrusion_logs_path = os.path.join(
os.path.abspath(self.target_path), "intrusion_logs"
)
if not os.path.isdir(intrusion_logs_path):
self.log.warning(
"intrusion_logs directory not found at %s",
intrusion_logs_path,
)
return False
elif self.__format == "zip" and self.__zip:
temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_")
for entry in intrusion_log_files:
normalized = entry.replace("\\", "/")
idx = normalized.find("intrusion_logs/")
relative = normalized[idx + len("intrusion_logs/") :]
if not relative or relative.endswith("/"):
continue
target = os.path.join(temp_dir, relative)
os.makedirs(os.path.dirname(target), exist_ok=True)
with self.__zip.open(entry) as src, open(target, "wb") as dst:
dst.write(src.read())
intrusion_logs_path = temp_dir
else:
return False
adv_module_options = dict(self.module_options or {})
if device_timezone := self._read_device_timezone():
adv_module_options["device_timezone"] = device_timezone
cmd = CmdAndroidCheckIntrusionLogs(
target_path=intrusion_logs_path,
results_path=self.results_path, results_path=self.results_path,
ioc_files=self.ioc_files, ioc_files=self.ioc_files,
iocs=self.iocs, iocs=self.iocs,
module_options=adv_module_options, module_options=self.module_options,
hashes=self.hashes, hashes=self.hashes,
sub_command=True, sub_command=True,
custom_modules=self.custom_modules,
) )
cmd.from_ab(backup)
cmd.run() cmd.run()
self.timeline.extend(cmd.timeline) self.timeline.extend(cmd.timeline)
self.alertstore.extend(cmd.alertstore.alerts) self.alertstore.extend(cmd.alertstore.alerts)
return True
finally: finally:
if temp_dir: if backup:
shutil.rmtree(temp_dir, ignore_errors=True) backup.close()
def finish(self) -> None: def finish(self) -> None:
""" """
Run nested modules if their respective files are found in AndroidQF data. Run the bugreport and backup modules if the respective files are found in the AndroidQF data.
""" """
self.run_bugreport_cmd() self.run_bugreport_cmd()
self.run_backup_cmd() self.run_backup_cmd()
self.run_intrusion_logs_cmd()
+8 -43
View File
@@ -21,17 +21,12 @@ from mvt.android.parsers.backup import (
) )
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class InvalidAndroidBackup(Exception):
pass
class CmdAndroidCheckBackup(Command): class CmdAndroidCheckBackup(Command):
def __init__( def __init__(
self, self,
@@ -44,9 +39,6 @@ class CmdAndroidCheckBackup(Command):
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
hashes: Optional[bool] = False, hashes: Optional[bool] = False,
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -59,12 +51,8 @@ class CmdAndroidCheckBackup(Command):
hashes=hashes, hashes=hashes,
sub_command=sub_command, sub_command=sub_command,
log=log, log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "android"
self.name = "check-backup" self.name = "check-backup"
self.modules = BACKUP_MODULES self.modules = BACKUP_MODULES
@@ -76,10 +64,6 @@ class CmdAndroidCheckBackup(Command):
self.__type = "ab" self.__type = "ab"
header = parse_ab_header(ab_file_bytes) header = parse_ab_header(ab_file_bytes)
if not header["backup"]: if not header["backup"]:
if self.sub_command:
raise InvalidAndroidBackup(
"Invalid backup format, file should be in .ab format"
)
log.critical("Invalid backup format, file should be in .ab format") log.critical("Invalid backup format, file should be in .ab format")
sys.exit(1) sys.exit(1)
@@ -95,51 +79,32 @@ class CmdAndroidCheckBackup(Command):
log.critical("Invalid backup password") log.critical("Invalid backup password")
sys.exit(1) sys.exit(1)
except AndroidBackupParsingError as exc: except AndroidBackupParsingError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc) log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead") log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1) sys.exit(1)
dbytes = io.BytesIO(tardata) dbytes = io.BytesIO(tardata)
try: self.__tar = tarfile.open(fileobj=dbytes)
self.__tar = tarfile.open(fileobj=dbytes)
except tarfile.TarError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1)
for member in self.__tar: for member in self.__tar:
self.__files.append(member.name) self.__files.append(member.name)
def init(self) -> None: def init(self) -> None:
if not self.target_path: # type: ignore[has-type] if not self.target_path:
return return
# Type guard: we know it's not None here after the check above if os.path.isfile(self.target_path):
assert self.target_path is not None # type: ignore[has-type]
# Use a different local variable name to avoid any scoping issues
backup_path: str = self.target_path # type: ignore[has-type]
if os.path.isfile(backup_path):
self.__type = "ab" self.__type = "ab"
with open(backup_path, "rb") as handle: with open(self.target_path, "rb") as handle:
ab_file_bytes = handle.read() ab_file_bytes = handle.read()
self.from_ab(ab_file_bytes) self.from_ab(ab_file_bytes)
elif os.path.isdir(backup_path): elif os.path.isdir(self.target_path):
self.__type = "folder" self.__type = "folder"
backup_path = Path(backup_path).absolute().as_posix() self.target_path = Path(self.target_path).absolute().as_posix()
self.target_path = backup_path for root, subdirs, subfiles in os.walk(os.path.abspath(self.target_path)):
for root, subdirs, subfiles in os.walk(os.path.abspath(backup_path)):
for fname in subfiles: for fname in subfiles:
self.__files.append( self.__files.append(
os.path.relpath(os.path.join(root, fname), backup_path) os.path.relpath(os.path.join(root, fname), self.target_path)
) )
else: else:
log.critical( log.critical(
-10
View File
@@ -12,7 +12,6 @@ from zipfile import ZipFile
from mvt.android.modules.bugreport.base import BugReportModule from mvt.android.modules.bugreport.base import BugReportModule
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.bugreport import BUGREPORT_MODULES from .modules.bugreport import BUGREPORT_MODULES
@@ -31,9 +30,6 @@ class CmdAndroidCheckBugreport(Command):
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
hashes: Optional[bool] = False, hashes: Optional[bool] = False,
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -46,12 +42,8 @@ class CmdAndroidCheckBugreport(Command):
hashes=hashes, hashes=hashes,
sub_command=sub_command, sub_command=sub_command,
log=log, log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "android"
self.name = "check-bugreport" self.name = "check-bugreport"
self.modules = BUGREPORT_MODULES self.modules = BUGREPORT_MODULES
@@ -100,8 +92,6 @@ class CmdAndroidCheckBugreport(Command):
if self.__format == "zip": if self.__format == "zip":
module.from_zip(self.__zip, self.__files) module.from_zip(self.__zip, self.__files)
else: else:
if not self.target_path:
raise ValueError("target_path is not set")
module.from_dir(self.target_path, self.__files) module.from_dir(self.target_path, self.__files)
def finish(self) -> None: def finish(self) -> None:
-117
View File
@@ -1,117 +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
import os
from typing import Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.intrusion_logs import (
INTRUSION_LOGS_MODULES,
KNOWN_INTRUSION_LOG_EVENT_TYPES,
)
from .modules.intrusion_logs.base import IntrusionLogsModule
log = logging.getLogger(__name__)
class CmdAndroidCheckIntrusionLogs(Command):
"""Command to check Android Intrusion Logging files."""
def __init__(
self,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
ioc_files: Optional[list] = None,
iocs: Optional[Indicators] = None,
module_name: Optional[str] = None,
serial: Optional[str] = None,
module_options: Optional[dict] = None,
hashes: Optional[bool] = False,
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
results_path=results_path,
ioc_files=ioc_files,
iocs=iocs,
module_name=module_name,
serial=serial,
module_options=module_options,
hashes=hashes,
sub_command=sub_command,
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-intrusion-logs"
self.modules = INTRUSION_LOGS_MODULES
self._all_events: dict[str, list[dict]] = {}
def init(self) -> None:
if not self.target_path:
raise ValueError("No target path specified")
if not os.path.isdir(self.target_path) and not (
os.path.isfile(self.target_path)
and self.target_path.lower().endswith(".zip")
):
raise ValueError(
f"Target path must be a directory or a .zip file: {self.target_path}"
)
self.log.info("Checking intrusion logs at path: %s", self.target_path)
self._all_events = self._pre_load_events()
def module_init(self, module: IntrusionLogsModule) -> None: # type: ignore[override]
module.il_events_by_type = self._all_events
def finish(self) -> None:
return
def _pre_load_events(self) -> dict[str, list[dict]]:
"""Load and parse all advanced-log files once for reuse by all modules."""
self.log.info("Pre-loading intrusion log files from: %s", self.target_path)
loader = IntrusionLogsModule(
target_path=self.target_path,
log=self.log,
)
try:
all_events = loader.load_all_events(self.target_path)
except Exception as exc:
self.log.error("Failed to pre-load events: %s", exc)
return {}
total_events = sum(len(events) for events in all_events.values())
self.log.info(
"Pre-loaded %d events across %d type(s); modules will reuse this data",
total_events,
len(all_events),
)
unknown_event_types = sorted(
event_type
for event_type in all_events
if event_type not in KNOWN_INTRUSION_LOG_EVENT_TYPES
)
if unknown_event_types:
self.log.warning(
"Found unknown intrusion logging event type(s): %s. "
"Please open an issue on GitHub so MVT can add support for them.",
", ".join(unknown_event_types),
)
return all_events
+32
View File
@@ -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,116 @@
# 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.utils import convert_chrometime_to_datetime, convert_datetime_to_iso
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleSerializedResult,
ModuleResults,
)
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 = []
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:
result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
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()
+156
View File
@@ -0,0 +1,156 @@
# 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.utils import convert_unix_to_iso
from mvt.common.module_types import ModuleResults
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 and self.indicators.check_file_path(result["path"]):
self.log.warning(
'Found a known suspicous file at path: "%s"', result["path"]
)
self.detected.append(result)
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()
+44
View File
@@ -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))
+58
View File
@@ -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()
+311
View File
@@ -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.get("package_name"))
if ioc_match:
result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
for package_file in result.get("files", []):
ioc_match = self.indicators.check_file_hash(package_file["sha256"])
if ioc_match:
result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical(
self.get_slug(), ioc_match.message, "", result
)
# @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()
+43
View File
@@ -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,71 @@
# 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 .base import AndroidExtraction
from mvt.common.module_types import ModuleResults
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.detected.append(root_binary)
self.log.warning('Found root binary "%s"', 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,49 @@
# 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 .base import AndroidExtraction
from mvt.common.module_types import ModuleResults
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 = {} if not results else results
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)
+59
View File
@@ -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()
+187
View File
@@ -0,0 +1,187 @@
# 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.utils import check_for_links, convert_unix_to_iso
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleResults,
ModuleSerializedResult,
)
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:
message["matched_indicator"] = ioc_match.ioc
self.alertstore.critical(
self.get_slug(), ioc_match.message, "", message
)
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()
+118
View File
@@ -0,0 +1,118 @@
# 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.utils import check_for_links, convert_unix_to_iso
from .base import AndroidExtraction
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleSerializedResult,
ModuleResults,
)
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"])
if self.indicators.check_urls(message_links):
self.detected.append(message)
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()
@@ -3,13 +3,11 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .aqf_files import AQFFiles
from .aqf_getprop import AQFGetProp from .aqf_getprop import AQFGetProp
from .aqf_packages import AQFPackages from .aqf_packages import AQFPackages
from .aqf_processes import AQFProcesses from .aqf_processes import AQFProcesses
from .aqf_settings import AQFSettings from .aqf_settings import AQFSettings
from .mounts import Mounts from .aqf_files import AQFFiles
from .root_binaries import RootBinaries
from .sms import SMS from .sms import SMS
ANDROIDQF_MODULES = [ ANDROIDQF_MODULES = [
@@ -19,6 +17,4 @@ ANDROIDQF_MODULES = [
AQFSettings, AQFSettings,
AQFFiles, AQFFiles,
SMS, SMS,
RootBinaries,
Mounts,
] ]
+20 -20
View File
@@ -15,8 +15,8 @@ from typing import Optional
from mvt.android.modules.androidqf.base import AndroidQFModule from mvt.android.modules.androidqf.base import AndroidQFModule
from mvt.common.module_types import ( from mvt.common.module_types import (
ModuleAtomicResult,
ModuleResults, ModuleResults,
ModuleAtomicResult,
ModuleSerializedResult, ModuleSerializedResult,
) )
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
@@ -41,7 +41,7 @@ class AQFFiles(AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -89,9 +89,9 @@ class AQFFiles(AndroidQFModule):
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_file_path(result["path"]) ioc_match = self.indicators.check_file_path(result["path"])
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
) self.alertstore.log_latest()
continue continue
# NOTE: Update with final path used for Android collector. # NOTE: Update with final path used for Android collector.
@@ -105,18 +105,18 @@ class AQFFiles(AndroidQFModule):
file_type = "executable " file_type = "executable "
msg = f'Found {file_type}file at suspicious path "{result["path"]}"' msg = f'Found {file_type}file at suspicious path "{result["path"]}"'
self.alertstore.high(msg, "", result) self.alertstore.high(self.get_slug(), msg, "", result)
self.alertstore.log_latest()
for hash_key in ("sha256", "sha1", "md5"): if result.get("sha256", "") == "":
file_hash = result.get(hash_key, "") continue
if not file_hash:
continue ioc_match = self.indicators.check_file_hash(result["sha256"])
ioc_match = self.indicators.check_file_hash(file_hash) if ioc_match:
if ioc_match: result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
) # TODO: adds SHA1 and MD5 when available in MVT
break
def run(self) -> None: def run(self) -> None:
if timezone := self._get_device_timezone(): if timezone := self._get_device_timezone():
@@ -131,7 +131,7 @@ class AQFFiles(AndroidQFModule):
data = json.loads(rawdata) data = json.loads(rawdata)
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
data = [] data = []
for line in rawdata.splitlines(): for line in rawdata.split("\n"):
if line.strip() == "": if line.strip() == "":
continue continue
data.append(json.loads(line)) data.append(json.loads(line))
@@ -142,11 +142,11 @@ class AQFFiles(AndroidQFModule):
utc_timestamp = datetime.datetime.fromtimestamp( utc_timestamp = datetime.datetime.fromtimestamp(
file_data[ts], tz=datetime.timezone.utc file_data[ts], tz=datetime.timezone.utc
) )
# Convert the UTC timestamp to local time on Android device's local timezone # Convert the UTC timestamp to local tiem on Android device's local timezone
local_timestamp = utc_timestamp.astimezone(device_timezone) local_timestamp = utc_timestamp.astimezone(device_timezone)
# Preserve the device-local wall-clock time while using # HACK: We only output the UTC timestamp in convert_datetime_to_iso, we
# the project-wide ISO conversion helper. # set the timestamp timezone to UTC, to avoid the timezone conversion again.
local_timestamp = local_timestamp.replace( local_timestamp = local_timestamp.replace(
tzinfo=datetime.timezone.utc tzinfo=datetime.timezone.utc
) )
@@ -7,9 +7,9 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.getprop import GetProp as GetPropArtifact from mvt.android.artifacts.getprop import GetProp as GetPropArtifact
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule from .base import AndroidQFModule
from mvt.common.module_types import ModuleResults
class AQFGetProp(GetPropArtifact, AndroidQFModule): class AQFGetProp(GetPropArtifact, AndroidQFModule):
@@ -22,7 +22,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,7 +32,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule):
log=log, log=log,
results=results, results=results,
) )
self.results: list = [] if results is None else results self.results = []
def run(self) -> None: def run(self) -> None:
getprop_files = self._get_files_by_pattern("*/getprop.txt") getprop_files = self._get_files_by_pattern("*/getprop.txt")
@@ -3,16 +3,15 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import os
import datetime import datetime
import logging import logging
import os
from typing import Optional from typing import Optional
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
from mvt.common.module_types import ModuleResults
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule from .base import AndroidQFModule
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule): class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
@@ -27,7 +26,7 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -38,13 +37,11 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
results=results, results=results,
) )
def _get_file_modification_time(self, file_path: str) -> datetime.datetime: def _get_file_modification_time(self, file_path: str) -> dict:
if self.archive: if self.archive:
file_timetuple = self.archive.getinfo(file_path).date_time file_timetuple = self.archive.getinfo(file_path).date_time
return datetime.datetime(*file_timetuple) return datetime.datetime(*file_timetuple)
else: else:
if not self.parent_path:
raise ValueError("parent_path is not set")
file_stat = os.stat(os.path.join(self.parent_path, file_path)) file_stat = os.stat(os.path.join(self.parent_path, file_path))
return datetime.datetime.fromtimestamp(file_stat.st_mtime) return datetime.datetime.fromtimestamp(file_stat.st_mtime)
@@ -11,13 +11,13 @@ from mvt.android.utils import (
BROWSER_INSTALLERS, BROWSER_INSTALLERS,
PLAY_STORE_INSTALLERS, PLAY_STORE_INSTALLERS,
ROOT_PACKAGES, ROOT_PACKAGES,
THIRD_PARTY_STORE_INSTALLERS,
SECURITY_PACKAGES, SECURITY_PACKAGES,
SYSTEM_UPDATE_PACKAGES, SYSTEM_UPDATE_PACKAGES,
THIRD_PARTY_STORE_INSTALLERS,
) )
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule from .base import AndroidQFModule
from mvt.common.module_types import ModuleResults
class AQFPackages(AndroidQFModule): class AQFPackages(AndroidQFModule):
@@ -30,7 +30,7 @@ class AQFPackages(AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -45,67 +45,79 @@ class AQFPackages(AndroidQFModule):
for result in self.results: for result in self.results:
if result["name"] in ROOT_PACKAGES: if result["name"] in ROOT_PACKAGES:
self.alertstore.medium( self.alertstore.medium(
self.get_slug(),
f'Found an installed package related to rooting/jailbreaking: "{result["name"]}"', f'Found an installed package related to rooting/jailbreaking: "{result["name"]}"',
"", "",
result, result,
) )
self.alertstore.log_latest()
continue continue
# Detections for apps installed via unusual methods. # Detections for apps installed via unusual methods.
if result["installer"] in THIRD_PARTY_STORE_INSTALLERS: if result["installer"] in THIRD_PARTY_STORE_INSTALLERS:
self.alertstore.info( self.alertstore.info(
self.get_slug(),
f'Found a package installed via a third party store (installer="{result["installer"]}"): "{result["name"]}"', f'Found a package installed via a third party store (installer="{result["installer"]}"): "{result["name"]}"',
"", "",
result, result,
) )
self.alertstore.log_latest()
elif result["installer"] in BROWSER_INSTALLERS: elif result["installer"] in BROWSER_INSTALLERS:
self.alertstore.medium( self.alertstore.medium(
self.get_slug(),
f'Found a package installed via a browser (installer="{result["installer"]}"): "{result["name"]}"', f'Found a package installed via a browser (installer="{result["installer"]}"): "{result["name"]}"',
"", "",
result, result,
) )
self.alertstore.log_latest()
elif result["installer"] == "null" and result["system"] is False: elif result["installer"] == "null" and result["system"] is False:
self.alertstore.medium( self.alertstore.high(
self.get_slug(),
f'Found a non-system package installed via adb or another method: "{result["name"]}"', f'Found a non-system package installed via adb or another method: "{result["name"]}"',
"", "",
result, result,
) )
self.alertstore.log_latest()
elif result["installer"] in PLAY_STORE_INSTALLERS: elif result["installer"] in PLAY_STORE_INSTALLERS:
pass pass
# Check for disabled security or software update packages. # Check for disabled security or software update packages.
package_disabled = result.get("disabled", None) package_disabled = result.get("disabled", None)
if result["name"] in SECURITY_PACKAGES and package_disabled: if result["name"] in SECURITY_PACKAGES and package_disabled:
self.alertstore.medium( self.alertstore.high(
self.get_slug(),
f'Security package "{result["name"]}" disabled on the phone', f'Security package "{result["name"]}" disabled on the phone',
"", "",
result, result,
) )
self.alertstore.log_latest()
if result["name"] in SYSTEM_UPDATE_PACKAGES and package_disabled: if result["name"] in SYSTEM_UPDATE_PACKAGES and package_disabled:
self.alertstore.medium( self.alertstore.high(
self.get_slug(),
f'System OTA update package "{result["name"]}" disabled on the phone', f'System OTA update package "{result["name"]}" disabled on the phone',
"", "",
result, result,
) )
self.alertstore.log_latest()
if not self.indicators: if not self.indicators:
continue continue
ioc_match = self.indicators.check_app_id(result.get("name") or "") ioc_match = self.indicators.check_app_id(result.get("name"))
if ioc_match: if ioc_match:
self.alertstore.critical( result["matched_indicator"] = ioc_match.ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
) self.alertstore.log_latest()
for package_file in result.get("files", []): for package_file in result.get("files", []):
ioc_match = self.indicators.check_file_hash( ioc_match = self.indicators.check_file_hash(package_file["sha256"])
package_file.get("sha256") or ""
)
if ioc_match: if ioc_match:
result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.get_slug(), ioc_match.message, "", result
) )
self.alertstore.log_latest()
if "certificate" not in package_file: if "certificate" not in package_file:
continue continue
@@ -117,12 +129,11 @@ class AQFPackages(AndroidQFModule):
certificate_hash certificate_hash
) )
if ioc_match: if ioc_match:
result["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, self.get_slug(), ioc_match.message, "", result
"",
result,
matched_indicator=ioc_match.ioc,
) )
self.alertstore.log_latest()
break break
def run(self) -> None: def run(self) -> None:
@@ -22,7 +22,7 @@ class AQFProcesses(ProcessesArtifact, AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -7,9 +7,9 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.settings import Settings as SettingsArtifact from mvt.android.artifacts.settings import Settings as SettingsArtifact
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule from .base import AndroidQFModule
from mvt.common.module_types import ModuleResults
class AQFSettings(SettingsArtifact, AndroidQFModule): class AQFSettings(SettingsArtifact, AndroidQFModule):
@@ -22,7 +22,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,7 +32,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
log=log, log=log,
results=results, results=results,
) )
self.results: dict = results if results is not None else {} self.results = {}
def run(self) -> None: def run(self) -> None:
for setting_file in self._get_files_by_pattern("*/settings_*.txt"): for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
@@ -40,7 +40,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
self.results[namespace] = {} self.results[namespace] = {}
data = self._get_file_content(setting_file) data = self._get_file_content(setting_file)
for line in data.decode("utf-8").splitlines(): for line in data.decode("utf-8").split("\n"):
line = line.strip() line = line.strip()
try: try:
key, value = line.split("=", 1) key, value = line.split("=", 1)
+3 -3
View File
@@ -23,7 +23,7 @@ class AndroidQFModule(MVTModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -33,8 +33,8 @@ class AndroidQFModule(MVTModule):
log=log, log=log,
results=results, results=results,
) )
self.parent_path: Optional[str] = None self.parent_path = None
self._path: Optional[str] = target_path self._path: str = target_path
self.files: List[str] = [] self.files: List[str] = []
self.archive: Optional[zipfile.ZipFile] = None self.archive: Optional[zipfile.ZipFile] = None
@@ -1,77 +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
import logging
from typing import Optional
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
from .base import AndroidQFModule
class Mounts(MountsArtifact, AndroidQFModule):
"""This module extracts and analyzes mount information from AndroidQF acquisitions."""
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: Optional[list] = None,
) -> 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 = [] if results is None else results
def run(self) -> None:
"""
Run the mounts analysis module.
This module looks for mount information files collected by androidqf
and analyzes them for suspicious configurations, particularly focusing
on detecting root access indicators like /system mounted as read-write.
"""
mount_files = self._get_files_by_pattern("*/mounts.json")
if not mount_files:
self.log.info("No mount information file found")
return
self.log.info("Found mount information file: %s", mount_files[0])
try:
data = self._get_file_content(mount_files[0]).decode(
"utf-8", errors="replace"
)
except Exception as exc:
self.log.error("Failed to read mount information file: %s", exc)
return
# Parse the mount data
try:
json_data = json.loads(data)
if isinstance(json_data, list):
# AndroidQF format: array of strings like
# "/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)"
mount_content = "\n".join(json_data)
else:
self.log.error("Expected mounts.json to contain a list of mount lines")
return
self.parse(mount_content)
except Exception as exc:
self.log.error("Failed to parse mount information: %s", exc)
return
self.log.info("Extracted a total of %d mount entries", len(self.results))
@@ -1,120 +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
import logging
from typing import Optional
from .base import AndroidQFModule
class RootBinaries(AndroidQFModule):
"""This module analyzes root_binaries.json for root binaries found by androidqf."""
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: Optional[list] = None,
) -> 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: dict) -> dict:
return {
"timestamp": record.get("timestamp"),
"module": self.__class__.__name__,
"event": "root_binary_found",
"data": f"Root binary found: {record['path']} (binary: {record['binary_name']})",
}
def check_indicators(self) -> None:
"""Check for indicators of device rooting."""
if not self.results:
return
# All found root binaries are considered indicators of rooting
for result in self.results:
self.alertstore.high(
f'Found root binary "{result["binary_name"]}" at path "{result["path"]}"',
"",
result,
)
if self.results:
self.log.warning(
"Device shows signs of rooting with %d root binaries found",
len(self.results),
)
def run(self) -> None:
"""Run the root binaries analysis."""
root_binaries_files = self._get_files_by_pattern("*/root_binaries.json")
if not root_binaries_files:
self.log.info("No root_binaries.json file found")
return
rawdata = self._get_file_content(root_binaries_files[0]).decode(
"utf-8", errors="ignore"
)
try:
root_binary_paths = json.loads(rawdata)
except json.JSONDecodeError as e:
self.log.error("Failed to parse root_binaries.json: %s", e)
return
if not isinstance(root_binary_paths, list):
self.log.error("Expected root_binaries.json to contain a list of paths")
return
# Known root binary names that might be found and their descriptions
# This maps the binary name to a human-readable description
known_root_binaries = {
"su": "SuperUser binary",
"busybox": "BusyBox utilities",
"supersu": "SuperSU root management",
"Superuser.apk": "Superuser app",
"KingoUser.apk": "KingRoot app",
"SuperSu.apk": "SuperSU app",
"magisk": "Magisk root framework",
"magiskhide": "Magisk hide utility",
"magiskinit": "Magisk init binary",
"magiskpolicy": "Magisk policy binary",
}
for path in root_binary_paths:
if not path or not isinstance(path, str):
continue
# Extract binary name from path
binary_name = path.split("/")[-1].lower()
# Check if this matches a known root binary by exact name match
description = "Unknown root binary"
for known_binary in known_root_binaries:
if binary_name == known_binary.lower():
description = known_root_binaries[known_binary]
break
result = {
"path": path.strip(),
"binary_name": binary_name,
"description": description,
}
self.results.append(result)
self.log.info("Found %d root binaries", len(self.results))
+8 -3
View File
@@ -21,6 +21,10 @@ from .base import AndroidQFModule
class SMS(AndroidQFModule): class SMS(AndroidQFModule):
""" """
This module analyse SMS file in backup This module analyse SMS file in backup
XXX: We should also de-duplicate this AQF module, but first we
need to add tests for loading encrypted SMS backups through the backup
sub-module.
""" """
def __init__( def __init__(
@@ -51,14 +55,15 @@ class SMS(AndroidQFModule):
ioc_match = self.indicators.check_domains(message.get("links", [])) ioc_match = self.indicators.check_domains(message.get("links", []))
if ioc_match: if ioc_match:
message["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc self.get_slug(), ioc_match.message, "", message
) )
def parse_backup(self, data): def parse_backup(self, data):
header = parse_ab_header(data) header = parse_ab_header(data)
if not header["backup"]: if not header["backup"]:
self.log.warning("Invalid backup format, backup.ab was not analysed") self.log.critical("Invalid backup format, backup.ab was not analysed")
return return
password = None password = None
@@ -76,7 +81,7 @@ class SMS(AndroidQFModule):
self.log.critical("Invalid backup password") self.log.critical("Invalid backup password")
return return
except AndroidBackupParsingError: except AndroidBackupParsingError:
self.log.warning( self.log.critical(
"Impossible to parse this backup file, please use" "Impossible to parse this backup file, please use"
" Android Backup Extractor instead" " Android Backup Extractor instead"
) )
+8 -11
View File
@@ -9,7 +9,7 @@ import os
from tarfile import TarFile from tarfile import TarFile
from typing import List, Optional from typing import List, Optional
from mvt.common.module import ModuleResults, MVTModule from mvt.common.module import MVTModule, ModuleResults
class BackupModule(MVTModule): class BackupModule(MVTModule):
@@ -22,7 +22,7 @@ class BackupModule(MVTModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,10 +32,10 @@ class BackupModule(MVTModule):
log=log, log=log,
results=results, results=results,
) )
self.ab: Optional[str] = None self.ab = None
self.backup_path: Optional[str] = None self.backup_path = None
self.tar: Optional[TarFile] = None self.tar = None
self.files: list = [] self.files = []
def from_dir(self, backup_path: Optional[str], files: List[str]) -> None: def from_dir(self, backup_path: Optional[str], files: List[str]) -> None:
self.backup_path = backup_path self.backup_path = backup_path
@@ -55,15 +55,12 @@ class BackupModule(MVTModule):
return fnmatch.filter(self.files, pattern) return fnmatch.filter(self.files, pattern)
def _get_file_content(self, file_path: str) -> bytes: def _get_file_content(self, file_path: str) -> bytes:
handle = None
if self.tar: if self.tar:
try: try:
member = self.tar.getmember(file_path) member = self.tar.getmember(file_path)
handle = self.tar.extractfile(member)
if not handle:
raise ValueError(f"Could not extract file: {file_path}")
except KeyError: except KeyError:
raise FileNotFoundError(f"File not found in tar: {file_path}") return None
handle = self.tar.extractfile(member)
elif self.backup_path: elif self.backup_path:
handle = open(os.path.join(self.backup_path, file_path), "rb") handle = open(os.path.join(self.backup_path, file_path), "rb")
else: else:
+6 -5
View File
@@ -4,12 +4,12 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging import logging
from typing import Any, Optional from typing import Optional
from mvt.android.modules.backup.base import BackupModule from mvt.android.modules.backup.base import BackupModule
from mvt.android.parsers.backup import parse_sms_file from mvt.android.parsers.backup import parse_sms_file
from mvt.common.module_types import ModuleResults
from mvt.common.utils import check_for_links from mvt.common.utils import check_for_links
from mvt.common.module_types import ModuleResults
class SMS(BackupModule): class SMS(BackupModule):
@@ -20,7 +20,7 @@ class SMS(BackupModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -30,7 +30,7 @@ class SMS(BackupModule):
log=log, log=log,
results=results, results=results,
) )
self.results: list[dict[str, Any]] = [] self.results = []
def check_indicators(self) -> None: def check_indicators(self) -> None:
if not self.indicators: if not self.indicators:
@@ -46,8 +46,9 @@ class SMS(BackupModule):
ioc_match = self.indicators.check_urls(message_links) ioc_match = self.indicators.check_urls(message_links)
if ioc_match: if ioc_match:
message["matched_indicator"] = ioc_match.ioc
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc self.get_slug(), ioc_match.message, "", message
) )
continue continue
+10 -21
View File
@@ -6,11 +6,11 @@ import datetime
import fnmatch import fnmatch
import logging import logging
import os import os
from pathlib import Path
from typing import List, Optional from typing import List, Optional
from zipfile import ZipFile from zipfile import ZipFile
from mvt.common.module import ModuleResults, MVTModule from mvt.common.module import MVTModule, ModuleResults
class BugReportModule(MVTModule): class BugReportModule(MVTModule):
@@ -23,7 +23,7 @@ class BugReportModule(MVTModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -69,19 +69,14 @@ class BugReportModule(MVTModule):
if self.zip_archive: if self.zip_archive:
handle = self.zip_archive.open(file_path) handle = self.zip_archive.open(file_path)
else: else:
if not self.extract_path: handle = open(os.path.join(self.extract_path, file_path), "rb")
raise ValueError("extract_path is not set")
joined = os.path.join(self.extract_path, file_path)
if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()):
raise ValueError("unsafe file_path")
handle = open(joined, "rb")
data = handle.read() data = handle.read()
handle.close() handle.close()
return data return data
def _get_dumpstate_file(self) -> Optional[bytes]: def _get_dumpstate_file(self) -> bytes:
main = self._get_files_by_pattern("main_entry.txt") main = self._get_files_by_pattern("main_entry.txt")
if main: if main:
main_content = self._get_file_content(main[0]) main_content = self._get_file_content(main[0])
@@ -89,23 +84,17 @@ class BugReportModule(MVTModule):
return self._get_file_content(main_content.decode().strip()) return self._get_file_content(main_content.decode().strip())
except KeyError: except KeyError:
return None return None
else:
dumpstate_logs = self._get_files_by_pattern("dumpState_*.log")
if not dumpstate_logs:
return None
dumpstate_logs = self._get_files_by_pattern("dumpState_*.log")
if dumpstate_logs:
return self._get_file_content(dumpstate_logs[0]) return self._get_file_content(dumpstate_logs[0])
dumpsys_files = self._get_files_by_pattern("*/dumpsys.txt") def _get_file_modification_time(self, file_path: str) -> dict:
if dumpsys_files:
return self._get_file_content(dumpsys_files[0])
return None
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
if self.zip_archive: if self.zip_archive:
file_timetuple = self.zip_archive.getinfo(file_path).date_time file_timetuple = self.zip_archive.getinfo(file_path).date_time
return datetime.datetime(*file_timetuple) return datetime.datetime(*file_timetuple)
else: else:
if not self.extract_path:
raise ValueError("extract_path is not set")
file_stat = os.stat(os.path.join(self.extract_path, file_path)) file_stat = os.stat(os.path.join(self.extract_path, file_path))
return datetime.datetime.fromtimestamp(file_stat.st_mtime) return datetime.datetime.fromtimestamp(file_stat.st_mtime)
@@ -22,7 +22,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -24,7 +24,7 @@ class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -24,7 +24,7 @@ class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -6,9 +6,9 @@
import logging import logging
from typing import Optional from typing import Optional
from mvt.common.module_types import ModuleResults
from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact
from mvt.android.utils import DANGEROUS_PERMISSIONS, DANGEROUS_PERMISSIONS_THRESHOLD from mvt.android.utils import DANGEROUS_PERMISSIONS, DANGEROUS_PERMISSIONS_THRESHOLD
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
@@ -23,7 +23,7 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -43,9 +43,8 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
) )
return return
content = self.extract_dumpsys_section( data = data.decode("utf-8", errors="replace")
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:" content = self.extract_dumpsys_section(data, "DUMP OF SERVICE package:")
)
self.parse(content) self.parse(content)
for result in self.results: for result in self.results:
@@ -22,7 +22,7 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -42,10 +42,8 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
) )
return return
decoded_data = data.decode("utf-8", errors="replace") data = data.decode("utf-8", errors="replace")
content = self.extract_dumpsys_section( content = self.extract_dumpsys_section(data, "DUMP OF SERVICE platform_compat:")
decoded_data, "DUMP OF SERVICE platform_compat:"
)
self.parse(content) self.parse(content)
self.log.info("Found %d uninstalled apps", len(self.results)) self.log.info("Found %d uninstalled apps", len(self.results))
@@ -22,7 +22,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -35,24 +35,6 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
self.results = results if results else {} self.results = results if results else {}
def check_indicators(self) -> None:
for result in self.results:
if self.indicators:
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,
)
continue
def run(self) -> None: def run(self) -> None:
content = self._get_dumpstate_file() content = self._get_dumpstate_file()
if not content: if not content:
@@ -24,7 +24,7 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -23,7 +23,7 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None, results: ModuleResults = [],
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -1,20 +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 .connect_event import ConnectEvent
from .dns_event import DnsEvent
from .security_event import SecurityEvent
INTRUSION_LOGS_MODULES = [
DnsEvent,
ConnectEvent,
SecurityEvent,
]
KNOWN_INTRUSION_LOG_EVENT_TYPES = {
"connect_event",
"dns_event",
"security_event",
}
@@ -1,395 +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 datetime
import io
import json
import logging
import zipfile
from pathlib import Path
from typing import Optional, Union
try:
import zoneinfo
except ImportError:
from backports import zoneinfo # type: ignore[no-redef]
from mvt.common.module import MVTModule
from mvt.common.utils import convert_datetime_to_iso, convert_unix_to_iso
class IntrusionLogsModule(MVTModule):
"""Base class for modules analyzing intrusion logs (newline-delimited JSON).
Performance note
----------------
Log files can be large and are shared by every module in this package.
To avoid re-reading and re-parsing the same files N times (once per
module), the command layer should call :meth:`load_all_events` exactly
once and then assign the returned dict to the ``il_events_by_type``
attribute of every module instance **before** calling ``run_module``.
When ``il_events_by_type`` is populated:
* :meth:`collect_txt` becomes a no-op (no disk I/O).
* :meth:`parse_collected_txt` iterates the in-memory list for the
requested event type instead of re-parsing raw text.
Modules that are used standalone (e.g. in tests) still work as before
because ``il_events_by_type`` defaults to ``None``, which preserves the
original file-loading code path.
"""
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: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
# Raw file content collected by collect_txt (fallback path only).
self.il_files: list[tuple[str, str]] = []
# Pre-parsed events injected by the command layer.
# Keys are event-type strings (e.g. "dns_event"), values are lists of
# raw event-data dicts exactly as they appear in the JSON lines.
# When this is not None, collect_txt and parse_collected_txt use it
# instead of touching the file system.
self.il_events_by_type: Optional[dict[str, list[dict]]] = None
# ------------------------------------------------------------------
# Serialization helper
# ------------------------------------------------------------------
def serialize(self, record: dict) -> Union[dict, list]:
"""Serialize a record for timeline output."""
return {
"timestamp": record.get("timestamp", record.get("isodate")),
"module": self.__class__.__name__,
"event": record.get("event_type", ""),
"data": str(record),
}
# ------------------------------------------------------------------
# File collection
# ------------------------------------------------------------------
def collect_txt(self, source) -> None:
"""Collect text log files from *source* into ``self.il_files``.
Entry points:
* directory → walk recursively
* zip file → walk zip entries
* anything else → silently skip
If ``self.il_events_by_type`` has already been populated (i.e. the
command layer pre-loaded the events), this method returns immediately
without any disk I/O.
"""
if self.il_events_by_type is not None:
self.log.debug(
"Pre-loaded events available — skipping file collection for %s",
self.__class__.__name__,
)
return
path = Path(source)
if path.is_dir():
self._walk_directory(path)
return
if path.is_file() and path.suffix.lower() == ".zip":
try:
with zipfile.ZipFile(path) as z:
self._walk_zip(z)
except zipfile.BadZipFile:
self.log.debug("Skipping invalid zip: %s", path)
return
self.log.debug("Skipping unsupported source: %s", source)
def _walk_directory(self, root: Path, prefix: str = "") -> None:
for item in root.iterdir():
if item.is_dir():
self._walk_directory(item, prefix=f"{prefix}{item.name}/")
continue
if item.suffix.lower() == ".txt":
self.il_files.append(
(f"{prefix}{item.name}", item.read_text(errors="ignore"))
)
elif item.suffix.lower() == ".zip":
try:
with zipfile.ZipFile(item) as z:
self._walk_zip(z, prefix=f"{prefix}{item.name}::")
except zipfile.BadZipFile:
self.log.warning("Skipping invalid zip: %s", item)
def _walk_zip(self, zf: zipfile.ZipFile, prefix: str = "") -> None:
for info in zf.infolist():
if info.is_dir():
continue
name = info.filename
with zf.open(info) as f:
data = f.read()
if name.lower().endswith(".txt"):
self.il_files.append((f"{prefix}{name}", data.decode(errors="ignore")))
elif name.lower().endswith(".zip"):
with zipfile.ZipFile(io.BytesIO(data)) as inner:
self._walk_zip(inner, prefix=f"{prefix}{name}::")
# ------------------------------------------------------------------
# Single-pass loader (used by the command layer)
# ------------------------------------------------------------------
def load_all_events(self, source) -> dict[str, list[dict]]:
"""Read every log file under *source* **once** and parse all JSON
lines in a single pass, routing events into per-type buckets.
Returns a ``dict`` mapping *event_type* strings to lists of raw
event-data dicts. The result is also stored in
``self.il_events_by_type`` so that subsequent calls to
:meth:`collect_txt` and :meth:`parse_collected_txt` on *this*
instance are no-ops.
Intended usage in the command layer::
loader = IntrusionLogsModule(target_path=target, log=log)
all_events = loader.load_all_events(target)
for module_cls in INTRUSION_LOGS_MODULES:
m = module_cls(target_path=target, ...)
m.il_events_by_type = all_events # inject — no re-reading
run_module(m)
"""
# Reset so that _collect_txt actually runs (il_events_by_type is None).
self.il_events_by_type = None
self.il_files = []
self.collect_txt(source)
events_by_type: dict[str, list[dict]] = {}
# JSON fingerprints used to drop events that appear in more than one
# log file (overlapping daily files are the most common source of
# cross-file duplicates).
seen_fingerprints: set[str] = set()
total_lines = 0
skipped_lines = 0
duplicate_lines = 0
for file_name, text in self.il_files:
for line_num, line in enumerate(text.splitlines(), start=1):
line = line.strip()
if not line:
continue
total_lines += 1
try:
entry = json.loads(line)
for event_type, event_data in entry.items():
if isinstance(event_data, dict):
fingerprint = json.dumps(event_data, sort_keys=True)
if fingerprint in seen_fingerprints:
duplicate_lines += 1
continue
seen_fingerprints.add(fingerprint)
events_by_type.setdefault(event_type, []).append(event_data)
except json.JSONDecodeError as e:
skipped_lines += 1
self.log.warning(
"Failed to parse JSON on line %d in %s: %s",
line_num,
file_name,
e,
)
except Exception as e:
skipped_lines += 1
self.log.warning(
"Error processing line %d in %s: %s",
line_num,
file_name,
e,
)
if duplicate_lines:
self.log.info(
"Removed %d duplicate event(s) seen across multiple log files",
duplicate_lines,
)
self.log.info(
"Loaded %d log files, parsed %d lines (%d skipped), found event types: %s",
len(self.il_files),
total_lines,
skipped_lines,
{k: len(v) for k, v in events_by_type.items()},
)
# Cache so this instance also benefits from the fast path.
self.il_events_by_type = events_by_type
return events_by_type
# ------------------------------------------------------------------
# Parsing
# ------------------------------------------------------------------
def parse_collected_txt(self, event_type: str) -> None:
"""Parse collected log text and dispatch events of *event_type*.
Fast path
~~~~~~~~~
When ``self.il_events_by_type`` is populated (injected by the command
layer after a single shared :meth:`load_all_events` call), the method
iterates the already-parsed in-memory list for *event_type* — no
re-reading, no re-parsing of JSON.
Fallback path
~~~~~~~~~~~~~
When ``self.il_events_by_type`` is ``None``, the method falls back to
iterating ``self.il_files`` and parsing each JSON line, which is the
original behaviour.
"""
if self.il_events_by_type is not None:
events = self.il_events_by_type.get(event_type, [])
self.log.debug(
"Using pre-loaded events: dispatching %d '%s' events",
len(events),
event_type,
)
for event_data in events:
try:
# Work on a shallow copy so that mutations in one module
# (e.g. adding "timestamp") do not affect other modules
# that share the same dict reference.
self.process_event(dict(event_data))
except Exception as e:
self.log.warning(
"Error processing pre-parsed '%s' event: %s",
event_type,
e,
)
return
# Fallback: parse raw text collected by collect_txt().
# Use the same JSON-fingerprint approach as MVTModule._deduplicate_timeline
# to drop events that appear verbatim in more than one log file.
seen_fingerprints: set[str] = set()
duplicate_count = 0
for file_name, text in self.il_files:
for line_num, line in enumerate(text.splitlines(), start=1):
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if event_type in entry:
event_data = entry[event_type]
fingerprint = json.dumps(event_data, sort_keys=True)
if fingerprint in seen_fingerprints:
duplicate_count += 1
continue
seen_fingerprints.add(fingerprint)
event_data["event_type"] = event_type
self.process_event(event_data)
except json.JSONDecodeError as e:
self.log.warning(
"Failed to parse JSON on line %d in %s: %s",
line_num,
file_name,
str(e),
)
except Exception as e:
self.log.warning(
"Error processing line %d in %s: %s",
line_num,
file_name,
str(e),
)
if duplicate_count:
self.log.info(
"Removed %d duplicate '%s' event(s) seen across multiple log files",
duplicate_count,
event_type,
)
# ------------------------------------------------------------------
# Event processing
# ------------------------------------------------------------------
def process_event(self, event_data: dict) -> None:
"""Process an individual event. Override this in subclasses.
Args:
event_data: Dictionary containing the event data.
"""
self.results.append(event_data)
# ------------------------------------------------------------------
# Timestamp localisation
# ------------------------------------------------------------------
def _localize_timestamp(self, event_time_seconds: float) -> str:
"""Convert a Unix timestamp (in seconds) to an ISO string.
When the device timezone is available via ``module_options["device_timezone"]``
(a IANA timezone name such as ``"Europe/Paris"`` read from
``persist.sys.timezone`` in ``getprop.txt``), the UTC instant is
converted to the device's local time before formatting — mirroring the
approach used by ``AQFFiles``.
When no timezone is configured the method falls back to UTC, which is
consistent with all other MVT modules that call ``convert_unix_to_iso``.
Args:
event_time_seconds: Unix epoch timestamp expressed in **seconds**
(callers are responsible for dividing ms/ns values first).
Returns:
ISO-formatted datetime string (``YYYY-mm-dd HH:MM:SS.ffffff``).
The string always represents the device-local time (or UTC when no
timezone is known); no UTC offset suffix is appended, matching the
format produced by :func:`mvt.common.utils.convert_unix_to_iso`.
"""
tz_name: Optional[str] = self.module_options.get("device_timezone")
if tz_name:
try:
device_tz = zoneinfo.ZoneInfo(tz_name)
utc_dt = datetime.datetime.fromtimestamp(
event_time_seconds, tz=datetime.timezone.utc
)
local_dt = utc_dt.astimezone(device_tz)
# Strip tzinfo so that convert_datetime_to_iso outputs the
# local wall-clock time without a timezone suffix. This is
# the same pattern used by AQFFiles.
return convert_datetime_to_iso(local_dt.replace(tzinfo=None))
except Exception as e:
self.log.warning(
"Could not apply device timezone '%s', falling back to UTC: %s",
tz_name,
e,
)
return convert_unix_to_iso(event_time_seconds)
# ------------------------------------------------------------------
# Abstract interface
# ------------------------------------------------------------------
def run(self) -> None:
"""Main execution method. Must be implemented by subclasses."""
raise NotImplementedError("Subclasses must implement the run() method")
@@ -1,121 +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 typing import Optional, Union
from .base import IntrusionLogsModule
class ConnectEvent(IntrusionLogsModule):
"""This module analyzes network connection events from intrusion logs."""
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: Optional[list] = None,
) -> 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:
"""Check connection events against indicators of compromise."""
if not self.indicators:
return
for result in self.results:
# Check IP address against indicators
ip_address = result.get("ip_address", "")
if ip_address:
# Clean IP address (remove leading slash and extract IP from format like "ip6-localhost/::1")
if "/" in ip_address:
parts = ip_address.split("/")
clean_ip = parts[-1] if len(parts) > 1 else parts[0]
else:
clean_ip = ip_address.lstrip("/")
# Skip localhost addresses
if clean_ip and clean_ip not in ["::1", "127.0.0.1", "0.0.0.0"]:
ioc = self.indicators.check_domain(clean_ip)
if ioc:
result["matched_ip"] = clean_ip
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Check package name against app identifiers
package_name = result.get("package_name", "")
if package_name:
ioc = self.indicators.check_app_id(package_name)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
def serialize(self, record: dict) -> Union[dict, list]:
"""Serialize a connection event record for timeline output."""
ip_address = record.get("ip_address", "")
port = record.get("port", 0)
package_name = record.get("package_name", "")
matched_ip = record.get("matched_ip", "")
# Clean IP address for display
if "/" in ip_address:
parts = ip_address.split("/")
clean_ip = parts[-1] if len(parts) > 1 else parts[0]
else:
clean_ip = ip_address.lstrip("/")
# Indicate when IP matched an IoC
if matched_ip:
data = f"Connection to {clean_ip}:{port} by {package_name} [Matched IP: {matched_ip}]"
else:
data = f"Connection to {clean_ip}:{port} by {package_name}"
return {
"timestamp": record.get("timestamp"),
"module": self.__class__.__name__,
"event": "network_connection",
"data": data,
}
def process_event(self, event_data: dict) -> None:
"""Process a connection event and add it to results."""
# Convert event_time from milliseconds to ISO format
event_time = event_data.get("event_time")
if event_time:
# Android event times are in milliseconds since epoch
event_data["timestamp"] = self._localize_timestamp(event_time / 1000.0)
else:
event_data["timestamp"] = None
self.results.append(event_data)
def run(self) -> None:
"""Extract and analyze connection events from intrusion logs."""
if not self.target_path:
self.log.error("No target path specified")
return
self.collect_txt(self.target_path)
self.parse_collected_txt("connect_event")
self.log.info("Identified %d connection events", len(self.results))
@@ -1,141 +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 typing import Optional, Union
from .base import IntrusionLogsModule
class DnsEvent(IntrusionLogsModule):
"""This module analyzes DNS events from intrusion logs."""
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: Optional[list] = None,
) -> 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:
"""Check DNS events against indicators of compromise."""
if not self.indicators:
return
for result in self.results:
# Check hostname against domain indicators
hostname = result.get("hostname", "")
if hostname:
ioc = self.indicators.check_domain(hostname)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Check IP addresses against indicators
ip_addresses = result.get("ip_addresses", [])
matched_ips = []
for ip_addr in ip_addresses:
# Remove leading slash if present
clean_ip = (
ip_addr.lstrip("/") if isinstance(ip_addr, str) else str(ip_addr)
)
if clean_ip and clean_ip != "0.0.0.0":
ioc = self.indicators.check_domain(clean_ip)
if ioc:
matched_ips.append(clean_ip)
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Store matched IPs for timeline display
if matched_ips:
result["matched_ips"] = matched_ips
# Check package name against app identifiers
package_name = result.get("package_name", "")
if package_name:
ioc = self.indicators.check_app_id(package_name)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
def serialize(self, record: dict) -> Union[dict, list]:
"""Serialize a DNS event record for timeline output."""
hostname = record.get("hostname", "")
package_name = record.get("package_name", "")
# Get IP addresses for display
ip_addresses = record.get("ip_addresses", [])
matched_ips = record.get("matched_ips", [])
# Clean up IP addresses (remove leading slashes)
clean_ips = []
for ip_addr in ip_addresses:
clean_ip = ip_addr.lstrip("/") if isinstance(ip_addr, str) else str(ip_addr)
if clean_ip and clean_ip != "0.0.0.0":
clean_ips.append(clean_ip)
# Build the data string with actual IPs
if matched_ips:
# Highlight matched IPs in the output
ip_display = ", ".join(matched_ips)
data = f"DNS query for {hostname} by {package_name} [Matched IPs: {ip_display}]"
elif clean_ips:
ip_display = ", ".join(clean_ips)
data = f"DNS query for {hostname} by {package_name} [IPs: {ip_display}]"
else:
data = f"DNS query for {hostname} by {package_name}"
return {
"timestamp": record.get("timestamp"),
"module": self.__class__.__name__,
"event": "dns_query",
"data": data,
}
def process_event(self, event_data: dict) -> None:
"""Process a DNS event and add it to results."""
# Convert event_time from milliseconds to ISO format
event_time = event_data.get("event_time")
if event_time:
# Android event times are in milliseconds since epoch
event_data["timestamp"] = self._localize_timestamp(event_time / 1000.0)
else:
event_data["timestamp"] = None
self.results.append(event_data)
def run(self) -> None:
"""Extract and analyze DNS events from intrusion logs."""
if not self.target_path:
self.log.error("No target path specified")
return
self.collect_txt(self.target_path)
self.parse_collected_txt("dns_event")
self.log.info("Identified %d DNS events", len(self.results))
@@ -1,777 +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 typing import Optional, Union
from .base import IntrusionLogsModule
# Security event tags based on Android SecurityLog API
# Reference: https://developer.android.com/reference/android/app/admin/SecurityLog
SECURITY_EVENT_TAGS = {
# ADB events (API level 24)
"adb_shell_interactive": {
"tag_id": 210001,
"name": "ADB Shell Interactive",
"description": "An ADB interactive shell was opened via 'adb shell'",
},
"adb_shell_cmd": {
"tag_id": 210002,
"name": "ADB Shell Command",
"description": "A shell command was issued over ADB via 'adb shell <command>'",
},
"adb_sync_recv_file": {
"tag_id": 210003,
"name": "ADB Sync Recv File",
"description": "A file was pulled from the device via adb daemon (adb pull)",
},
"adb_sync_send_file": {
"tag_id": 210004,
"name": "ADB Sync Send File",
"description": "A file was pushed to the device via adb daemon (adb push)",
},
# App process events (API level 24)
"app_process_start": {
"tag_id": 210005,
"name": "App Process Start",
"description": "An app process was started",
},
# Keyguard events (API level 24)
"keyguard_dismissed": {
"tag_id": 210006,
"name": "Keyguard Dismissed",
"description": "Keyguard has been dismissed",
},
"keyguard_dismiss_auth_attempt": {
"tag_id": 210007,
"name": "Keyguard Dismiss Auth Attempt",
"description": "Authentication attempt to dismiss keyguard",
},
"keyguard_secured": {
"tag_id": 210008,
"name": "Keyguard Secured",
"description": "Device has been locked",
},
# OS events (API level 28)
"os_startup": {
"tag_id": 210009,
"name": "OS Startup",
"description": "Android OS has started",
},
"os_shutdown": {
"tag_id": 210010,
"name": "OS Shutdown",
"description": "Android OS has shutdown",
},
# Logging events (API level 28)
"logging_started": {
"tag_id": 210011,
"name": "Logging Started",
"description": "Audit logging has started",
},
"logging_stopped": {
"tag_id": 210012,
"name": "Logging Stopped",
"description": "Audit logging has stopped",
},
# Media events (API level 28)
"media_mount": {
"tag_id": 210013,
"name": "Media Mount",
"description": "Removable media has been mounted",
},
"media_unmount": {
"tag_id": 210014,
"name": "Media Unmount",
"description": "Removable media was unmounted",
},
# Log buffer event (API level 28)
"log_buffer_size_critical": {
"tag_id": 210015,
"name": "Log Buffer Size Critical",
"description": "Audit log buffer has reached 90% capacity",
},
# Password policy events (API level 28)
"password_expiration_set": {
"tag_id": 210016,
"name": "Password Expiration Set",
"description": "Admin set password expiration timeout",
},
"password_complexity_set": {
"tag_id": 210017,
"name": "Password Complexity Set",
"description": "Admin set password complexity requirement",
},
"password_history_length_set": {
"tag_id": 210018,
"name": "Password History Length Set",
"description": "Admin set password history length",
},
"max_screen_lock_timeout_set": {
"tag_id": 210019,
"name": "Max Screen Lock Timeout Set",
"description": "Admin set maximum screen lock timeout",
},
"max_password_attempts_set": {
"tag_id": 210020,
"name": "Max Password Attempts Set",
"description": "Admin set maximum failed password attempts before wipe",
},
"keyguard_disabled_features_set": {
"tag_id": 210021,
"name": "Keyguard Disabled Features Set",
"description": "Admin set disabled keyguard features",
},
# Remote lock event (API level 28)
"remote_lock": {
"tag_id": 210022,
"name": "Remote Lock",
"description": "Admin remotely locked the device or profile",
},
# Wipe failure event (API level 28)
"wipe_failure": {
"tag_id": 210023,
"name": "Wipe Failure",
"description": "Failed to wipe device or user data",
},
# Cryptographic key events (API level 28)
"key_generated": {
"tag_id": 210024,
"name": "Key Generated",
"description": "Cryptographic key was generated",
},
"key_import": {
"tag_id": 210025,
"name": "Key Import",
"description": "Cryptographic key was imported",
},
"key_destruction": {
"tag_id": 210026,
"name": "Key Destruction",
"description": "Cryptographic key was destroyed",
},
# User restriction events (API level 28)
"user_restriction_added": {
"tag_id": 210027,
"name": "User Restriction Added",
"description": "Admin added a user restriction",
},
"user_restriction_removed": {
"tag_id": 210028,
"name": "User Restriction Removed",
"description": "Admin removed a user restriction",
},
# Certificate events (API level 28)
"cert_authority_installed": {
"tag_id": 210029,
"name": "Certificate Authority Installed",
"description": "Root certificate installed to trusted storage",
},
"cert_authority_removed": {
"tag_id": 210030,
"name": "Certificate Authority Removed",
"description": "Root certificate removed from trusted storage",
},
"crypto_self_test_completed": {
"tag_id": 210031,
"name": "Crypto Self Test Completed",
"description": "Cryptographic functionality self test completed",
},
"key_integrity_violation": {
"tag_id": 210032,
"name": "Key Integrity Violation",
"description": "Key integrity violation detected",
},
"cert_validation_failure": {
"tag_id": 210033,
"name": "Certificate Validation Failure",
"description": "X.509v3 certificate validation failed",
},
# Camera policy event (API level 30)
"camera_policy_set": {
"tag_id": 210034,
"name": "Camera Policy Set",
"description": "Admin set policy to disable camera",
},
# Password complexity events (API level 31/33)
"password_complexity_required": {
"tag_id": 210035,
"name": "Password Complexity Required",
"description": "Admin set password complexity requirement using predefined levels",
},
"password_changed": {
"tag_id": 210036,
"name": "Password Changed",
"description": "User changed their lockscreen password",
},
# WiFi events (API level 33)
"wifi_connection": {
"tag_id": 210037,
"name": "WiFi Connection",
"description": "Device attempted to connect to a managed WiFi network",
},
"wifi_disconnection": {
"tag_id": 210038,
"name": "WiFi Disconnection",
"description": "Device disconnected from a managed WiFi network",
},
# Bluetooth events (API level 33)
"bluetooth_connection": {
"tag_id": 210039,
"name": "Bluetooth Connection",
"description": "Device attempted to connect to a Bluetooth device",
},
"bluetooth_disconnection": {
"tag_id": 210040,
"name": "Bluetooth Disconnection",
"description": "Device disconnected from a Bluetooth device",
},
# Package events (API level 34)
"package_installed": {
"tag_id": 210041,
"name": "Package Installed",
"description": "Application package was installed",
},
"package_updated": {
"tag_id": 210042,
"name": "Package Updated",
"description": "Application package was updated",
},
"package_uninstalled": {
"tag_id": 210043,
"name": "Package Uninstalled",
"description": "Application package was uninstalled",
},
# Backup service event (API level 35)
"backup_service_toggled": {
"tag_id": 210044,
"name": "Backup Service Toggled",
"description": "Admin enabled or disabled backup service",
},
# NFC events (API level 36)
"nfc_enabled": {
"tag_id": 210045,
"name": "NFC Enabled",
"description": "NFC service is enabled",
},
"nfc_disabled": {
"tag_id": 210046,
"name": "NFC Disabled",
"description": "NFC service is disabled",
},
}
SECURITY_EVENT_METADATA_KEYS = {
"event_id",
"event_time",
"event_type",
"timestamp",
}
class SecurityEvent(IntrusionLogsModule):
"""This module analyzes security events from intrusion logs."""
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: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.event_type_counts: dict[str, int] = {}
def _get_event_tag(self, event_data: dict) -> Optional[str]:
"""Return the security-event tag key, including tags unknown to MVT."""
for key in event_data:
if key not in SECURITY_EVENT_METADATA_KEYS:
return key
return None
def check_indicators(self) -> None:
"""Check security events against indicators of compromise."""
for result in self.results:
# Heuristic alerts are intrinsic to the event, so they run even
# when no indicator set is loaded.
self._check_security_heuristics(result)
# The remaining checks match events against loaded indicators.
if not self.indicators:
continue
# Check app process start events for suspicious package names
if "app_process_start" in result:
process_info = result["app_process_start"]
process_name = process_info.get("process", "")
if process_name:
# Check the full process name
ioc = self.indicators.check_app_id(process_name)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Also check process components after the first colon
# Example: "com.google.android.webview:sandboxed_process0:org.chromium.content.app.SandboxedProcessService0:0"
# We want to check "sandboxed_process0" and subsequent components
if ":" in process_name:
components = process_name.split(":")
for component in components[
1:
]: # Skip the first component (main package name)
if component:
ioc = self.indicators.check_app_id(component)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
break
# Check package operations for suspicious packages
for pkg_event in [
"package_installed",
"package_updated",
"package_uninstalled",
]:
if pkg_event in result:
pkg_info = result[pkg_event]
pkg_name = pkg_info.get("package_name", "")
if pkg_name:
ioc = self.indicators.check_app_id(pkg_name)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Check ADB shell commands for suspicious patterns
if "adb_shell_cmd" in result:
cmd_info = result["adb_shell_cmd"]
command = cmd_info.get("command", "")
if command:
# Check if command contains any suspicious app IDs
ioc = self.indicators.check_app_id(command)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Check ADB file sync events for suspicious paths
for adb_event in ["adb_sync_recv_file", "adb_sync_send_file"]:
if adb_event in result:
file_info = result[adb_event]
file_path = file_info.get("path", "")
if file_path:
ioc = self.indicators.check_file_path(file_path)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
def _check_security_heuristics(self, result: dict) -> None:
"""Raise alerts for events that are intrinsically suspicious,
independent of any loaded indicators."""
# Flag failed cryptographic operations as potentially suspicious
if "key_generated" in result:
if not result["key_generated"].get("success", True):
self.log.warning(
"Failed key generation detected for key_id: %s",
result["key_generated"].get("key_id", "unknown"),
)
# Flag certificate validation failures
if "cert_validation_failure" in result:
self.alertstore.medium(
"Certificate validation failure detected: "
f"{result.get('cert_validation_failure')}",
result.get("timestamp") or "",
result,
)
# Flag key integrity violations
if "key_integrity_violation" in result:
self.alertstore.medium(
f"Key integrity violation detected: {result.get('key_integrity_violation')}",
result.get("timestamp") or "",
result,
)
# Flag successful certificate authority installations (potential
# MITM); a missing success field is treated as installed
if "cert_authority_installed" in result:
cert_info = result["cert_authority_installed"]
if cert_info.get("success", True):
self.alertstore.medium(
"Certificate authority installed: "
f"{cert_info.get('subject', 'unknown')}",
result.get("timestamp") or "",
result,
)
else:
self.log.warning(
"Failed certificate authority install attempt: %s",
cert_info.get("subject", "unknown"),
)
# Flag wipe failures
if "wipe_failure" in result:
self.alertstore.medium(
"Device wipe failure detected",
result.get("timestamp") or "",
result,
)
# Flag crypto self test failures
if "crypto_self_test_completed" in result:
test_result = result["crypto_self_test_completed"]
if isinstance(test_result, dict):
success = test_result.get("success", True)
else:
success = test_result == 1
if not success:
self.alertstore.medium(
"Cryptographic self test failed",
result.get("timestamp") or "",
result,
)
def serialize(self, record: dict) -> Union[dict, list]:
"""Serialize a security event record for timeline output."""
# Determine the event sub-type
event_subtype = None
event_data_str = ""
event_subtype = self._get_event_tag(record)
if event_subtype:
event_info = record[event_subtype]
if event_subtype in SECURITY_EVENT_TAGS:
# ADB events
if event_subtype == "adb_shell_interactive":
event_data_str = "ADB interactive shell opened"
elif event_subtype == "adb_shell_cmd":
command = event_info.get("command", "")
event_data_str = f"ADB shell command: {command}"
elif event_subtype == "adb_sync_recv_file":
path = event_info.get("path", "")
event_data_str = f"File pulled via ADB: {path}"
elif event_subtype == "adb_sync_send_file":
path = event_info.get("path", "")
event_data_str = f"File pushed via ADB: {path}"
# App process events
elif event_subtype == "app_process_start":
process_name = event_info.get("process", "")
uid = event_info.get("uid", "")
pid = event_info.get("pid", "")
event_data_str = (
f"Process started: {process_name} (UID: {uid}, PID: {pid})"
)
# Keyguard events
elif event_subtype == "keyguard_dismiss_auth_attempt":
success = event_info.get("success", False)
method = event_info.get("method_strength", 0)
event_data_str = f"Auth attempt: {'Success' if success else 'Failed'} (method strength: {method})"
elif event_subtype == "keyguard_dismissed":
event_data_str = "Keyguard dismissed"
elif event_subtype == "keyguard_secured":
event_data_str = "Device locked"
elif event_subtype == "keyguard_disabled_features_set":
admin = event_info.get("admin_package", "")
features = event_info.get("disabled_features", "")
event_data_str = (
f"Keyguard features disabled by {admin}: {features}"
)
# Key events
elif event_subtype == "key_generated":
success = event_info.get("success", False)
key_id = event_info.get("key_id", "unknown")
uid = event_info.get("uid", "")
event_data_str = f"Key {'generated' if success else 'generation failed'}: {key_id} (UID: {uid})"
elif event_subtype == "key_destruction":
success = event_info.get("success", False)
key_id = event_info.get("key_id", "unknown")
uid = event_info.get("uid", "")
event_data_str = f"Key {'destroyed' if success else 'destruction failed'}: {key_id} (UID: {uid})"
elif event_subtype == "key_import":
success = event_info.get("success", False)
key_id = event_info.get("key_id", "unknown")
event_data_str = (
f"Key {'imported' if success else 'import failed'}: {key_id}"
)
elif event_subtype == "key_integrity_violation":
key_id = event_info.get("key_id", "unknown")
event_data_str = f"Key integrity violation: {key_id}"
# Certificate events
elif event_subtype == "cert_authority_installed":
success = event_info.get("success", False)
subject = event_info.get("subject", "unknown")
event_data_str = f"Cert {'installed' if success else 'install failed'}: {subject}"
elif event_subtype == "cert_authority_removed":
success = event_info.get("success", False)
subject = event_info.get("subject", "unknown")
event_data_str = (
f"Cert {'removed' if success else 'removal failed'}: {subject}"
)
elif event_subtype == "cert_validation_failure":
reason = (
event_info if isinstance(event_info, str) else str(event_info)
)
event_data_str = f"Certificate validation failure: {reason}"
elif event_subtype == "crypto_self_test_completed":
if isinstance(event_info, dict):
success = event_info.get("success", False)
else:
success = event_info == 1
event_data_str = (
f"Crypto self test: {'passed' if success else 'FAILED'}"
)
# Package events
elif event_subtype in [
"package_installed",
"package_updated",
"package_uninstalled",
]:
pkg_name = event_info.get("package_name", "")
version = event_info.get("version_code", "")
user_id = event_info.get("user_id", "")
action = event_subtype.replace("package_", "").title()
event_data_str = (
f"Package {action}: {pkg_name} (v{version}, user: {user_id})"
)
# OS events
elif event_subtype == "os_startup":
verified_boot = event_info.get("verified_boot_state", "")
dm_verity = event_info.get("dm_verity_mode", "")
event_data_str = f"OS startup (verified boot: {verified_boot}, dm-verity: {dm_verity})"
elif event_subtype == "os_shutdown":
event_data_str = "OS shutdown"
# Logging events
elif event_subtype == "logging_started":
event_data_str = "Audit logging started"
elif event_subtype == "logging_stopped":
event_data_str = "Audit logging stopped"
elif event_subtype == "log_buffer_size_critical":
event_data_str = "Log buffer at 90% capacity"
# Media events
elif event_subtype == "media_mount":
mount_point = event_info.get("mount_point", "")
label = event_info.get("volume_label", "")
event_data_str = f"Media mounted: {mount_point} ({label})"
elif event_subtype == "media_unmount":
mount_point = event_info.get("mount_point", "")
label = event_info.get("volume_label", "")
event_data_str = f"Media unmounted: {mount_point} ({label})"
# Password policy events
elif event_subtype == "password_expiration_set":
admin = event_info.get("admin_package", "")
timeout = event_info.get("timeout_ms", "")
event_data_str = f"Password expiration set by {admin}: {timeout}ms"
elif event_subtype == "password_complexity_set":
admin = event_info.get("admin_package", "")
event_data_str = f"Password complexity set by {admin}"
elif event_subtype == "password_complexity_required":
admin = event_info.get("admin_package", "")
complexity = event_info.get("complexity", "")
event_data_str = (
f"Password complexity required by {admin}: {complexity}"
)
elif event_subtype == "password_history_length_set":
admin = event_info.get("admin_package", "")
length = event_info.get("length", "")
event_data_str = f"Password history length set by {admin}: {length}"
elif event_subtype == "password_changed":
complexity = event_info.get("complexity", "")
user_id = event_info.get("user_id", "")
event_data_str = (
f"Password changed (complexity: {complexity}, user: {user_id})"
)
elif event_subtype == "max_screen_lock_timeout_set":
admin = event_info.get("admin_package", "")
timeout = event_info.get("timeout_ms", "")
event_data_str = (
f"Max screen lock timeout set by {admin}: {timeout}ms"
)
elif event_subtype == "max_password_attempts_set":
admin = event_info.get("admin_package", "")
attempts = event_info.get("max_attempts", "")
event_data_str = f"Max password attempts set by {admin}: {attempts}"
# Remote lock and wipe events
elif event_subtype == "remote_lock":
admin = event_info.get("admin_package", "")
event_data_str = f"Device remotely locked by {admin}"
elif event_subtype == "wipe_failure":
event_data_str = "Device wipe failed"
# User restriction events
elif event_subtype == "user_restriction_added":
admin = event_info.get("admin_package", "")
restriction = event_info.get("restriction", "")
event_data_str = f"User restriction added by {admin}: {restriction}"
elif event_subtype == "user_restriction_removed":
admin = event_info.get("admin_package", "")
restriction = event_info.get("restriction", "")
event_data_str = (
f"User restriction removed by {admin}: {restriction}"
)
# WiFi events
elif event_subtype == "wifi_connection":
bssid = event_info.get("bssid", "")
event_type = event_info.get("event_type", "")
reason = event_info.get("reason", "")
event_data_str = f"WiFi connection: {event_type} (BSSID: {bssid})"
if reason:
event_data_str += f" - {reason}"
elif event_subtype == "wifi_disconnection":
bssid = event_info.get("bssid", "")
reason = event_info.get("reason", "")
event_data_str = f"WiFi disconnection (BSSID: {bssid})"
if reason:
event_data_str += f" - {reason}"
# Bluetooth events
elif event_subtype == "bluetooth_connection":
mac = event_info.get("mac_address", "")
success = event_info.get("success", False)
reason = event_info.get("reason", "")
event_data_str = f"Bluetooth {'connected' if success else 'connection failed'}: {mac}"
if reason:
event_data_str += f" - {reason}"
elif event_subtype == "bluetooth_disconnection":
mac = event_info.get("mac_address", "")
reason = event_info.get("reason", "")
event_data_str = f"Bluetooth disconnected: {mac}"
if reason:
event_data_str += f" - {reason}"
# Camera policy event
elif event_subtype == "camera_policy_set":
admin = event_info.get("admin_package", "")
disabled = event_info.get("disabled", False)
event_data_str = (
f"Camera {'disabled' if disabled else 'enabled'} by {admin}"
)
# Backup service event
elif event_subtype == "backup_service_toggled":
admin = event_info.get("admin_package", "")
enabled = event_info.get("enabled", False)
event_data_str = f"Backup service {'enabled' if enabled else 'disabled'} by {admin}"
# NFC events
elif event_subtype == "nfc_enabled":
event_data_str = "NFC enabled"
elif event_subtype == "nfc_disabled":
event_data_str = "NFC disabled"
else:
event_data_str = (
f"{SECURITY_EVENT_TAGS.get(event_subtype, {}).get('name', event_subtype)}: "
f"{event_info}"
)
else:
event_data_str = f"{event_subtype}: {event_info}"
if not event_subtype:
event_subtype = "unknown"
event_data_str = str(record)
return {
"timestamp": record.get("timestamp"),
"module": self.__class__.__name__,
"event": event_subtype,
"data": event_data_str,
}
def process_event(self, event_data: dict) -> None:
"""Process a security event and add it to results."""
# Convert event_time to ISO format
# Security events use nanoseconds since epoch
event_time = event_data.get("event_time")
if event_time:
# Convert nanoseconds to seconds
event_data["timestamp"] = self._localize_timestamp(
event_time / 1_000_000_000.0
)
else:
event_data["timestamp"] = None
# Track event type statistics, including future tags unknown to MVT.
event_tag = self._get_event_tag(event_data)
if event_tag:
self.event_type_counts[event_tag] = (
self.event_type_counts.get(event_tag, 0) + 1
)
self.results.append(event_data)
def run(self) -> None:
"""Extract and analyze security events from intrusion logs."""
if not self.target_path:
self.log.error("No target path specified")
return
self.collect_txt(self.target_path)
self.parse_collected_txt("security_event")
self.log.info("Identified %d security events", len(self.results))
# Log event type breakdown
if self.event_type_counts:
self.log.info("Security event breakdown:")
for event_type, count in sorted(
self.event_type_counts.items(), key=lambda x: x[1], reverse=True
):
event_name = SECURITY_EVENT_TAGS.get(event_type, {}).get(
"name", event_type
)
self.log.info(" - %s: %d", event_name, count)
unknown_event_types = sorted(
event_type
for event_type in self.event_type_counts
if event_type not in SECURITY_EVENT_TAGS
)
if unknown_event_types:
self.log.warning(
"Found unknown intrusion logging security event type(s): %s. "
"Please open an issue on GitHub so MVT can add support for them.",
", ".join(unknown_event_types),
)
+12 -15
View File
@@ -29,6 +29,9 @@ class InvalidBackupPassword(AndroidBackupParsingError):
pass pass
# TODO: Need to clean all the following code and conform it to the coding style.
def to_utf8_bytes(input_bytes): def to_utf8_bytes(input_bytes):
output = [] output = []
for byte in input_bytes: for byte in input_bytes:
@@ -48,16 +51,13 @@ def parse_ab_header(data):
'encryption': "none", 'version': 4} 'encryption': "none", 'version': 4}
""" """
if data.startswith(b"ANDROID BACKUP"): if data.startswith(b"ANDROID BACKUP"):
try: [_, version, is_compressed, encryption, _] = data.split(b"\n", 4)
[_, version, is_compressed, encryption, _] = data.split(b"\n", 4) return {
return { "backup": True,
"backup": True, "compression": (is_compressed == b"1"),
"compression": (is_compressed == b"1"), "version": int(version),
"version": int(version), "encryption": encryption.decode("utf-8"),
"encryption": encryption.decode("utf-8"), }
}
except (UnicodeDecodeError, ValueError):
pass
return {"backup": False, "compression": None, "version": None, "encryption": None} return {"backup": False, "compression": None, "version": None, "encryption": None}
@@ -157,13 +157,13 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
checksum_salt=checksum_salt, checksum_salt=checksum_salt,
) )
# Decrypt and unpad backup data using derived key. # Decrypt and unpad backup data using derivied key.
cipher = Cipher(algorithms.AES(master_key), modes.CBC(master_iv)) cipher = Cipher(algorithms.AES(master_key), modes.CBC(master_iv))
decryptor = cipher.decryptor() decryptor = cipher.decryptor()
decrypted_tar = decryptor.update(encrypted_data) + decryptor.finalize() decrypted_tar = decryptor.update(encrypted_data) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder() unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(decrypted_tar) + unpadder.finalize() return unpadder.update(decrypted_tar)
def parse_backup_file(data, password=None): def parse_backup_file(data, password=None):
@@ -210,8 +210,6 @@ def parse_tar_for_sms(data):
or member.name.endswith("_mms_backup") or member.name.endswith("_mms_backup")
): ):
dhandler = tar.extractfile(member) dhandler = tar.extractfile(member)
if not dhandler:
continue
res.extend(parse_sms_file(dhandler.read())) res.extend(parse_sms_file(dhandler.read()))
return res return res
@@ -233,7 +231,6 @@ def parse_sms_file(data):
entry.pop("mms_body") entry.pop("mms_body")
body = entry.get("body", None) body = entry.get("body", None)
message_links = None
if body: if body:
message_links = check_for_links(entry["body"]) message_links = check_for_links(entry["body"])
+139 -138
View File
@@ -1,12 +1,13 @@
# Generated by the protocol buffer compiler. DO NOT EDIT! # Generated by the protocol buffer compiler. DO NOT EDIT!
# sources: tombstone.proto # sources: tombstone.proto
# plugin: python-betterproto2 # plugin: python-betterproto
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List
import betterproto2 import betterproto
class Architecture(betterproto2.Enum): class Architecture(betterproto.Enum):
ARM32 = 0 ARM32 = 0
ARM64 = 1 ARM64 = 1
X86 = 2 X86 = 2
@@ -15,12 +16,12 @@ class Architecture(betterproto2.Enum):
NONE = 5 NONE = 5
class MemoryErrorTool(betterproto2.Enum): class MemoryErrorTool(betterproto.Enum):
GWP_ASAN = 0 GWP_ASAN = 0
SCUDO = 1 SCUDO = 1
class MemoryErrorType(betterproto2.Enum): class MemoryErrorType(betterproto.Enum):
UNKNOWN = 0 UNKNOWN = 0
USE_AFTER_FREE = 1 USE_AFTER_FREE = 1
DOUBLE_FREE = 2 DOUBLE_FREE = 2
@@ -29,179 +30,179 @@ class MemoryErrorType(betterproto2.Enum):
BUFFER_UNDERFLOW = 5 BUFFER_UNDERFLOW = 5
@dataclass(eq=False, repr=False) @dataclass
class CrashDetail(betterproto2.Message): class CrashDetail(betterproto.Message):
""" """
NOTE TO OEMS: If you add custom fields to this proto, do not use numbers in NOTE TO OEMS: If you add custom fields to this proto, do not use numbers in
the reserved range. the reserved range.
""" """
name: "bytes" = betterproto2.field(1, betterproto2.TYPE_BYTES) name: bytes = betterproto.bytes_field(1)
data: "bytes" = betterproto2.field(2, betterproto2.TYPE_BYTES) data: bytes = betterproto.bytes_field(2)
@dataclass(eq=False, repr=False) @dataclass
class StackHistoryBufferEntry(betterproto2.Message): class StackHistoryBufferEntry(betterproto.Message):
addr: "BacktraceFrame | None" = betterproto2.field(1, betterproto2.TYPE_MESSAGE, optional=True) addr: "BacktraceFrame" = betterproto.message_field(1)
fp: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64) fp: int = betterproto.uint64_field(2)
tag: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64) tag: int = betterproto.uint64_field(3)
@dataclass(eq=False, repr=False) @dataclass
class StackHistoryBuffer(betterproto2.Message): class StackHistoryBuffer(betterproto.Message):
tid: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64) tid: int = betterproto.uint64_field(1)
entries: "list[StackHistoryBufferEntry]" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, repeated=True) entries: List["StackHistoryBufferEntry"] = betterproto.message_field(2)
@dataclass(eq=False, repr=False) @dataclass
class Tombstone(betterproto2.Message): class Tombstone(betterproto.Message):
arch: "Architecture" = betterproto2.field(1, betterproto2.TYPE_ENUM, default_factory=lambda: Architecture(0)) arch: "Architecture" = betterproto.enum_field(1)
guest_arch: "Architecture" = betterproto2.field(24, betterproto2.TYPE_ENUM, default_factory=lambda: Architecture(0)) guest_arch: "Architecture" = betterproto.enum_field(24)
build_fingerprint: "str" = betterproto2.field(2, betterproto2.TYPE_STRING) build_fingerprint: str = betterproto.string_field(2)
revision: "str" = betterproto2.field(3, betterproto2.TYPE_STRING) revision: str = betterproto.string_field(3)
timestamp: "str" = betterproto2.field(4, betterproto2.TYPE_STRING) timestamp: str = betterproto.string_field(4)
pid: "int" = betterproto2.field(5, betterproto2.TYPE_UINT32) pid: int = betterproto.uint32_field(5)
tid: "int" = betterproto2.field(6, betterproto2.TYPE_UINT32) tid: int = betterproto.uint32_field(6)
uid: "int" = betterproto2.field(7, betterproto2.TYPE_UINT32) uid: int = betterproto.uint32_field(7)
selinux_label: "str" = betterproto2.field(8, betterproto2.TYPE_STRING) selinux_label: str = betterproto.string_field(8)
command_line: "list[str]" = betterproto2.field(9, betterproto2.TYPE_STRING, repeated=True) command_line: List[str] = betterproto.string_field(9)
# Process uptime in seconds. # Process uptime in seconds.
process_uptime: "int" = betterproto2.field(20, betterproto2.TYPE_UINT32) process_uptime: int = betterproto.uint32_field(20)
signal_info: "Signal | None" = betterproto2.field(10, betterproto2.TYPE_MESSAGE, optional=True) signal_info: "Signal" = betterproto.message_field(10)
abort_message: "str" = betterproto2.field(14, betterproto2.TYPE_STRING) abort_message: str = betterproto.string_field(14)
crash_details: "list[CrashDetail]" = betterproto2.field(21, betterproto2.TYPE_MESSAGE, repeated=True) crash_details: List["CrashDetail"] = betterproto.message_field(21)
causes: "list[Cause]" = betterproto2.field(15, betterproto2.TYPE_MESSAGE, repeated=True) causes: List["Cause"] = betterproto.message_field(15)
threads: "dict[int, Thread]" = betterproto2.field( threads: Dict[int, "Thread"] = betterproto.map_field(
16, betterproto2.TYPE_MAP, map_meta=betterproto2.map_meta(betterproto2.TYPE_UINT32, betterproto2.TYPE_MESSAGE) 16, betterproto.TYPE_UINT32, betterproto.TYPE_MESSAGE
) )
guest_threads: "dict[int, Thread]" = betterproto2.field( guest_threads: Dict[int, "Thread"] = betterproto.map_field(
25, betterproto2.TYPE_MAP, map_meta=betterproto2.map_meta(betterproto2.TYPE_UINT32, betterproto2.TYPE_MESSAGE) 25, betterproto.TYPE_UINT32, betterproto.TYPE_MESSAGE
) )
memory_mappings: "list[MemoryMapping]" = betterproto2.field(17, betterproto2.TYPE_MESSAGE, repeated=True) memory_mappings: List["MemoryMapping"] = betterproto.message_field(17)
log_buffers: "list[LogBuffer]" = betterproto2.field(18, betterproto2.TYPE_MESSAGE, repeated=True) log_buffers: List["LogBuffer"] = betterproto.message_field(18)
open_fds: "list[FD]" = betterproto2.field(19, betterproto2.TYPE_MESSAGE, repeated=True) open_fds: List["FD"] = betterproto.message_field(19)
page_size: "int" = betterproto2.field(22, betterproto2.TYPE_UINT32) page_size: int = betterproto.uint32_field(22)
has_been_16kb_mode: "bool" = betterproto2.field(23, betterproto2.TYPE_BOOL) has_been_16kb_mode: bool = betterproto.bool_field(23)
stack_history_buffer: "StackHistoryBuffer | None" = betterproto2.field(26, betterproto2.TYPE_MESSAGE, optional=True) stack_history_buffer: "StackHistoryBuffer" = betterproto.message_field(26)
@dataclass(eq=False, repr=False) @dataclass
class Signal(betterproto2.Message): class Signal(betterproto.Message):
number: "int" = betterproto2.field(1, betterproto2.TYPE_INT32) number: int = betterproto.int32_field(1)
name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING) name: str = betterproto.string_field(2)
code: "int" = betterproto2.field(3, betterproto2.TYPE_INT32) code: int = betterproto.int32_field(3)
code_name: "str" = betterproto2.field(4, betterproto2.TYPE_STRING) code_name: str = betterproto.string_field(4)
has_sender: "bool" = betterproto2.field(5, betterproto2.TYPE_BOOL) has_sender: bool = betterproto.bool_field(5)
sender_uid: "int" = betterproto2.field(6, betterproto2.TYPE_INT32) sender_uid: int = betterproto.int32_field(6)
sender_pid: "int" = betterproto2.field(7, betterproto2.TYPE_INT32) sender_pid: int = betterproto.int32_field(7)
has_fault_address: "bool" = betterproto2.field(8, betterproto2.TYPE_BOOL) has_fault_address: bool = betterproto.bool_field(8)
fault_address: "int" = betterproto2.field(9, betterproto2.TYPE_UINT64) fault_address: int = betterproto.uint64_field(9)
# Note, may or may not contain the dump of the actual memory contents. # Note, may or may not contain the dump of the actual memory contents.
# Currently, on arm64, we only include metadata, and not the 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) @dataclass
class HeapObject(betterproto2.Message): class HeapObject(betterproto.Message):
address: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64) address: int = betterproto.uint64_field(1)
size: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64) size: int = betterproto.uint64_field(2)
allocation_tid: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64) allocation_tid: int = betterproto.uint64_field(3)
allocation_backtrace: "list[BacktraceFrame]" = betterproto2.field(4, betterproto2.TYPE_MESSAGE, repeated=True) allocation_backtrace: List["BacktraceFrame"] = betterproto.message_field(4)
deallocation_tid: "int" = betterproto2.field(5, betterproto2.TYPE_UINT64) deallocation_tid: int = betterproto.uint64_field(5)
deallocation_backtrace: "list[BacktraceFrame]" = betterproto2.field(6, betterproto2.TYPE_MESSAGE, repeated=True) deallocation_backtrace: List["BacktraceFrame"] = betterproto.message_field(6)
@dataclass(eq=False, repr=False) @dataclass
class MemoryError(betterproto2.Message): class MemoryError(betterproto.Message):
tool: "MemoryErrorTool" = betterproto2.field(1, betterproto2.TYPE_ENUM, default_factory=lambda: MemoryErrorTool(0)) tool: "MemoryErrorTool" = betterproto.enum_field(1)
type: "MemoryErrorType" = betterproto2.field(2, betterproto2.TYPE_ENUM, default_factory=lambda: MemoryErrorType(0)) type: "MemoryErrorType" = betterproto.enum_field(2)
heap: "HeapObject | None" = betterproto2.field(3, betterproto2.TYPE_MESSAGE, optional=True, group="location") heap: "HeapObject" = betterproto.message_field(3, group="location")
@dataclass(eq=False, repr=False) @dataclass
class Cause(betterproto2.Message): class Cause(betterproto.Message):
human_readable: "str" = betterproto2.field(1, betterproto2.TYPE_STRING) human_readable: str = betterproto.string_field(1)
memory_error: "MemoryError | None" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, optional=True, group="details") memory_error: "MemoryError" = betterproto.message_field(2, group="details")
@dataclass(eq=False, repr=False) @dataclass
class Register(betterproto2.Message): class Register(betterproto.Message):
name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING) name: str = betterproto.string_field(1)
u64: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64) u64: int = betterproto.uint64_field(2)
@dataclass(eq=False, repr=False) @dataclass
class Thread(betterproto2.Message): class Thread(betterproto.Message):
id: "int" = betterproto2.field(1, betterproto2.TYPE_INT32) id: int = betterproto.int32_field(1)
name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING) name: str = betterproto.string_field(2)
registers: "list[Register]" = betterproto2.field(3, betterproto2.TYPE_MESSAGE, repeated=True) registers: List["Register"] = betterproto.message_field(3)
backtrace_note: "list[str]" = betterproto2.field(7, betterproto2.TYPE_STRING, repeated=True) backtrace_note: List[str] = betterproto.string_field(7)
unreadable_elf_files: "list[str]" = betterproto2.field(9, betterproto2.TYPE_STRING, repeated=True) unreadable_elf_files: List[str] = betterproto.string_field(9)
current_backtrace: "list[BacktraceFrame]" = betterproto2.field(4, betterproto2.TYPE_MESSAGE, repeated=True) current_backtrace: List["BacktraceFrame"] = betterproto.message_field(4)
memory_dump: "list[MemoryDump]" = betterproto2.field(5, betterproto2.TYPE_MESSAGE, repeated=True) memory_dump: List["MemoryDump"] = betterproto.message_field(5)
tagged_addr_ctrl: "int" = betterproto2.field(6, betterproto2.TYPE_INT64) tagged_addr_ctrl: int = betterproto.int64_field(6)
pac_enabled_keys: "int" = betterproto2.field(8, betterproto2.TYPE_INT64) pac_enabled_keys: int = betterproto.int64_field(8)
@dataclass(eq=False, repr=False) @dataclass
class BacktraceFrame(betterproto2.Message): class BacktraceFrame(betterproto.Message):
rel_pc: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64) rel_pc: int = betterproto.uint64_field(1)
pc: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64) pc: int = betterproto.uint64_field(2)
sp: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64) sp: int = betterproto.uint64_field(3)
function_name: "str" = betterproto2.field(4, betterproto2.TYPE_STRING) function_name: str = betterproto.string_field(4)
function_offset: "int" = betterproto2.field(5, betterproto2.TYPE_UINT64) function_offset: int = betterproto.uint64_field(5)
file_name: "str" = betterproto2.field(6, betterproto2.TYPE_STRING) file_name: str = betterproto.string_field(6)
file_map_offset: "int" = betterproto2.field(7, betterproto2.TYPE_UINT64) file_map_offset: int = betterproto.uint64_field(7)
build_id: "str" = betterproto2.field(8, betterproto2.TYPE_STRING) build_id: str = betterproto.string_field(8)
@dataclass(eq=False, repr=False) @dataclass
class ArmMTEMetadata(betterproto2.Message): class ArmMTEMetadata(betterproto.Message):
# One memory tag per granule (e.g. every 16 bytes) of regular memory. # 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) @dataclass
class MemoryDump(betterproto2.Message): class MemoryDump(betterproto.Message):
register_name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING) register_name: str = betterproto.string_field(1)
mapping_name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING) mapping_name: str = betterproto.string_field(2)
begin_address: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64) begin_address: int = betterproto.uint64_field(3)
memory: "bytes" = betterproto2.field(4, betterproto2.TYPE_BYTES) memory: bytes = betterproto.bytes_field(4)
arm_mte_metadata: "ArmMTEMetadata | None" = betterproto2.field(6, betterproto2.TYPE_MESSAGE, optional=True, group="metadata") arm_mte_metadata: "ArmMTEMetadata" = betterproto.message_field(6, group="metadata")
@dataclass(eq=False, repr=False) @dataclass
class MemoryMapping(betterproto2.Message): class MemoryMapping(betterproto.Message):
begin_address: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64) begin_address: int = betterproto.uint64_field(1)
end_address: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64) end_address: int = betterproto.uint64_field(2)
offset: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64) offset: int = betterproto.uint64_field(3)
read: "bool" = betterproto2.field(4, betterproto2.TYPE_BOOL) read: bool = betterproto.bool_field(4)
write: "bool" = betterproto2.field(5, betterproto2.TYPE_BOOL) write: bool = betterproto.bool_field(5)
execute: "bool" = betterproto2.field(6, betterproto2.TYPE_BOOL) execute: bool = betterproto.bool_field(6)
mapping_name: "str" = betterproto2.field(7, betterproto2.TYPE_STRING) mapping_name: str = betterproto.string_field(7)
build_id: "str" = betterproto2.field(8, betterproto2.TYPE_STRING) build_id: str = betterproto.string_field(8)
load_bias: "int" = betterproto2.field(9, betterproto2.TYPE_UINT64) load_bias: int = betterproto.uint64_field(9)
@dataclass(eq=False, repr=False) @dataclass
class FD(betterproto2.Message): class FD(betterproto.Message):
fd: "int" = betterproto2.field(1, betterproto2.TYPE_INT32) fd: int = betterproto.int32_field(1)
path: "str" = betterproto2.field(2, betterproto2.TYPE_STRING) path: str = betterproto.string_field(2)
owner: "str" = betterproto2.field(3, betterproto2.TYPE_STRING) owner: str = betterproto.string_field(3)
tag: "int" = betterproto2.field(4, betterproto2.TYPE_UINT64) tag: int = betterproto.uint64_field(4)
@dataclass(eq=False, repr=False) @dataclass
class LogBuffer(betterproto2.Message): class LogBuffer(betterproto.Message):
name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING) name: str = betterproto.string_field(1)
logs: "list[LogMessage]" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, repeated=True) logs: List["LogMessage"] = betterproto.message_field(2)
@dataclass(eq=False, repr=False) @dataclass
class LogMessage(betterproto2.Message): class LogMessage(betterproto.Message):
timestamp: "str" = betterproto2.field(1, betterproto2.TYPE_STRING) timestamp: str = betterproto.string_field(1)
pid: "int" = betterproto2.field(2, betterproto2.TYPE_UINT32) pid: int = betterproto.uint32_field(2)
tid: "int" = betterproto2.field(3, betterproto2.TYPE_UINT32) tid: int = betterproto.uint32_field(3)
priority: "int" = betterproto2.field(4, betterproto2.TYPE_UINT32) priority: int = betterproto.uint32_field(4)
tag: "str" = betterproto2.field(5, betterproto2.TYPE_STRING) tag: str = betterproto.string_field(5)
message: "str" = betterproto2.field(6, betterproto2.TYPE_STRING) message: str = betterproto.string_field(6)
+3 -2
View File
@@ -6,13 +6,14 @@ from datetime import datetime, timedelta
from typing import List from typing import List
def warn_android_patch_level(patch_level: str, log) -> str | bool: def warn_android_patch_level(patch_level: str, log) -> str:
"""Alert if Android patch level out-of-date""" """Alert if Android patch level out-of-date"""
patch_date = datetime.strptime(patch_level, "%Y-%m-%d") patch_date = datetime.strptime(patch_level, "%Y-%m-%d")
if (datetime.now() - patch_date) > timedelta(days=6 * 31): if (datetime.now() - patch_date) > timedelta(days=6 * 31):
warning_message = ( warning_message = (
f"This phone has not received security updates " f"This phone has not received security updates "
f"for more than six months (last update: {patch_level})." f"for more than six months (last update: {patch_level}).",
patch_level,
) )
return warning_message return warning_message
+15 -84
View File
@@ -4,13 +4,12 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import csv import csv
import inspect
import logging import logging
from dataclasses import asdict, dataclass
from enum import Enum from enum import Enum
from typing import Any, Dict, List, Optional from dataclasses import dataclass, asdict
from typing import List, Dict, Any, Optional
from .log import CRITICAL_ALERT, HIGH_ALERT, INFO_ALERT, LOW_ALERT, MEDIUM_ALERT from .log import INFO_ALERT, LOW_ALERT, HIGH_ALERT, CRITICAL_ALERT, MEDIUM_ALERT
from .module_types import ModuleAtomicResult from .module_types import ModuleAtomicResult
@@ -29,7 +28,6 @@ class Alert:
message: str message: str
event_time: str event_time: str
event: ModuleAtomicResult event: ModuleAtomicResult
matched_indicator: Optional[Any] = None
class AlertStore: class AlertStore:
@@ -37,138 +35,78 @@ class AlertStore:
self.__alerts: List[Alert] = [] self.__alerts: List[Alert] = []
self.__log = log self.__log = log
def _get_calling_module(self) -> str:
"""
Automatically detect the calling MVT module and return its slug.
Walks up the call stack to find the first frame that belongs to an MVT module
(artifact or extraction module) and extracts its slug.
:return: Module slug string
"""
frame = inspect.currentframe()
try:
# Walk up the call stack
while frame is not None:
frame = frame.f_back
if frame is None:
break
# Get the 'self' object from the frame's local variables
frame_locals = frame.f_locals
if "self" in frame_locals:
obj = frame_locals["self"]
# Check if it has a get_slug method (MVT modules have this)
if hasattr(obj, "get_slug") and callable(obj.get_slug):
try:
return str(obj.get_slug())
except Exception:
pass
# Fallback: return "unknown" if we can't find the module
return "unknown"
finally:
del frame
@property @property
def alerts(self) -> List[Alert]: def alerts(self) -> List[Alert]:
return self.__alerts return self.__alerts
def add(self, alert: Alert) -> None: def add(self, alert: Alert) -> None:
self.__alerts.append(alert) self.__alerts.append(alert)
self.log(alert)
def extend(self, alerts: List[Alert]) -> None: def extend(self, alerts: List[Alert]) -> None:
for alert in alerts: self.__alerts.extend(alerts)
self.add(alert)
def info( def info(
self, self, module: str, message: str, event_time: str, event: ModuleAtomicResult
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
): ):
self.add( self.add(
Alert( Alert(
level=AlertLevel.INFORMATIONAL, level=AlertLevel.INFORMATIONAL,
module=self._get_calling_module(), module=module,
message=message, message=message,
event_time=event_time, event_time=event_time,
event=event, event=event,
matched_indicator=matched_indicator,
) )
) )
def low( def low(
self, self, module: str, message: str, event_time: str, event: ModuleAtomicResult
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
): ):
self.add( self.add(
Alert( Alert(
level=AlertLevel.LOW, level=AlertLevel.LOW,
module=self._get_calling_module(), module=module,
message=message, message=message,
event_time=event_time, event_time=event_time,
event=event, event=event,
matched_indicator=matched_indicator,
) )
) )
def medium( def medium(
self, self, module: str, message: str, event_time: str, event: ModuleAtomicResult
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
): ):
self.add( self.add(
Alert( Alert(
level=AlertLevel.MEDIUM, level=AlertLevel.MEDIUM,
module=self._get_calling_module(), module=module,
message=message, message=message,
event_time=event_time, event_time=event_time,
event=event, event=event,
matched_indicator=matched_indicator,
) )
) )
def high( def high(
self, self, module: str, message: str, event_time: str, event: ModuleAtomicResult
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
): ):
self.add( self.add(
Alert( Alert(
level=AlertLevel.HIGH, level=AlertLevel.HIGH,
module=self._get_calling_module(), module=module,
message=message, message=message,
event_time=event_time, event_time=event_time,
event=event, event=event,
matched_indicator=matched_indicator,
) )
) )
def critical( def critical(
self, self, module: str, message: str, event_time: str, event: ModuleAtomicResult
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
): ):
self.add( self.add(
Alert( Alert(
level=AlertLevel.CRITICAL, level=AlertLevel.CRITICAL,
module=self._get_calling_module(), module=module,
message=message, message=message,
event_time=event_time, event_time=event_time,
event=event, event=event,
matched_indicator=matched_indicator,
) )
) )
@@ -207,19 +145,12 @@ class AlertStore:
alert_dict = asdict(alert) alert_dict = asdict(alert)
# This is required because an Enum is not JSON serializable. # This is required because an Enum is not JSON serializable.
alert_dict["level"] = alert.level.name 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) alerts.append(alert_dict)
return alerts return alerts
def save_timeline(self, timeline_path: str) -> None: def save_timeline(self, timeline_path: str) -> None:
with open(timeline_path, "w", encoding="utf-8") as handle: with open(timeline_path, "a+", encoding="utf-8") as handle:
csvoutput = csv.writer( csvoutput = csv.writer(
handle, handle,
delimiter=",", delimiter=",",
+1 -2
View File
@@ -8,6 +8,5 @@ from .module import MVTModule
class Artifact(MVTModule): class Artifact(MVTModule):
"""Base class for artifacts. """Base class for artifacts.
Artifacts share the MVTModule lifecycle so commands can run artifacts and XXX: Inheriting from MVTModule to have the same signature as other modules. Not sure if this is a good idea.
extraction modules through the same interface.
""" """
+1 -14
View File
@@ -8,7 +8,6 @@ import os
from typing import Optional from typing import Optional
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.module import MVTModule
from mvt.common.utils import exec_or_profile from mvt.common.utils import exec_or_profile
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -25,10 +24,6 @@ class CmdCheckIOCS(Command):
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
hashes: Optional[bool] = False, hashes: Optional[bool] = False,
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
platform: str = "",
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -40,18 +35,14 @@ class CmdCheckIOCS(Command):
hashes=hashes, hashes=hashes,
sub_command=sub_command, sub_command=sub_command,
log=log, log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = platform
self.name = "check-iocs" self.name = "check-iocs"
def run(self) -> None: def run(self) -> None:
assert self.target_path is not None assert self.target_path is not None
all_modules = [] all_modules = []
for entry in self._available_modules(): for entry in self.modules:
if entry not in all_modules: if entry not in all_modules:
all_modules.append(entry) all_modules.append(entry)
@@ -78,10 +69,6 @@ class CmdCheckIOCS(Command):
m = iocs_module.from_json( m = iocs_module.from_json(
file_path, log=logging.getLogger(iocs_module.__module__) file_path, log=logging.getLogger(iocs_module.__module__)
) )
if not m:
log.warning("No result from this module, skipping it")
continue
if self.iocs.total_ioc_count > 0: if self.iocs.total_ioc_count > 0:
m.indicators = self.iocs m.indicators = self.iocs
m.indicators.log = m.log m.indicators.log = m.log
+22 -125
View File
@@ -8,25 +8,20 @@ import logging
import os import os
import sys import sys
from datetime import datetime from datetime import datetime
from heapq import heappop, heappush from typing import Optional
from typing import Any, Optional
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text
from .alerts import AlertLevel, AlertStore
from .config import settings
from .indicators import Indicators from .indicators import Indicators
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline from .module import MVTModule, run_module, save_timeline
from .module_loader import module_supports_command
from .module_types import ModuleTimeline
from .utils import ( from .utils import (
CustomJSONEncoder,
convert_datetime_to_iso, convert_datetime_to_iso,
generate_hashes_from_path, generate_hashes_from_path,
get_sha256_from_file_path, get_sha256_from_file_path,
) )
from .config import settings
from .alerts import AlertStore, AlertLevel
from .version import MVT_VERSION from .version import MVT_VERSION
@@ -43,14 +38,9 @@ class Command:
hashes: Optional[bool] = False, hashes: Optional[bool] = False,
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
self.name = "" self.name = ""
self.platform = "" self.modules = []
self.modules: list[type[MVTModule]] = []
self.custom_modules = custom_modules if custom_modules else []
self.target_path = target_path self.target_path = target_path
self.results_path = results_path self.results_path = results_path
@@ -59,8 +49,6 @@ class Command:
self.serial = serial self.serial = serial
self.log = log self.log = log
self.sub_command = sub_command self.sub_command = sub_command
self.disable_version_check = disable_version_check
self.disable_indicator_check = disable_indicator_check
# This dictionary can contain options that will be passed down from # This dictionary can contain options that will be passed down from
# the Command to all modules. This can for example be used to pass # the Command to all modules. This can for example be used to pass
@@ -69,10 +57,10 @@ class Command:
# This list will contain all executed modules. # This list will contain all executed modules.
# We can use this to reference e.g. self.executed[0].results. # We can use this to reference e.g. self.executed[0].results.
self.executed: list[MVTModule] = [] self.executed = []
self.hashes = hashes self.hashes = hashes
self.hash_values: list[dict[str, Any]] = [] self.hash_values = []
self.timeline: ModuleTimeline = [] self.timeline = []
# Load IOCs # Load IOCs
self._create_storage() self._create_storage()
@@ -148,7 +136,7 @@ class Command:
alerts_path = os.path.join(self.results_path, "alerts.json") alerts_path = os.path.join(self.results_path, "alerts.json")
with open(alerts_path, "w+", encoding="utf-8") as handle: 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: def _store_alerts_timeline(self) -> None:
if not self.results_path: if not self.results_path:
@@ -161,11 +149,11 @@ class Command:
if not self.results_path: if not self.results_path:
return return
target_path: Optional[str] = None target_path = None
if self.target_path: if self.target_path:
target_path = os.path.abspath(self.target_path) target_path = os.path.abspath(self.target_path)
info: dict[str, Any] = { info = {
"target_path": target_path, "target_path": target_path,
"mvt_version": MVT_VERSION, "mvt_version": MVT_VERSION,
"date": convert_datetime_to_iso(datetime.now()), "date": convert_datetime_to_iso(datetime.now()),
@@ -203,24 +191,9 @@ class Command:
def list_modules(self) -> None: def list_modules(self) -> None:
self.log.info("Following is the list of available %s modules:", self.name) self.log.info("Following is the list of available %s modules:", self.name)
for module in self._available_modules(): for module in self.modules:
self.log.info(" - %s", module.__name__) self.log.info(" - %s", module.__name__)
def _available_modules(self) -> list[type[MVTModule]]:
modules = list(self.modules)
modules.extend(
module
for module in self.custom_modules
if module_supports_command(module, self.platform, self.name)
)
deduplicated = []
for module in modules:
if module not in deduplicated:
deduplicated.append(module)
return deduplicated
def init(self) -> None: def init(self) -> None:
raise NotImplementedError raise NotImplementedError
@@ -279,86 +252,22 @@ class Command:
console.print("") console.print("")
console.print(panel) console.print(panel)
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
"""Return enabled modules in stable topological order."""
modules = self._available_modules()
module_indexes = {module: index for index, module in enumerate(modules)}
if self.module_name:
selected = [
module for module in modules if module.__name__ == self.module_name
]
else:
selected = [module for module in modules if module.enabled]
required = set(selected)
pending = list(selected)
while pending:
module = pending.pop()
for dependency in module.dependencies:
if dependency not in module_indexes:
self.log.warning(
"Module %s depends on unavailable module %s. "
"No modules will be run.",
module.__name__,
dependency.__name__,
)
return None
if dependency not in required:
required.add(dependency)
pending.append(dependency)
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
module: [] for module in required
}
indegree = {module: 0 for module in required}
for module in required:
for dependency in module.dependencies:
if dependency not in required:
continue
dependents[dependency].append(module)
indegree[module] += 1
ready: list[tuple[int, type[MVTModule]]] = []
for module, count in indegree.items():
if count == 0:
heappush(ready, (module_indexes[module], module))
ordered = []
while ready:
_, module = heappop(ready)
ordered.append(module)
for dependent in dependents[module]:
indegree[dependent] -= 1
if indegree[dependent] == 0:
heappush(ready, (module_indexes[dependent], dependent))
if len(ordered) != len(required):
cyclic_modules = sorted(
(module.__name__ for module, count in indegree.items() if count > 0)
)
self.log.warning(
"Circular module dependency detected involving: %s. "
"No modules will be run.",
", ".join(cyclic_modules),
)
return None
return ordered
def run(self) -> None: def run(self) -> None:
ordered_modules = self._ordered_modules()
if ordered_modules is None:
return
try: try:
self.init() self.init()
except NotImplementedError: except NotImplementedError:
pass pass
executed_by_type: dict[type[MVTModule], MVTModule] = {} for module in self.modules:
for module in ordered_modules: if self.module_name and module.__name__ != self.module_name:
continue
if not module.enabled and not (
self.module_name and module.__name__ == self.module_name
):
continue
# FIXME: do we need the logger here
module_logger = logging.getLogger(module.__module__) module_logger = logging.getLogger(module.__module__)
m = module( m = module(
@@ -367,10 +276,6 @@ class Command:
module_options=self.module_options, module_options=self.module_options,
log=module_logger, log=module_logger,
) )
m.dependency_modules = {
dependency: executed_by_type[dependency]
for dependency in module.dependencies
}
if self.iocs.total_ioc_count: if self.iocs.total_ioc_count:
m.indicators = self.iocs m.indicators = self.iocs
@@ -384,17 +289,9 @@ class Command:
except NotImplementedError: except NotImplementedError:
pass pass
try: run_module(m)
run_module(m)
except EncryptedBackupError:
self.log.critical(
"The backup appears to be encrypted. "
"Please decrypt it first using `mvt-ios decrypt-backup`."
)
return
self.executed.append(m) self.executed.append(m)
executed_by_type[module] = m
self.timeline.extend(m.timeline) self.timeline.extend(m.timeline)
self.alertstore.extend(m.alertstore.alerts) self.alertstore.extend(m.alertstore.alerts)
+23 -22
View File
@@ -1,12 +1,13 @@
import json
import os import os
from typing import Optional, Tuple, Type
import yaml import yaml
import json
from typing import Tuple, Type, Optional
from appdirs import user_config_dir from appdirs import user_config_dir
from pydantic import Field from pydantic import AnyHttpUrl, Field
from pydantic_settings import ( from pydantic_settings import (
BaseSettings, BaseSettings,
InitSettingsSource,
PydanticBaseSettingsSource, PydanticBaseSettingsSource,
SettingsConfigDict, SettingsConfigDict,
YamlConfigSettingsSource, YamlConfigSettingsSource,
@@ -21,51 +22,51 @@ class MVTSettings(BaseSettings):
env_prefix="MVT_", env_prefix="MVT_",
env_nested_delimiter="_", env_nested_delimiter="_",
extra="ignore", extra="ignore",
nested_model_default_partial_updates=True,
) )
# Allow to decided if want to load environment variables # Allow to decided if want to load environment variables
load_env: bool = Field(True, exclude=True) load_env: bool = Field(True, exclude=True)
# General settings # General settings
PYPI_UPDATE_URL: str = Field( PYPI_UPDATE_URL: AnyHttpUrl = Field(
default="https://pypi.org/pypi/mvt/json", "https://pypi.org/pypi/mvt/json",
validate_default=False,
) )
NETWORK_ACCESS_ALLOWED: bool = True NETWORK_ACCESS_ALLOWED: bool = True
NETWORK_TIMEOUT: int = 15 NETWORK_TIMEOUT: int = 15
# Command default settings, all can be specified by MVT_ prefixed environment variables too. # Command default settings, all can be specified by MVT_ prefixed environment variables too.
IOS_BACKUP_PASSWORD: Optional[str] = Field( IOS_BACKUP_PASSWORD: Optional[str] = Field(
default=None, description="Default password to use to decrypt iOS backups" None, description="Default password to use to decrypt iOS backups"
) )
ANDROID_BACKUP_PASSWORD: Optional[str] = Field( ANDROID_BACKUP_PASSWORD: Optional[str] = Field(
default=None, description="Default password to use to decrypt Android backups" None, description="Default password to use to decrypt Android backups"
) )
STIX2: Optional[str] = Field( STIX2: Optional[str] = Field(
default=None, description="List of directories where STIX2 files are stored" None, description="List of directories where STIX2 files are stored"
) )
VT_API_KEY: Optional[str] = Field( VT_API_KEY: Optional[str] = Field(
default=None, description="API key to use for VirusTotal lookups" None, description="API key to use for VirusTotal lookups"
) )
PROFILE: bool = Field( PROFILE: bool = Field(False, description="Profile the execution of MVT modules")
default=False, description="Profile the execution of MVT modules" HASH_FILES: bool = Field(False, description="Should MVT hash output files")
)
HASH_FILES: bool = Field(default=False, description="Should MVT hash output files")
@classmethod @classmethod
def settings_customise_sources( def settings_customise_sources(
cls, cls,
settings_cls: Type[BaseSettings], settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource, init_settings: InitSettingsSource,
env_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource,
) -> Tuple[PydanticBaseSettingsSource, ...]: ) -> Tuple[PydanticBaseSettingsSource, ...]:
yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH) sources = (
sources: Tuple[PydanticBaseSettingsSource, ...] = ( YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH),
yaml_source,
init_settings, init_settings,
) )
# Always load env variables by default # Load env variables if enabled
sources = (env_settings,) + sources if init_settings.init_kwargs.get("load_env", True):
sources = (env_settings,) + sources
return sources return sources
def save_settings( def save_settings(
@@ -93,11 +94,11 @@ class MVTSettings(BaseSettings):
Afterwards we load the settings again, this time including the env variables. Afterwards we load the settings again, this time including the env variables.
""" """
# Set invalid env prefix to avoid loading env variables. # Set invalid env prefix to avoid loading env variables.
settings = cls(load_env=False) settings = MVTSettings(load_env=False)
settings.save_settings() settings.save_settings()
# Load the settings again with any ENV variables. # Load the settings again with any ENV variables.
settings = cls(load_env=True) settings = MVTSettings(load_env=True)
return settings return settings
-7
View File
@@ -10,17 +10,11 @@ HELP_MSG_IOC = "Path to indicators file (can be invoked multiple time)"
HELP_MSG_FAST = "Avoid running time/resource consuming features" HELP_MSG_FAST = "Avoid running time/resource consuming features"
HELP_MSG_LIST_MODULES = "Print list of available modules and exit" HELP_MSG_LIST_MODULES = "Print list of available modules and exit"
HELP_MSG_MODULE = "Name of a single module you would like to run instead of all" HELP_MSG_MODULE = "Name of a single module you would like to run instead of all"
HELP_MSG_LOAD_MODULE = (
"Load custom MVT module(s) from a Python file or folder "
"(can be invoked multiple times)"
)
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing" HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
HELP_MSG_HASHES = "Generate hashes of all the files analyzed" HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
HELP_MSG_VERBOSE = "Verbose mode" HELP_MSG_VERBOSE = "Verbose mode"
HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators" HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators"
HELP_MSG_STIX2 = "Download public STIX2 indicators" HELP_MSG_STIX2 = "Download public STIX2 indicators"
HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check"
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check"
# IOS Specific # IOS Specific
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup" HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
@@ -51,4 +45,3 @@ HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION = (
HELP_MSG_CHECK_BUGREPORT = "Check an Android Bug Report" HELP_MSG_CHECK_BUGREPORT = "Check an Android Bug Report"
HELP_MSG_CHECK_ANDROID_BACKUP = "Check an Android Backup" HELP_MSG_CHECK_ANDROID_BACKUP = "Check an Android Backup"
HELP_MSG_CHECK_ANDROIDQF = "Check data collected with AndroidQF" HELP_MSG_CHECK_ANDROIDQF = "Check data collected with AndroidQF"
HELP_MSG_CHECK_INTRUSION_LOGS = "Check Android Intrusion Logging files"
+8 -40
View File
@@ -7,15 +7,15 @@ import glob
import json import json
import logging import logging
import os import os
from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Any, Dict, Iterator, List, Optional from typing import Any, Dict, Iterator, List, Optional
from dataclasses import dataclass
import ahocorasick import ahocorasick
from appdirs import user_data_dir from appdirs import user_data_dir
from .config import settings
from .url import URL from .url import URL
from .config import settings
MVT_DATA_FOLDER = user_data_dir("mvt") MVT_DATA_FOLDER = user_data_dir("mvt")
MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators") MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
@@ -67,7 +67,9 @@ class Indicators:
if os.path.isfile(path) and path.lower().endswith(".stix2"): if os.path.isfile(path) and path.lower().endswith(".stix2"):
self.parse_stix2(path) self.parse_stix2(path)
elif os.path.isdir(path): elif os.path.isdir(path):
for file in glob.glob(os.path.join(path, "**", "*.stix2"), recursive=True): for file in glob.glob(
os.path.join(path, "**", "*.stix2", recursive=True)
):
self.parse_stix2(file) self.parse_stix2(file)
else: else:
self.log.error( self.log.error(
@@ -115,17 +117,6 @@ class Indicators:
key, value = indicator.get("pattern", "").strip("[]").split("=") key, value = indicator.get("pattern", "").strip("[]").split("=")
key = key.strip() 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": if key == "domain-name:value":
# We force domain names to lower case. # We force domain names to lower case.
self._add_indicator( self._add_indicator(
@@ -359,7 +350,7 @@ class Indicators:
@lru_cache() @lru_cache()
def get_ioc_matcher( def get_ioc_matcher(
self, ioc_type: Optional[str] = None, ioc_list: Optional[List[Indicator]] = None self, ioc_type: Optional[str] = None, ioc_list: Optional[list] = None
) -> ahocorasick.Automaton: ) -> ahocorasick.Automaton:
""" """
Build an Aho-Corasick automaton from a list of iocs (i.e indicators) Build an Aho-Corasick automaton from a list of iocs (i.e indicators)
@@ -379,9 +370,9 @@ class Indicators:
""" """
automaton = ahocorasick.Automaton() automaton = ahocorasick.Automaton()
if ioc_type: if ioc_type:
iocs: Iterator[Indicator] = self.get_iocs(ioc_type) iocs = self.get_iocs(ioc_type)
elif ioc_list: elif ioc_list:
iocs = iter(ioc_list) iocs = ioc_list
else: else:
raise ValueError("Must provide either ioc_type or ioc_list") raise ValueError("Must provide either ioc_type or ioc_list")
@@ -727,29 +718,6 @@ class Indicators:
return None return None
def check_receiver_prefix(
self, receiver_name: str
) -> Optional[IndicatorMatch]:
"""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
"""
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}"',
)
return None
def check_android_property_name( def check_android_property_name(
self, property_name: str self, property_name: str
) -> Optional[IndicatorMatch]: ) -> Optional[IndicatorMatch]:
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more