mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-23 23:58:23 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e621640b | ||
|
|
05ad7d274c | ||
|
|
70d646af78 | ||
|
|
2d547662f8 | ||
|
|
ca0bc46f11 | ||
|
|
1b03002a00 | ||
|
|
6bac787cb5 | ||
|
|
064b9fbeb9 | ||
|
|
4c1cdf5129 | ||
|
|
a08c24b02a | ||
|
|
5d696350dc |
No files matched your search
@@ -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
|
||||
@@ -7,19 +7,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: 3.9
|
||||
cache: 'pip'
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
uv sync --locked --group dev
|
||||
pip install mypy
|
||||
- name: mypy
|
||||
run: |
|
||||
make mypy
|
||||
@@ -4,8 +4,6 @@ name: Create and publish a Docker image
|
||||
# Configures this workflow to run every time a release is published.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -25,18 +23,9 @@ jobs:
|
||||
attestations: write
|
||||
id-token: write
|
||||
#
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- dockerfile: "Dockerfile"
|
||||
tag-suffix: ""
|
||||
- dockerfile: "Dockerfile.ios"
|
||||
tag-suffix: "-ios"
|
||||
- dockerfile: "Dockerfile.android"
|
||||
tag-suffix: "-android"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
@@ -47,33 +36,26 @@ jobs:
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{ is_default_branch }},suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=raw,enable=${{ github.event_name == 'release' || github.ref_type == 'tag' }},value=stable,suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=raw,enable=${{ github.event_name == 'release' }},value=${{ github.event.release.tag_name }},suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=sha,suffix=${{ matrix.platform.tag-suffix }}
|
||||
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
|
||||
# This step sets up some additional capabilities to generate the provenance and sbom attestations
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
|
||||
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
with:
|
||||
file: ${{ matrix.platform.dockerfile }}
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
|
||||
# This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)."
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v1
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
@@ -11,19 +11,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: 3.9
|
||||
cache: 'pip'
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
uv sync --locked --group dev
|
||||
pip install ruff
|
||||
- name: ruff
|
||||
run: |
|
||||
make ruff
|
||||
@@ -12,21 +12,18 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
python-version: ['3.8', '3.9', '3.10'] # , '3.11']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --locked --group dev --python ${{ matrix.python-version }}
|
||||
make install
|
||||
make test-requirements
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
set -o pipefail
|
||||
@@ -38,4 +35,4 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
pytest-coverage-path: ./pytest-coverage.txt
|
||||
junitxml-path: ./pytest.xml
|
||||
junitxml-path: ./pytest.xml
|
||||
@@ -16,15 +16,14 @@ jobs:
|
||||
- name: Run script to fetch latest iOS releases from Apple RSS feed.
|
||||
run: python3 .github/workflows/scripts/update-ios-releases.py
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
title: '[auto] Update iOS releases and versions'
|
||||
commit-message: Add new iOS versions and build numbers
|
||||
branch: auto/add-new-ios-releases
|
||||
draft: true
|
||||
body: |
|
||||
This is an automated pull request to update the iOS releases and version numbers.
|
||||
add-paths: |
|
||||
*.json
|
||||
labels: |
|
||||
automated pr
|
||||
automated pr
|
||||
+1
-4
@@ -86,9 +86,6 @@ ipython_config.py
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# uv project Python version
|
||||
!.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
@@ -141,4 +138,4 @@ dmypy.json
|
||||
.idea
|
||||
|
||||
# Sublime Text project files
|
||||
*.sublime*
|
||||
*.sublime*
|
||||
@@ -1 +0,0 @@
|
||||
3.10
|
||||
+4
-6
@@ -6,16 +6,14 @@
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: "ubuntu-24.04"
|
||||
os: "ubuntu-22.04"
|
||||
tools:
|
||||
python: "3.11"
|
||||
|
||||
mkdocs:
|
||||
configuration: mkdocs.yml
|
||||
|
||||
# Optionally set the version of Python and requirements required to build your docs
|
||||
python:
|
||||
install:
|
||||
- method: uv
|
||||
command: sync
|
||||
groups:
|
||||
- docs
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
# Base image for building libraries
|
||||
# ---------------------------------
|
||||
FROM ubuntu:22.04 AS build-base
|
||||
FROM ubuntu:22.04 as build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -22,7 +22,7 @@ RUN apt-get update \
|
||||
|
||||
# libplist
|
||||
# --------
|
||||
FROM build-base AS build-libplist
|
||||
FROM build-base as build-libplist
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
@@ -32,7 +32,7 @@ RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
|
||||
# libimobiledevice-glue
|
||||
# ---------------------
|
||||
FROM build-base AS build-libimobiledevice-glue
|
||||
FROM build-base as build-libimobiledevice-glue
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -45,7 +45,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd li
|
||||
|
||||
# libtatsu
|
||||
# --------
|
||||
FROM build-base AS build-libtatsu
|
||||
FROM build-base as build-libtatsu
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -58,7 +58,7 @@ RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
|
||||
|
||||
# libusbmuxd
|
||||
# ----------
|
||||
FROM build-base AS build-libusbmuxd
|
||||
FROM build-base as build-libusbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -72,7 +72,7 @@ RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
|
||||
|
||||
# libimobiledevice
|
||||
# ----------------
|
||||
FROM build-base AS build-libimobiledevice
|
||||
FROM build-base as build-libimobiledevice
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -88,7 +88,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimob
|
||||
|
||||
# usbmuxd
|
||||
# -------
|
||||
FROM build-base AS build-usbmuxd
|
||||
FROM build-base as build-usbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -103,8 +103,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
|
||||
|
||||
|
||||
# Create main image
|
||||
FROM ubuntu:24.04 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
FROM ubuntu:22.04 as main
|
||||
|
||||
LABEL org.opencontainers.image.url="https://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
|
||||
COPY . mvt/
|
||||
RUN uv pip install --system --break-system-packages --no-cache ./mvt \
|
||||
&& rm -rf mvt
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y git python3-pip \
|
||||
&& PIP_NO_CACHE_DIR=1 pip3 install --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
|
||||
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
# Create main image
|
||||
FROM python:3.10.14-alpine3.20 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
FROM python:3.10.14-alpine3.20 as main
|
||||
|
||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
||||
@@ -21,7 +20,7 @@ RUN apk add --no-cache \
|
||||
# Install mvt
|
||||
COPY ./ mvt
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev \
|
||||
&& uv pip install --system --no-cache ./mvt \
|
||||
&& PIP_NO_CACHE_DIR=1 pip3 install ./mvt \
|
||||
&& apk del .build-deps gcc musl-dev && rm -rf ./mvt
|
||||
|
||||
# Installing ABE
|
||||
|
||||
+9
-10
@@ -1,6 +1,6 @@
|
||||
# Base image for building libraries
|
||||
# ---------------------------------
|
||||
FROM ubuntu:22.04 AS build-base
|
||||
FROM ubuntu:22.04 as build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -22,7 +22,7 @@ RUN apt-get update \
|
||||
|
||||
# libplist
|
||||
# --------
|
||||
FROM build-base AS build-libplist
|
||||
FROM build-base as build-libplist
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
@@ -32,7 +32,7 @@ RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
|
||||
# libimobiledevice-glue
|
||||
# ---------------------
|
||||
FROM build-base AS build-libimobiledevice-glue
|
||||
FROM build-base as build-libimobiledevice-glue
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -45,7 +45,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd li
|
||||
|
||||
# libtatsu
|
||||
# --------
|
||||
FROM build-base AS build-libtatsu
|
||||
FROM build-base as build-libtatsu
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -58,7 +58,7 @@ RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
|
||||
|
||||
# libusbmuxd
|
||||
# ----------
|
||||
FROM build-base AS build-libusbmuxd
|
||||
FROM build-base as build-libusbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -72,7 +72,7 @@ RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
|
||||
|
||||
# libimobiledevice
|
||||
# ----------------
|
||||
FROM build-base AS build-libimobiledevice
|
||||
FROM build-base as build-libimobiledevice
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -88,7 +88,7 @@ RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimob
|
||||
|
||||
# usbmuxd
|
||||
# -------
|
||||
FROM build-base AS build-usbmuxd
|
||||
FROM build-base as build-usbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
@@ -104,8 +104,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
|
||||
|
||||
# Main image
|
||||
# ----------
|
||||
FROM python:3.10.14-alpine3.20 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
FROM python:3.10.14-alpine3.20 as main
|
||||
|
||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
|
||||
@@ -132,7 +131,7 @@ COPY --from=build-usbmuxd /build /
|
||||
# Install mvt using the locally checked out source
|
||||
COPY ./ mvt
|
||||
RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \
|
||||
&& uv pip install --system --no-cache ./mvt \
|
||||
&& PIP_NO_CACHE_DIR=1 pip3 install ./mvt \
|
||||
&& apk del .build-deps git gcc musl-dev && rm -rf ./mvt
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
|
||||
@@ -1,39 +1,44 @@
|
||||
PWD = $(shell pwd)
|
||||
UV ?= uv
|
||||
|
||||
autofix:
|
||||
ruff format .
|
||||
ruff check --fix .
|
||||
|
||||
check: ruff mypy
|
||||
|
||||
ruff:
|
||||
$(UV) run ruff check .
|
||||
ruff format --check .
|
||||
ruff check -q .
|
||||
|
||||
mypy:
|
||||
$(UV) run mypy
|
||||
mypy
|
||||
|
||||
test:
|
||||
$(UV) run pytest
|
||||
python3 -m pytest
|
||||
|
||||
test-ci:
|
||||
$(UV) run pytest -v
|
||||
python3 -m pytest -v
|
||||
|
||||
install:
|
||||
$(UV) sync
|
||||
python3 -m pip install --upgrade -e .
|
||||
|
||||
test-requirements:
|
||||
$(UV) sync --group dev
|
||||
python3 -m pip install --upgrade -r test-requirements.txt
|
||||
|
||||
generate-proto-parsers:
|
||||
# Generate python parsers for protobuf files
|
||||
PROTO_FILES=$$(find src/mvt/android/parsers/proto/ -iname "*.proto"); \
|
||||
$(UV) run protoc -Isrc/mvt/android/parsers/proto/ --python_betterproto2_out=src/mvt/android/parsers/proto/ $$PROTO_FILES
|
||||
protoc -Isrc/mvt/android/parsers/proto/ --python_betterproto_out=src/mvt/android/parsers/proto/ $$PROTO_FILES
|
||||
|
||||
clean:
|
||||
rm -rf $(PWD)/build $(PWD)/dist $(PWD)/src/mvt.egg-info
|
||||
|
||||
dist:
|
||||
$(UV) build
|
||||
python3 -m pip install --upgrade build
|
||||
python3 -m build
|
||||
|
||||
upload:
|
||||
$(UV) tool run twine upload dist/*
|
||||
python3 -m twine upload dist/*
|
||||
|
||||
test-upload:
|
||||
$(UV) tool run twine upload --repository testpypi dist/*
|
||||
python3 -m twine upload --repository testpypi dist/*
|
||||
@@ -4,9 +4,6 @@
|
||||
|
||||
# Mobile Verification Toolkit
|
||||
|
||||
> [!IMPORTANT]
|
||||
> We recently merged the "v3" branch. This introduced breaking changes. If you relied on mvt output in other scripts They might have broken. More details: https://github.com/mvt-project/mvt/issues/757
|
||||
|
||||
[](https://pypi.org/project/mvt/)
|
||||
[](https://docs.mvt.re/en/latest/?badge=latest)
|
||||
[](https://github.com/mvt-project/mvt/actions/workflows/tests.yml)
|
||||
@@ -41,18 +38,6 @@ MVT can be installed from sources or from [PyPI](https://pypi.org/project/mvt/)
|
||||
pip3 install mvt
|
||||
```
|
||||
|
||||
You can also install MVT from PyPI with [uv](https://docs.astral.sh/uv/). First, install uv:
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
Then install MVT as a command-line tool:
|
||||
|
||||
```bash
|
||||
uv tool install mvt
|
||||
```
|
||||
|
||||
For alternative installation options and known issues, please refer to the [documentation](https://docs.mvt.re/en/latest/install/) as well as [GitHub Issues](https://github.com/mvt-project/mvt/issues).
|
||||
|
||||
|
||||
|
||||
+1
-58
@@ -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!
|
||||
|
||||
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`.
|
||||
|
||||
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-----
|
||||
```
|
||||
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).
|
||||
+16
-19
@@ -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
|
||||
mvt-android check-androidqf /path/to/androidqf-output
|
||||
```
|
||||
The ability to analyze Android devices over ADB (`mvt-android check-adb`) has been removed from MVT due to several technical and forensic limitations.
|
||||
|
||||
For a standalone Android bug report, use:
|
||||
|
||||
```bash
|
||||
mvt-android check-bugreport /path/to/bugreport.zip
|
||||
```
|
||||
|
||||
## Reasons for Removal
|
||||
## Reasons for Deprecation
|
||||
|
||||
1. **Inconsistent Data Collection Across Devices**
|
||||
Android devices vary significantly in system architecture, security policy, and diagnostic log availability. This made it difficult to collect reliable forensic data across devices.
|
||||
Android devices vary significantly in their system architecture, security policies, and available diagnostic logs. This inconsistency makes it difficult to ensure that MVT can reliably collect necessary forensic data across all devices.
|
||||
|
||||
2. **Incomplete Forensic Data Acquisition**
|
||||
Direct ADB analysis did not retrieve a complete forensic snapshot. Critical artifacts such as full bug reports could be missing.
|
||||
The `check-adb` command did not retrieve a full forensic snapshot of all available data on the device. For example, critical logs such as the **full bugreport** were not systematically collected, leading to potential gaps in forensic analysis. This can be a serious problem in scenarios where the analyst only had one time access to the Android device.
|
||||
|
||||
3. **Duplicated Analysis Paths**
|
||||
Similar artifacts were parsed through separate ADB, AndroidQF, and bugreport modules, which made behavior harder to keep consistent.
|
||||
4. **Code Duplication and Difficulty Ensuring Consistent Behavior Across Sources**
|
||||
Similar forensic data such as "dumpsys" logs were being loaded and parsed by MVT's ADB, AndroidQF and Bugreport commands. Multiple modules were needed to handle each source format which created duplication leading to inconsistent
|
||||
behavior and difficulties in maintaining the code base.
|
||||
|
||||
4. **Workflow Consistency**
|
||||
MVT now focuses on analyzing acquired artifacts rather than interacting with live devices directly.
|
||||
5. **Alignment with iOS Workflow**
|
||||
MVT’s forensic workflow for iOS relies on pre-extracted artifacts, such as iTunes backups or filesystem dumps, rather than preforming commands or interactions directly on a live device. Removing the ADB functionality ensures a more consistent methodology across both Android and iOS mobile forensic.
|
||||
|
||||
## Alternative: Using AndroidQF for Forensic Data Collection
|
||||
|
||||
To replace the deprecated ADB-based approach, forensic analysts should use [AndroidQF](https://github.com/mvt-project/androidqf) for comprehensive data collection, followed by MVT for forensic analysis. The workflow is outlined in the MVT [Android methodology](./methodology.md)
|
||||
@@ -0,0 +1,28 @@
|
||||
# Downloading APKs from an Android phone
|
||||
|
||||
MVT allows you to attempt to download all available installed packages (APKs) from a device in order to further inspect them and potentially identify any which might be malicious in nature.
|
||||
|
||||
You can do so by launching the following command:
|
||||
|
||||
```bash
|
||||
mvt-android download-apks --output /path/to/folder
|
||||
```
|
||||
|
||||
It might take several minutes to complete.
|
||||
|
||||
!!! info
|
||||
MVT will likely warn you it was unable to download certain installed packages. There is no reason to be alarmed: this is typically expected behavior when MVT attempts to download a system package it has no privileges to access.
|
||||
|
||||
Optionally, you can decide to enable lookups of the SHA256 hash of all the extracted APKs on [VirusTotal](https://www.virustotal.com). While these lookups do not provide any conclusive assessment on all of the extracted APKs, they might highlight any known malicious ones:
|
||||
|
||||
```bash
|
||||
MVT_VT_API_KEY=<key> mvt-android download-apks --output /path/to/folder --virustotal
|
||||
```
|
||||
|
||||
Please note that in order to use VirusTotal lookups you are required to provide your own API key through the `MVT_VT_API_KEY` environment variable. You should also note that VirusTotal enforces strict API usage. Be mindful that MVT might consume your hourly search quota.
|
||||
|
||||
In case you have a previous extraction of APKs you want to later check against VirusTotal, you can do so with the following arguments:
|
||||
|
||||
```bash
|
||||
MVT_VT_API_KEY=<key> mvt-android download-apks --from-file /path/to/folder/apks.json --virustotal
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
See the [Android ADB documentation](./adb.md) for more information.
|
||||
|
||||
## Check an Android Backup (SMS messages)
|
||||
|
||||
|
||||
+2
-28
@@ -2,17 +2,9 @@
|
||||
|
||||
The Mobile Verification Toolkit team welcomes contributions of new forensic modules or other contributions which help improve the software.
|
||||
|
||||
## Local environment
|
||||
|
||||
MVT uses `uv` for dependency management. To install the project and development dependencies from the locked environment, run:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `ruff` and `mypy`. All can
|
||||
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `flake8`, `ruff` and `black`. All can
|
||||
be run automatically with:
|
||||
|
||||
```bash
|
||||
@@ -21,24 +13,6 @@ make check
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
@@ -50,4 +24,4 @@ MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_
|
||||
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
||||
```
|
||||
|
||||
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
|
||||
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
|
||||
+1
-19
@@ -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"
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -42,8 +42,9 @@ nav:
|
||||
- Records extracted by mvt-ios: "ios/records.md"
|
||||
- MVT for Android:
|
||||
- Android Forensic Methodology: "android/methodology.md"
|
||||
- Check over ADB: "android/adb.md"
|
||||
- Check an Android Backup (SMS messages): "android/backup.md"
|
||||
- Check Android Intrusion Logs: "android/intrusion_logs.md"
|
||||
- Download APKs: "android/download_apks.md"
|
||||
- Indicators of Compromise: "iocs.md"
|
||||
- Development: "development.md"
|
||||
- License: "license.md"
|
||||
+42
-62
@@ -1,11 +1,13 @@
|
||||
[project]
|
||||
name = "mvt"
|
||||
dynamic = ["version"]
|
||||
authors = [{ name = "Claudio Guarnieri", email = "nex@nex.sx" }]
|
||||
authors = [
|
||||
{name = "Claudio Guarnieri", email = "nex@nex.sx"}
|
||||
]
|
||||
maintainers = [
|
||||
{ name = "Etienne Maynier", email = "tek@randhome.io" },
|
||||
{ name = "Donncha Ó Cearbhaill", email = "donncha.ocearbhaill@amnesty.org" },
|
||||
{ name = "Rory Flynn", email = "rory.flynn@amnesty.org" },
|
||||
{name = "Etienne Maynier", email = "tek@randhome.io"},
|
||||
{name = "Donncha Ó Cearbhaill", email = "donncha.ocearbhaill@amnesty.org"},
|
||||
{name = "Rory Flynn", email = "rory.flynn@amnesty.org"}
|
||||
]
|
||||
description = "Mobile Verification Toolkit"
|
||||
readme = "README.md"
|
||||
@@ -14,86 +16,64 @@ classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Information Technology",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python"
|
||||
]
|
||||
dependencies = [
|
||||
"click==8.3.2",
|
||||
"rich==14.3.3",
|
||||
"tld==0.13.1",
|
||||
"requests==2.33.1",
|
||||
"simplejson==3.20.2",
|
||||
"packaging==26.0",
|
||||
"appdirs==1.4.4",
|
||||
"iOSbackup==0.9.925",
|
||||
"adb-shell[usb]==0.4.4",
|
||||
"libusb1==3.3.1",
|
||||
"cryptography==47.0.0",
|
||||
"PyYAML>=6.0.2",
|
||||
"pyahocorasick==2.2.0",
|
||||
"betterproto2==0.9.1",
|
||||
"pydantic==2.13.3",
|
||||
"pydantic-settings==2.13.1",
|
||||
"NSKeyedUnArchiver==1.5.2",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"tzdata==2026.2",
|
||||
"click >=8.1.3",
|
||||
"rich >=12.6.0",
|
||||
"tld >=0.12.6",
|
||||
"requests >=2.28.1",
|
||||
"simplejson >=3.17.6",
|
||||
"packaging >=21.3",
|
||||
"appdirs >=1.4.4",
|
||||
"iOSbackup >=0.9.923",
|
||||
"cryptography >=42.0.5",
|
||||
"pyyaml >=6.0",
|
||||
"pyahocorasick >= 2.0.0",
|
||||
"betterproto >=1.2.0",
|
||||
"pydantic >= 2.10.0",
|
||||
"pydantic-settings >= 2.7.0",
|
||||
'backports.zoneinfo; python_version < "3.9"',
|
||||
]
|
||||
requires-python = ">= 3.10"
|
||||
requires-python = ">= 3.8"
|
||||
|
||||
[project.urls]
|
||||
homepage = "https://docs.mvt.re/en/latest/"
|
||||
repository = "https://github.com/mvt-project/mvt"
|
||||
|
||||
[project.scripts]
|
||||
mvt-ios = "mvt.ios: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",
|
||||
]
|
||||
mvt-ios = "mvt.ios:cli"
|
||||
mvt-android = "mvt.android:cli"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = ["tests/*"]
|
||||
omit = [
|
||||
"tests/*",
|
||||
]
|
||||
|
||||
[tool.coverage.html]
|
||||
directory = "htmlcov"
|
||||
directory= "htmlcov"
|
||||
|
||||
[tool.mypy]
|
||||
install_types = false
|
||||
install_types = true
|
||||
non_interactive = true
|
||||
ignore_missing_imports = true
|
||||
packages = "src"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-ra -q --cov=mvt --cov-report html --junitxml=pytest.xml --cov-report=term-missing:skip-covered"
|
||||
testpaths = ["tests"]
|
||||
testpaths = [
|
||||
"tests"
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = ["C90", "E", "F", "W"] # flake8 default set
|
||||
lint.ignore = [
|
||||
"E501", # don't enforce line length violations
|
||||
"C901", # complex-structure
|
||||
[tool.ruff.lint]
|
||||
select = ["C90", "E", "F", "W"] # flake8 default set
|
||||
ignore = [
|
||||
"E501", # don't enforce line length violations
|
||||
"C901", # complex-structure
|
||||
|
||||
# These were previously ignored but don't seem to be required:
|
||||
# "E265", # no-space-after-block-comment
|
||||
@@ -105,14 +85,14 @@ lint.ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"__init__.py" = ["F401"] # unused-import
|
||||
"__init__.py" = ["F401"] # unused-import
|
||||
|
||||
[tool.ruff.lint.mccabe]
|
||||
max-complexity = 10
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
package-dir = { "" = "src" }
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -121,4 +101,4 @@ where = ["src"]
|
||||
mvt = ["ios/data/*.json"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "mvt.common.version.MVT_VERSION" }
|
||||
version = {attr = "mvt.common.version.MVT_VERSION"}
|
||||
@@ -20,39 +20,23 @@ class AndroidArtifact(Artifact):
|
||||
:param binary: whether the dumpsys should be pared as binary or not (bool)
|
||||
:return: section extracted (string or bytes)
|
||||
"""
|
||||
lines = []
|
||||
in_section = False
|
||||
delimiter_str = "------------------------------------------------------------------------------"
|
||||
delimiter_bytes = b"------------------------------------------------------------------------------"
|
||||
|
||||
delimiter = "------------------------------------------------------------------------------"
|
||||
if binary:
|
||||
lines_bytes = []
|
||||
for line in dumpsys.splitlines(): # type: ignore[union-attr]
|
||||
if line.strip() == separator: # type: ignore[arg-type]
|
||||
in_section = True
|
||||
continue
|
||||
delimiter = delimiter.encode("utf-8")
|
||||
|
||||
if not in_section:
|
||||
continue
|
||||
for line in dumpsys.splitlines():
|
||||
if line.strip() == separator:
|
||||
in_section = True
|
||||
continue
|
||||
|
||||
if line.strip().startswith(delimiter_bytes): # type: ignore[arg-type]
|
||||
break
|
||||
if not in_section:
|
||||
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]
|
||||
else:
|
||||
lines_str = []
|
||||
for line in dumpsys.splitlines(): # type: ignore[union-attr]
|
||||
if line.strip() == separator: # type: ignore[arg-type]
|
||||
in_section = True
|
||||
continue
|
||||
lines.append(line)
|
||||
|
||||
if not in_section:
|
||||
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]
|
||||
return b"\n".join(lines) if binary else "\n".join(lines)
|
||||
@@ -10,20 +10,15 @@ from .artifact import AndroidArtifact
|
||||
|
||||
class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
def check_indicators(self) -> None:
|
||||
for result in self.results:
|
||||
if self.indicators:
|
||||
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
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
self.alertstore.medium(
|
||||
f'Found accessibility service: "{result["service"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_app_id(result["package_name"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
continue
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
@@ -84,22 +83,17 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
return keystore
|
||||
|
||||
@staticmethod
|
||||
def calculate_key_info(user_key: bytes) -> dict:
|
||||
def calculate_key_info(user_key: bytes) -> str:
|
||||
if b" " in user_key:
|
||||
key_base64, user = user_key.split(b" ", 1)
|
||||
else:
|
||||
key_base64, user = user_key, b""
|
||||
|
||||
try:
|
||||
key_raw = base64.b64decode(key_base64)
|
||||
key_fingerprint = hashlib.md5(key_raw).hexdigest().upper()
|
||||
key_fingerprint_colon = ":".join(
|
||||
[key_fingerprint[i : i + 2] for i in range(0, len(key_fingerprint), 2)]
|
||||
)
|
||||
except binascii.Error:
|
||||
# Impossible to parse base64
|
||||
key_fingerprint_colon = ""
|
||||
|
||||
key_raw = base64.b64decode(key_base64)
|
||||
key_fingerprint = hashlib.md5(key_raw).hexdigest().upper()
|
||||
key_fingerprint_colon = ":".join(
|
||||
[key_fingerprint[i : i + 2] for i in range(0, len(key_fingerprint), 2)]
|
||||
)
|
||||
return {
|
||||
"user": user.decode("utf-8"),
|
||||
"fingerprint": key_fingerprint_colon,
|
||||
@@ -131,17 +125,10 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
)
|
||||
return
|
||||
|
||||
start_of_json = content.find(b"\n{")
|
||||
if start_of_json == -1:
|
||||
self.log.error("Unable to find ADB manager state in dumpsys output")
|
||||
return
|
||||
|
||||
end_of_json = content.rfind(b"}\n")
|
||||
if end_of_json == -1 or end_of_json <= start_of_json:
|
||||
self.log.error("Unable to find complete ADB manager state in dumpsys output")
|
||||
return
|
||||
|
||||
json_content = content[start_of_json + 2 : end_of_json - 2].rstrip()
|
||||
# TODO: Parse AdbDebuggingManager line in output.
|
||||
start_of_json = content.find(b"\n{") + 2
|
||||
end_of_json = content.rfind(b"}\n") - 2
|
||||
json_content = content[start_of_json:end_of_json].rstrip()
|
||||
|
||||
parsed = self.indented_dump_parser(json_content)
|
||||
if parsed.get("debugging_manager") is None:
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
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.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
RISKY_PERMISSIONS = ["REQUEST_INSTALL_PACKAGES"]
|
||||
RISKY_PACKAGES = ["com.android.shell"]
|
||||
|
||||
@@ -45,8 +45,9 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
if self.indicators:
|
||||
ioc_match = self.indicators.check_app_id(result.get("package_name"))
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
self.get_slug(), ioc_match.message, "", result
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -65,6 +66,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
cleaned_result = result.copy()
|
||||
cleaned_result["permissions"] = [perm]
|
||||
self.alertstore.medium(
|
||||
self.get_slug(),
|
||||
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
|
||||
entry["timestamp"],
|
||||
cleaned_result,
|
||||
@@ -78,6 +80,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
cleaned_result = result.copy()
|
||||
cleaned_result["permissions"] = [perm]
|
||||
self.alertstore.medium(
|
||||
self.get_slug(),
|
||||
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
|
||||
entry["timestamp"],
|
||||
cleaned_result,
|
||||
@@ -85,9 +88,9 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
# self.results: List[Dict[str, Any]] = []
|
||||
perm: dict[str, Any] = {}
|
||||
package: dict[str, Any] = {}
|
||||
entry: dict[str, Any] = {}
|
||||
perm = {}
|
||||
package = {}
|
||||
entry = {}
|
||||
uid = None
|
||||
in_packages = False
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
# 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 mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
from mvt.common.module_types import ModuleSerializedResult, ModuleAtomicResult
|
||||
|
||||
|
||||
class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
@@ -16,23 +14,12 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
"""
|
||||
|
||||
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 {
|
||||
"timestamp": record["from"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "battery_daily",
|
||||
"data": data,
|
||||
"data": f"Recorded update of package {record['package_name']} "
|
||||
f"with vers {record['vers']}",
|
||||
}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
@@ -42,17 +29,13 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
for result in self.results:
|
||||
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
|
||||
)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
continue
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
daily = None
|
||||
daily_updates: list[dict[str, Any]] = []
|
||||
package_versions: dict[
|
||||
str, str
|
||||
] = {} # Track package versions to detect downgrades
|
||||
daily_updates = []
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" Daily from "):
|
||||
if len(daily_updates) > 0:
|
||||
@@ -81,43 +64,15 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
break
|
||||
|
||||
if not already_seen:
|
||||
update_record: dict[str, Any] = {
|
||||
"action": "update",
|
||||
"from": daily["from"],
|
||||
"to": daily["to"],
|
||||
"package_name": package_name,
|
||||
"vers": vers_nr,
|
||||
}
|
||||
|
||||
# Check for uninstall (version 0)
|
||||
if vers_nr == "0":
|
||||
self.alertstore.medium(
|
||||
f"Detected uninstall of package {package_name} (vers 0)",
|
||||
daily["from"],
|
||||
update_record,
|
||||
)
|
||||
# Check for downgrade
|
||||
elif package_name in package_versions:
|
||||
try:
|
||||
current_vers = int(vers_nr)
|
||||
previous_vers = int(package_versions[package_name])
|
||||
if current_vers < previous_vers:
|
||||
update_record["action"] = "downgrade"
|
||||
update_record["previous_vers"] = str(previous_vers)
|
||||
self.alertstore.medium(
|
||||
f"Detected downgrade of package {package_name} "
|
||||
f"from vers {previous_vers} to vers {current_vers}",
|
||||
daily["from"],
|
||||
update_record,
|
||||
)
|
||||
except ValueError:
|
||||
# If version numbers aren't integers, skip comparison
|
||||
pass
|
||||
|
||||
# Update tracking dictionary
|
||||
package_versions[package_name] = vers_nr
|
||||
|
||||
daily_updates.append(update_record)
|
||||
daily_updates.append(
|
||||
{
|
||||
"action": "update",
|
||||
"from": daily["from"],
|
||||
"to": daily["to"],
|
||||
"package_name": package_name,
|
||||
"vers": vers_nr,
|
||||
}
|
||||
)
|
||||
|
||||
if len(daily_updates) > 0:
|
||||
self.results.extend(daily_updates)
|
||||
@@ -18,9 +18,8 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact):
|
||||
for result in self.results:
|
||||
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
|
||||
)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
continue
|
||||
|
||||
def parse(self, data: str) -> None:
|
||||
|
||||
@@ -22,8 +22,9 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
for part in path.split("/"):
|
||||
ioc_match = self.indicators.check_app_id(part)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
self.get_slug(), ioc_match.message, "", result
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
|
||||
for activity in self.results:
|
||||
ioc_match = self.indicators.check_app_id(activity["package_name"])
|
||||
if ioc_match:
|
||||
activity["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", activity, matched_indicator=ioc_match.ioc
|
||||
self.get_slug(), ioc_match.message, "", activity
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
@@ -7,24 +7,23 @@ import re
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from mvt.android.utils import ROOT_PACKAGES
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
|
||||
|
||||
class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
def check_indicators(self) -> None:
|
||||
alerted_root_packages = set()
|
||||
for result in self.results:
|
||||
# XXX: De-duplication Package detections
|
||||
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.get_slug(),
|
||||
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
if not self.indicators:
|
||||
@@ -32,9 +31,9 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
|
||||
ioc_match = self.indicators.check_app_id(result.get("package_name", ""))
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
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:
|
||||
records = []
|
||||
@@ -64,15 +63,15 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
"""
|
||||
Parse one entry of a dumpsys package information
|
||||
"""
|
||||
details: Dict[str, Any] = {
|
||||
details = {
|
||||
"uid": "",
|
||||
"version_name": "",
|
||||
"version_code": "",
|
||||
"timestamp": "",
|
||||
"first_install_time": "",
|
||||
"last_update_time": "",
|
||||
"permissions": list(),
|
||||
"requested_permissions": list(),
|
||||
"permissions": [],
|
||||
"requested_permissions": [],
|
||||
}
|
||||
in_install_permissions = False
|
||||
in_runtime_permissions = False
|
||||
@@ -150,7 +149,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
results = []
|
||||
package_name = None
|
||||
package = {}
|
||||
lines: list[str] = []
|
||||
lines = []
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" Package ["):
|
||||
if len(lines) > 0:
|
||||
@@ -191,7 +190,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
package = []
|
||||
|
||||
in_package_list = False
|
||||
for line in content.splitlines():
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("Packages:"):
|
||||
in_package_list = True
|
||||
continue
|
||||
|
||||
@@ -18,9 +18,8 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
|
||||
for result in self.results:
|
||||
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
|
||||
)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
continue
|
||||
|
||||
def parse(self, data: str) -> None:
|
||||
|
||||
@@ -52,16 +52,14 @@ class DumpsysReceiversArtifact(AndroidArtifact):
|
||||
|
||||
ioc_match = self.indicators.check_app_id(receiver["package_name"])
|
||||
if ioc_match:
|
||||
receiver["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
{intent: receiver},
|
||||
matched_indicator=ioc_match.ioc,
|
||||
self.get_slug(), ioc_match.message, "", {intent: receiver}
|
||||
)
|
||||
continue
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
self.results: dict[str, list[dict[str, str]]] = {}
|
||||
self.results = {}
|
||||
|
||||
in_receiver_resolver_table = False
|
||||
in_non_data_actions = False
|
||||
|
||||
@@ -39,10 +39,10 @@ class GetProp(AndroidArtifact):
|
||||
if not matches or len(matches[0]) != 2:
|
||||
continue
|
||||
|
||||
prop_entry = {"name": matches[0][0], "value": matches[0][1]}
|
||||
self.results.append(prop_entry)
|
||||
entry = {"name": matches[0][0], "value": matches[0][1]}
|
||||
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
|
||||
|
||||
@@ -60,8 +60,7 @@ class GetProp(AndroidArtifact):
|
||||
|
||||
if entry["name"] == "ro.build.version.security_patch":
|
||||
warning_message = warn_android_patch_level(entry["value"], self.log)
|
||||
if isinstance(warning_message, str):
|
||||
self.alertstore.medium(warning_message, "", entry)
|
||||
self.alertstore.medium(self.get_slug(), warning_message, "", entry)
|
||||
|
||||
if not self.indicators:
|
||||
return
|
||||
@@ -71,6 +70,5 @@ class GetProp(AndroidArtifact):
|
||||
result.get("name", "")
|
||||
)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -8,7 +8,7 @@ from .artifact import AndroidArtifact
|
||||
|
||||
class Processes(AndroidArtifact):
|
||||
def parse(self, entry: str) -> None:
|
||||
for line in entry.splitlines()[1:]:
|
||||
for line in entry.split("\n")[1:]:
|
||||
proc = line.split()
|
||||
|
||||
# Skip empty lines
|
||||
@@ -60,13 +60,11 @@ class Processes(AndroidArtifact):
|
||||
|
||||
ioc_match = self.indicators.check_app_id(proc_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_process(proc_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
@@ -51,6 +51,11 @@ ANDROID_DANGEROUS_SETTINGS = [
|
||||
"key": "send_action_app_error",
|
||||
"safe_value": "1",
|
||||
},
|
||||
{
|
||||
"description": "enabled installation of non Google Play apps",
|
||||
"key": "install_non_market_apps",
|
||||
"safe_value": "0",
|
||||
},
|
||||
{
|
||||
"description": "enabled accessibility services",
|
||||
"key": "accessibility_enabled",
|
||||
@@ -67,14 +72,11 @@ class Settings(AndroidArtifact):
|
||||
# Check if one of the dangerous settings is using an unsafe
|
||||
# value (different than the one specified).
|
||||
if danger["key"] == key and danger["safe_value"] != value:
|
||||
self.alertstore.medium(
|
||||
f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})',
|
||||
"",
|
||||
{
|
||||
"namespace": namespace,
|
||||
"key": key,
|
||||
"value": value,
|
||||
"description": danger["description"],
|
||||
},
|
||||
self.log.warning(
|
||||
'Found suspicious "%s" setting "%s = %s" (%s)',
|
||||
namespace,
|
||||
key,
|
||||
value,
|
||||
danger["description"],
|
||||
)
|
||||
break
|
||||
@@ -7,15 +7,14 @@ import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import pydantic
|
||||
import betterproto2
|
||||
from dateutil import parser
|
||||
import betterproto
|
||||
|
||||
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.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from mvt.android.parsers.proto.tombstone import Tombstone
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
TOMBSTONE_DELIMITER = "*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***"
|
||||
|
||||
# Map the legacy crash file keys to the new format.
|
||||
@@ -54,7 +53,7 @@ class TombstoneCrashResult(pydantic.BaseModel):
|
||||
file_name: str
|
||||
file_timestamp: str # We store the timestamp as a string to avoid timezone issues
|
||||
build_fingerprint: str
|
||||
revision: str
|
||||
revision: int
|
||||
arch: Optional[str] = None
|
||||
timestamp: str # We store the timestamp as a string to avoid timezone issues
|
||||
process_uptime: Optional[int] = None
|
||||
@@ -64,14 +63,14 @@ class TombstoneCrashResult(pydantic.BaseModel):
|
||||
process_name: Optional[str] = None
|
||||
binary_path: Optional[str] = None
|
||||
selinux_label: Optional[str] = None
|
||||
uid: int
|
||||
uid: Optional[int] = None
|
||||
signal_info: SignalInfo
|
||||
cause: Optional[str] = None
|
||||
extra: Optional[str] = None
|
||||
|
||||
|
||||
class TombstoneCrashArtifact(AndroidArtifact):
|
||||
"""
|
||||
""" "
|
||||
Parser for Android 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:
|
||||
ioc_match = self.indicators.check_process(result["process_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
continue
|
||||
|
||||
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)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
self.get_slug(), ioc_match.message, "", result
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -116,6 +115,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
]
|
||||
if result["uid"] in SUSPICIOUS_UIDS:
|
||||
self.alertstore.medium(
|
||||
self.get_slug(),
|
||||
(
|
||||
f"Potentially suspicious crash in process '{result['process_name']}' "
|
||||
f"running as UID '{result['uid']}' in tombstone '{result['file_name']}' at {result['timestamp']}"
|
||||
@@ -127,11 +127,11 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
def parse_protobuf(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
|
||||
) -> 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_dict = tombstone_pb.to_dict(
|
||||
casing=betterproto2.Casing.SNAKE, include_default_values=True
|
||||
)
|
||||
tombstone_dict = tombstone_pb.to_dict(betterproto.Casing.SNAKE)
|
||||
|
||||
# Add some extra metadata
|
||||
tombstone_dict["timestamp"] = self._parse_timestamp_string(
|
||||
@@ -148,23 +148,21 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
def parse(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
|
||||
) -> None:
|
||||
"""Parse text Android tombstone crash files."""
|
||||
"""
|
||||
Parse text Android tombstone crash files.
|
||||
"""
|
||||
|
||||
# Split the tombstone file into a dictonary
|
||||
tombstone_dict = {
|
||||
"file_name": file_name,
|
||||
"file_timestamp": convert_datetime_to_iso(file_timestamp),
|
||||
}
|
||||
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:
|
||||
continue
|
||||
try:
|
||||
for key, destination_key in TOMBSTONE_TEXT_KEY_MAPPINGS.items():
|
||||
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)}")
|
||||
for key, destination_key in TOMBSTONE_TEXT_KEY_MAPPINGS.items():
|
||||
self._parse_tombstone_line(line, key, destination_key, tombstone_dict)
|
||||
|
||||
# Validate the tombstone and add it to the results
|
||||
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
|
||||
@@ -174,7 +172,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
self, line: str, key: str, destination_key: str, tombstone: dict
|
||||
) -> bool:
|
||||
if not line.startswith(f"{key}"):
|
||||
return False
|
||||
return None
|
||||
|
||||
if key == "pid":
|
||||
return self._load_pid_line(line, tombstone)
|
||||
@@ -193,63 +191,64 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
raise ValueError(f"Expected key {key}, got {line_key}")
|
||||
|
||||
value_clean = value.strip().strip("'")
|
||||
if destination_key == "uid":
|
||||
if destination_key in ["uid", "revision"]:
|
||||
tombstone[destination_key] = int(value_clean)
|
||||
elif destination_key == "process_uptime":
|
||||
# eg. "Process uptime: 40s"
|
||||
tombstone[destination_key] = int(value_clean.rstrip("s"))
|
||||
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]
|
||||
else:
|
||||
tombstone[destination_key] = value_clean
|
||||
return True
|
||||
|
||||
def _load_pid_line(self, line: str, tombstone: dict) -> bool:
|
||||
try:
|
||||
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
|
||||
process_info = parts[0]
|
||||
pid_part, tid_part, name_part = [part.strip() for part in line.split(",")]
|
||||
|
||||
# Parse pid, tid, name from process info
|
||||
info_parts = [p.strip() for p in process_info.split(",")]
|
||||
for info in info_parts:
|
||||
key, value = info.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
pid_key, pid_value = pid_part.split(":", 1)
|
||||
if pid_key != "pid":
|
||||
raise ValueError(f"Expected key pid, got {pid_key}")
|
||||
pid_value = int(pid_value.strip())
|
||||
|
||||
if key == "pid":
|
||||
tombstone["pid"] = int(value)
|
||||
elif key == "tid":
|
||||
tombstone["tid"] = int(value)
|
||||
elif key == "name":
|
||||
tombstone["process_name"] = value
|
||||
tid_key, tid_value = tid_part.split(":", 1)
|
||||
if tid_key != "tid":
|
||||
raise ValueError(f"Expected key tid, got {tid_key}")
|
||||
tid_value = int(tid_value.strip())
|
||||
|
||||
# Extract binary path if it exists
|
||||
if len(parts) > 1:
|
||||
tombstone["binary_path"] = parts[1].strip().rstrip(" <")
|
||||
name_key, name_value = name_part.split(":", 1)
|
||||
if name_key != "name":
|
||||
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:
|
||||
raise ValueError(f"Failed to parse PID line: {str(e)}")
|
||||
def _parse_process_name(self, process_name_part, tombstone: dict) -> bool:
|
||||
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:
|
||||
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]:
|
||||
match = part.split(prefix)[1]
|
||||
number = int(match.split()[0])
|
||||
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 ")
|
||||
code_part = code.split("code ")[1]
|
||||
code_number, code_name = code_part.split(" ")
|
||||
code_name = code_name.strip("()")
|
||||
|
||||
tombstone["signal_info"] = {
|
||||
"code": code_number,
|
||||
"code": int(code_number),
|
||||
"code_name": code_name,
|
||||
"name": signal_name,
|
||||
"number": signal_number,
|
||||
"number": int(signal_code),
|
||||
}
|
||||
return True
|
||||
|
||||
@@ -260,8 +259,14 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp_string(timestamp: str) -> str:
|
||||
timestamp_parsed = parser.parse(timestamp)
|
||||
# Preserve the source wall-clock time while returning the project-wide ISO format.
|
||||
timestamp_date, timezone = timestamp.split("+")
|
||||
# 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)
|
||||
return convert_datetime_to_iso(local_timestamp)
|
||||
|
||||
|
||||
+17
-130
@@ -9,39 +9,35 @@ import click
|
||||
|
||||
from mvt.common.cmd_check_iocs import CmdCheckIOCS
|
||||
from mvt.common.help import (
|
||||
HELP_MSG_ANDROID_BACKUP_PASSWORD,
|
||||
HELP_MSG_CHECK_ADB_REMOVED,
|
||||
HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION,
|
||||
HELP_MSG_CHECK_ANDROID_BACKUP,
|
||||
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_VERSION,
|
||||
HELP_MSG_OUTPUT,
|
||||
HELP_MSG_VERBOSE,
|
||||
HELP_MSG_IOC,
|
||||
HELP_MSG_LIST_MODULES,
|
||||
HELP_MSG_MODULE,
|
||||
HELP_MSG_NONINTERACTIVE,
|
||||
HELP_MSG_OUTPUT,
|
||||
HELP_MSG_ANDROID_BACKUP_PASSWORD,
|
||||
HELP_MSG_CHECK_ADB_REMOVED,
|
||||
HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION,
|
||||
HELP_MSG_CHECK_BUGREPORT,
|
||||
HELP_MSG_CHECK_ANDROID_BACKUP,
|
||||
HELP_MSG_CHECK_ANDROIDQF,
|
||||
HELP_MSG_HASHES,
|
||||
HELP_MSG_CHECK_IOCS,
|
||||
HELP_MSG_STIX2,
|
||||
HELP_MSG_VERBOSE,
|
||||
HELP_MSG_VERSION,
|
||||
)
|
||||
from mvt.common.logo import logo
|
||||
from mvt.common.updates import IndicatorsUpdates
|
||||
from mvt.common.utils import init_logging, set_verbose_logging
|
||||
|
||||
|
||||
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
||||
from .cmd_check_backup import CmdAndroidCheckBackup
|
||||
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.helpers import cli_load_android_backup_password
|
||||
from .modules.bugreport import BUGREPORT_MODULES
|
||||
from .modules.androidqf import ANDROIDQF_MODULES
|
||||
|
||||
init_logging()
|
||||
log = logging.getLogger("mvt")
|
||||
@@ -49,37 +45,12 @@ log = logging.getLogger("mvt")
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Main
|
||||
# ==============================================================================
|
||||
@click.group(invoke_without_command=False)
|
||||
@click.option(
|
||||
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
|
||||
)
|
||||
@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,
|
||||
)
|
||||
def cli():
|
||||
logo()
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -99,7 +70,6 @@ def version():
|
||||
@click.pass_context
|
||||
def check_adb(ctx):
|
||||
log.error(HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION)
|
||||
ctx.exit(1)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -131,8 +101,6 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
|
||||
ioc_files=iocs,
|
||||
module_name=module,
|
||||
hashes=True,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -191,8 +159,6 @@ def check_backup(
|
||||
"interactive": not non_interactive,
|
||||
"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],
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -253,8 +219,6 @@ def check_androidqf(
|
||||
"interactive": not non_interactive,
|
||||
"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],
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -269,75 +233,6 @@ def check_androidqf(
|
||||
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(
|
||||
"--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,
|
||||
timezone,
|
||||
verbose,
|
||||
logs_path,
|
||||
):
|
||||
set_verbose_logging(verbose)
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
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
|
||||
# ==============================================================================
|
||||
@@ -355,16 +250,8 @@ def check_intrusion_logs(
|
||||
@click.argument("FOLDER", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_iocs(ctx, iocs, list_modules, module, folder):
|
||||
cmd = CmdCheckIOCS(
|
||||
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],
|
||||
)
|
||||
cmd.modules = (
|
||||
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
|
||||
)
|
||||
cmd = CmdCheckIOCS(target_path=folder, ioc_files=iocs, module_name=module)
|
||||
cmd.modules = BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
|
||||
@@ -5,19 +5,16 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
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
|
||||
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
|
||||
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.base import AndroidQFModule
|
||||
|
||||
@@ -48,8 +45,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
module_options: Optional[dict] = None,
|
||||
hashes: Optional[bool] = False,
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -62,8 +57,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
hashes=hashes,
|
||||
sub_command=sub_command,
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
)
|
||||
|
||||
self.name = "check-androidqf"
|
||||
@@ -143,57 +136,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
|
||||
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:
|
||||
bugreport = None
|
||||
try:
|
||||
bugreport = self.load_bugreport()
|
||||
except NoAndroidQFBugReport:
|
||||
@@ -220,8 +163,6 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
if bugreport:
|
||||
bugreport.close()
|
||||
|
||||
return True
|
||||
|
||||
def run_backup_cmd(self) -> bool:
|
||||
try:
|
||||
backup = self.load_backup()
|
||||
@@ -230,102 +171,29 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
"Skipping backup modules as no backup.ab found in AndroidQF data."
|
||||
)
|
||||
return False
|
||||
|
||||
cmd = CmdAndroidCheckBackup(
|
||||
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,
|
||||
)
|
||||
cmd.from_ab(backup)
|
||||
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,
|
||||
else:
|
||||
cmd = CmdAndroidCheckBackup(
|
||||
target_path=None,
|
||||
results_path=self.results_path,
|
||||
ioc_files=self.ioc_files,
|
||||
iocs=self.iocs,
|
||||
module_options=adv_module_options,
|
||||
module_options=self.module_options,
|
||||
hashes=self.hashes,
|
||||
sub_command=True,
|
||||
)
|
||||
cmd.from_ab(backup)
|
||||
cmd.run()
|
||||
|
||||
self.timeline.extend(cmd.timeline)
|
||||
self.alertstore.extend(cmd.alertstore.alerts)
|
||||
return True
|
||||
|
||||
finally:
|
||||
if temp_dir:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
if backup:
|
||||
backup.close()
|
||||
|
||||
|
||||
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_backup_cmd()
|
||||
self.run_intrusion_logs_cmd()
|
||||
@@ -39,8 +39,6 @@ class CmdAndroidCheckBackup(Command):
|
||||
module_options: Optional[dict] = None,
|
||||
hashes: Optional[bool] = False,
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -53,8 +51,6 @@ class CmdAndroidCheckBackup(Command):
|
||||
hashes=hashes,
|
||||
sub_command=sub_command,
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
)
|
||||
|
||||
self.name = "check-backup"
|
||||
@@ -93,28 +89,22 @@ class CmdAndroidCheckBackup(Command):
|
||||
self.__files.append(member.name)
|
||||
|
||||
def init(self) -> None:
|
||||
if not self.target_path: # type: ignore[has-type]
|
||||
if not self.target_path:
|
||||
return
|
||||
|
||||
# Type guard: we know it's not None here after the check above
|
||||
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):
|
||||
if os.path.isfile(self.target_path):
|
||||
self.__type = "ab"
|
||||
with open(backup_path, "rb") as handle:
|
||||
with open(self.target_path, "rb") as handle:
|
||||
ab_file_bytes = handle.read()
|
||||
self.from_ab(ab_file_bytes)
|
||||
|
||||
elif os.path.isdir(backup_path):
|
||||
elif os.path.isdir(self.target_path):
|
||||
self.__type = "folder"
|
||||
backup_path = Path(backup_path).absolute().as_posix()
|
||||
self.target_path = backup_path
|
||||
for root, subdirs, subfiles in os.walk(os.path.abspath(backup_path)):
|
||||
self.target_path = Path(self.target_path).absolute().as_posix()
|
||||
for root, subdirs, subfiles in os.walk(os.path.abspath(self.target_path)):
|
||||
for fname in subfiles:
|
||||
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:
|
||||
log.critical(
|
||||
|
||||
@@ -30,8 +30,6 @@ class CmdAndroidCheckBugreport(Command):
|
||||
module_options: Optional[dict] = None,
|
||||
hashes: Optional[bool] = False,
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -44,8 +42,6 @@ class CmdAndroidCheckBugreport(Command):
|
||||
hashes=hashes,
|
||||
sub_command=sub_command,
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
)
|
||||
|
||||
self.name = "check-bugreport"
|
||||
@@ -96,8 +92,6 @@ class CmdAndroidCheckBugreport(Command):
|
||||
if self.__format == "zip":
|
||||
module.from_zip(self.__zip, self.__files)
|
||||
else:
|
||||
if not self.target_path:
|
||||
raise ValueError("target_path is not set")
|
||||
module.from_dir(self.target_path, self.__files)
|
||||
|
||||
def finish(self) -> None:
|
||||
|
||||
@@ -1,113 +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 .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,
|
||||
) -> 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,
|
||||
)
|
||||
|
||||
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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,44 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.getprop import GetProp as GetPropArtifact
|
||||
|
||||
from .base import AndroidExtraction
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class Getprop(GetPropArtifact, AndroidExtraction):
|
||||
"""This module extracts device properties from getprop command."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.results = {} if not results else results
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
output = self._adb_command("getprop")
|
||||
self._adb_disconnect()
|
||||
|
||||
self.parse(output)
|
||||
self.log.info("Extracted %d Android system properties", len(self.results))
|
||||
@@ -0,0 +1,58 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .base import AndroidExtraction
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class Logcat(AndroidExtraction):
|
||||
"""This module extracts details on installed packages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
# Get the current logcat.
|
||||
output = self._adb_command('logcat -d -b all "*:V"')
|
||||
# Get the locat prior to last reboot.
|
||||
last_output = self._adb_command('logcat -L -b all "*:V"')
|
||||
|
||||
if self.results_path:
|
||||
logcat_path = os.path.join(self.results_path, "logcat.txt")
|
||||
with open(logcat_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(output)
|
||||
|
||||
self.log.info("Current logcat logs stored at %s", logcat_path)
|
||||
|
||||
logcat_last_path = os.path.join(self.results_path, "logcat_last.txt")
|
||||
with open(logcat_last_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(last_output)
|
||||
|
||||
self.log.info(
|
||||
"Logcat logs prior to last reboot stored at %s", logcat_last_path
|
||||
)
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,311 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact
|
||||
from mvt.android.utils import (
|
||||
DANGEROUS_PERMISSIONS,
|
||||
DANGEROUS_PERMISSIONS_THRESHOLD,
|
||||
ROOT_PACKAGES,
|
||||
SECURITY_PACKAGES,
|
||||
SYSTEM_UPDATE_PACKAGES,
|
||||
)
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
|
||||
class Packages(AndroidExtraction):
|
||||
"""This module extracts the list of installed packages."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self._user_needed = False
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
|
||||
timestamps = [
|
||||
{"event": "package_install", "timestamp": record["timestamp"]},
|
||||
{
|
||||
"event": "package_first_install",
|
||||
"timestamp": record["first_install_time"],
|
||||
},
|
||||
{"event": "package_last_update", "timestamp": record["last_update_time"]},
|
||||
]
|
||||
|
||||
for timestamp in timestamps:
|
||||
records.append(
|
||||
{
|
||||
"timestamp": timestamp["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": timestamp["event"],
|
||||
"data": f"{record['package_name']} (system: {record['system']},"
|
||||
f" third party: {record['third_party']})",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for result in self.results:
|
||||
if result["package_name"] in ROOT_PACKAGES:
|
||||
self.log.warning(
|
||||
'Found an installed package related to rooting/jailbreaking: "%s"',
|
||||
result["package_name"],
|
||||
)
|
||||
self.detected.append(result)
|
||||
continue
|
||||
|
||||
if result["package_name"] in SECURITY_PACKAGES and result["disabled"]:
|
||||
self.log.warning(
|
||||
'Found a security package disabled: "%s"', result["package_name"]
|
||||
)
|
||||
|
||||
if result["package_name"] in SYSTEM_UPDATE_PACKAGES and result["disabled"]:
|
||||
self.log.warning(
|
||||
'System OTA update package "%s" disabled on the phone',
|
||||
result["package_name"],
|
||||
)
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(result.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()
|
||||
@@ -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)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import AndroidExtraction
|
||||
|
||||
|
||||
class Settings(SettingsArtifact, AndroidExtraction):
|
||||
"""This module extracts Android system settings."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_path: Optional[str] = None,
|
||||
target_path: Optional[str] = None,
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
target_path=target_path,
|
||||
results_path=results_path,
|
||||
module_options=module_options,
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.results = {} if not results else results
|
||||
|
||||
def run(self) -> None:
|
||||
self._adb_connect()
|
||||
|
||||
for namespace in ["system", "secure", "global"]:
|
||||
out = self._adb_command(f"cmd settings list {namespace}")
|
||||
if not out:
|
||||
continue
|
||||
|
||||
self.results[namespace] = {}
|
||||
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line == "":
|
||||
continue
|
||||
|
||||
fields = line.split("=", 1)
|
||||
try:
|
||||
self.results[namespace][fields[0]] = fields[1]
|
||||
except IndexError:
|
||||
continue
|
||||
|
||||
self._adb_disconnect()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .aqf_files import AQFFiles
|
||||
from .aqf_getprop import AQFGetProp
|
||||
from .aqf_packages import AQFPackages
|
||||
from .aqf_processes import AQFProcesses
|
||||
from .aqf_settings import AQFSettings
|
||||
from .mounts import Mounts
|
||||
from .root_binaries import RootBinaries
|
||||
from .aqf_files import AQFFiles
|
||||
from .sms import SMS
|
||||
|
||||
ANDROIDQF_MODULES = [
|
||||
@@ -19,6 +17,4 @@ ANDROIDQF_MODULES = [
|
||||
AQFSettings,
|
||||
AQFFiles,
|
||||
SMS,
|
||||
RootBinaries,
|
||||
Mounts,
|
||||
]
|
||||
@@ -15,8 +15,8 @@ from typing import Optional
|
||||
|
||||
from mvt.android.modules.androidqf.base import AndroidQFModule
|
||||
from mvt.common.module_types import (
|
||||
ModuleAtomicResult,
|
||||
ModuleResults,
|
||||
ModuleAtomicResult,
|
||||
ModuleSerializedResult,
|
||||
)
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
@@ -41,7 +41,7 @@ class AQFFiles(AndroidQFModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -89,9 +89,9 @@ class AQFFiles(AndroidQFModule):
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_file_path(result["path"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
# NOTE: Update with final path used for Android collector.
|
||||
@@ -105,18 +105,18 @@ class AQFFiles(AndroidQFModule):
|
||||
file_type = "executable "
|
||||
|
||||
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"):
|
||||
file_hash = result.get(hash_key, "")
|
||||
if not file_hash:
|
||||
continue
|
||||
ioc_match = self.indicators.check_file_hash(file_hash)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
break
|
||||
if result.get("sha256", "") == "":
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_file_hash(result["sha256"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(self.get_slug(), ioc_match.message, "", result)
|
||||
|
||||
# TODO: adds SHA1 and MD5 when available in MVT
|
||||
|
||||
def run(self) -> None:
|
||||
if timezone := self._get_device_timezone():
|
||||
@@ -131,7 +131,7 @@ class AQFFiles(AndroidQFModule):
|
||||
data = json.loads(rawdata)
|
||||
except json.decoder.JSONDecodeError:
|
||||
data = []
|
||||
for line in rawdata.splitlines():
|
||||
for line in rawdata.split("\n"):
|
||||
if line.strip() == "":
|
||||
continue
|
||||
data.append(json.loads(line))
|
||||
@@ -142,11 +142,11 @@ class AQFFiles(AndroidQFModule):
|
||||
utc_timestamp = datetime.datetime.fromtimestamp(
|
||||
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)
|
||||
|
||||
# Preserve the device-local wall-clock time while using
|
||||
# the project-wide ISO conversion helper.
|
||||
# HACK: We only output the UTC timestamp in convert_datetime_to_iso, we
|
||||
# set the timestamp timezone to UTC, to avoid the timezone conversion again.
|
||||
local_timestamp = local_timestamp.replace(
|
||||
tzinfo=datetime.timezone.utc
|
||||
)
|
||||
|
||||
@@ -7,9 +7,9 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.getprop import GetProp as GetPropArtifact
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import AndroidQFModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class AQFGetProp(GetPropArtifact, AndroidQFModule):
|
||||
@@ -22,7 +22,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -32,7 +32,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule):
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self.results: list = [] if results is None else results
|
||||
self.results = []
|
||||
|
||||
def run(self) -> None:
|
||||
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
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import os
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
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.module_types import ModuleResults
|
||||
from .base import AndroidQFModule
|
||||
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
|
||||
|
||||
|
||||
class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
|
||||
@@ -27,7 +26,7 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -38,13 +37,11 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
|
||||
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:
|
||||
file_timetuple = self.archive.getinfo(file_path).date_time
|
||||
return datetime.datetime(*file_timetuple)
|
||||
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))
|
||||
return datetime.datetime.fromtimestamp(file_stat.st_mtime)
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ from mvt.android.utils import (
|
||||
BROWSER_INSTALLERS,
|
||||
PLAY_STORE_INSTALLERS,
|
||||
ROOT_PACKAGES,
|
||||
THIRD_PARTY_STORE_INSTALLERS,
|
||||
SECURITY_PACKAGES,
|
||||
SYSTEM_UPDATE_PACKAGES,
|
||||
THIRD_PARTY_STORE_INSTALLERS,
|
||||
)
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import AndroidQFModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class AQFPackages(AndroidQFModule):
|
||||
@@ -30,7 +30,7 @@ class AQFPackages(AndroidQFModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -45,67 +45,79 @@ class AQFPackages(AndroidQFModule):
|
||||
for result in self.results:
|
||||
if result["name"] in ROOT_PACKAGES:
|
||||
self.alertstore.medium(
|
||||
self.get_slug(),
|
||||
f'Found an installed package related to rooting/jailbreaking: "{result["name"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
continue
|
||||
|
||||
# Detections for apps installed via unusual methods.
|
||||
if result["installer"] in THIRD_PARTY_STORE_INSTALLERS:
|
||||
self.alertstore.info(
|
||||
self.get_slug(),
|
||||
f'Found a package installed via a third party store (installer="{result["installer"]}"): "{result["name"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
elif result["installer"] in BROWSER_INSTALLERS:
|
||||
self.alertstore.medium(
|
||||
self.get_slug(),
|
||||
f'Found a package installed via a browser (installer="{result["installer"]}"): "{result["name"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
elif result["installer"] == "null" and result["system"] is False:
|
||||
self.alertstore.medium(
|
||||
self.alertstore.high(
|
||||
self.get_slug(),
|
||||
f'Found a non-system package installed via adb or another method: "{result["name"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
elif result["installer"] in PLAY_STORE_INSTALLERS:
|
||||
pass
|
||||
|
||||
# Check for disabled security or software update packages.
|
||||
package_disabled = result.get("disabled", None)
|
||||
if result["name"] in SECURITY_PACKAGES and package_disabled:
|
||||
self.alertstore.medium(
|
||||
self.alertstore.high(
|
||||
self.get_slug(),
|
||||
f'Security package "{result["name"]}" disabled on the phone',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if result["name"] in SYSTEM_UPDATE_PACKAGES and package_disabled:
|
||||
self.alertstore.medium(
|
||||
self.alertstore.high(
|
||||
self.get_slug(),
|
||||
f'System OTA update package "{result["name"]}" disabled on the phone',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
|
||||
if not self.indicators:
|
||||
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:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
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", []):
|
||||
ioc_match = self.indicators.check_file_hash(
|
||||
package_file.get("sha256") or ""
|
||||
)
|
||||
ioc_match = self.indicators.check_file_hash(package_file["sha256"])
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
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:
|
||||
continue
|
||||
@@ -117,12 +129,11 @@ class AQFPackages(AndroidQFModule):
|
||||
certificate_hash
|
||||
)
|
||||
if ioc_match:
|
||||
result["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
self.get_slug(), ioc_match.message, "", result
|
||||
)
|
||||
self.alertstore.log_latest()
|
||||
break
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
@@ -22,7 +22,7 @@ class AQFProcesses(ProcessesArtifact, AndroidQFModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -7,9 +7,9 @@ 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 AndroidQFModule
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class AQFSettings(SettingsArtifact, AndroidQFModule):
|
||||
@@ -22,7 +22,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -32,7 +32,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self.results: dict = results if results is not None else {}
|
||||
self.results = {}
|
||||
|
||||
def run(self) -> None:
|
||||
for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
|
||||
@@ -40,7 +40,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
|
||||
|
||||
self.results[namespace] = {}
|
||||
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()
|
||||
try:
|
||||
key, value = line.split("=", 1)
|
||||
|
||||
@@ -23,7 +23,7 @@ class AndroidQFModule(MVTModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -33,8 +33,8 @@ class AndroidQFModule(MVTModule):
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self.parent_path: Optional[str] = None
|
||||
self._path: Optional[str] = target_path
|
||||
self.parent_path = None
|
||||
self._path: str = target_path
|
||||
self.files: List[str] = []
|
||||
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))
|
||||
@@ -21,6 +21,10 @@ from .base import AndroidQFModule
|
||||
class SMS(AndroidQFModule):
|
||||
"""
|
||||
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__(
|
||||
@@ -51,8 +55,9 @@ class SMS(AndroidQFModule):
|
||||
|
||||
ioc_match = self.indicators.check_domains(message.get("links", []))
|
||||
if ioc_match:
|
||||
message["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
|
||||
self.get_slug(), ioc_match.message, "", message
|
||||
)
|
||||
|
||||
def parse_backup(self, data):
|
||||
|
||||
@@ -9,7 +9,7 @@ import os
|
||||
from tarfile import TarFile
|
||||
from typing import List, Optional
|
||||
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
from mvt.common.module import MVTModule, ModuleResults
|
||||
|
||||
|
||||
class BackupModule(MVTModule):
|
||||
@@ -22,7 +22,7 @@ class BackupModule(MVTModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -32,10 +32,10 @@ class BackupModule(MVTModule):
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self.ab: Optional[str] = None
|
||||
self.backup_path: Optional[str] = None
|
||||
self.tar: Optional[TarFile] = None
|
||||
self.files: list = []
|
||||
self.ab = None
|
||||
self.backup_path = None
|
||||
self.tar = None
|
||||
self.files = []
|
||||
|
||||
def from_dir(self, backup_path: Optional[str], files: List[str]) -> None:
|
||||
self.backup_path = backup_path
|
||||
@@ -55,15 +55,12 @@ class BackupModule(MVTModule):
|
||||
return fnmatch.filter(self.files, pattern)
|
||||
|
||||
def _get_file_content(self, file_path: str) -> bytes:
|
||||
handle = None
|
||||
if self.tar:
|
||||
try:
|
||||
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:
|
||||
raise FileNotFoundError(f"File not found in tar: {file_path}")
|
||||
return None
|
||||
handle = self.tar.extractfile(member)
|
||||
elif self.backup_path:
|
||||
handle = open(os.path.join(self.backup_path, file_path), "rb")
|
||||
else:
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.modules.backup.base import BackupModule
|
||||
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.module_types import ModuleResults
|
||||
|
||||
|
||||
class SMS(BackupModule):
|
||||
@@ -20,7 +20,7 @@ class SMS(BackupModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -30,7 +30,7 @@ class SMS(BackupModule):
|
||||
log=log,
|
||||
results=results,
|
||||
)
|
||||
self.results: list[dict[str, Any]] = []
|
||||
self.results = []
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
@@ -46,8 +46,9 @@ class SMS(BackupModule):
|
||||
|
||||
ioc_match = self.indicators.check_urls(message_links)
|
||||
if ioc_match:
|
||||
message["matched_indicator"] = ioc_match.ioc
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
|
||||
self.get_slug(), ioc_match.message, "", message
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import datetime
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
from mvt.common.module import MVTModule, ModuleResults
|
||||
|
||||
|
||||
class BugReportModule(MVTModule):
|
||||
@@ -23,7 +23,7 @@ class BugReportModule(MVTModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -69,19 +69,14 @@ class BugReportModule(MVTModule):
|
||||
if self.zip_archive:
|
||||
handle = self.zip_archive.open(file_path)
|
||||
else:
|
||||
if not self.extract_path:
|
||||
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")
|
||||
handle = open(os.path.join(self.extract_path, file_path), "rb")
|
||||
|
||||
data = handle.read()
|
||||
handle.close()
|
||||
|
||||
return data
|
||||
|
||||
def _get_dumpstate_file(self) -> Optional[bytes]:
|
||||
def _get_dumpstate_file(self) -> bytes:
|
||||
main = self._get_files_by_pattern("main_entry.txt")
|
||||
if main:
|
||||
main_content = self._get_file_content(main[0])
|
||||
@@ -89,23 +84,17 @@ class BugReportModule(MVTModule):
|
||||
return self._get_file_content(main_content.decode().strip())
|
||||
except KeyError:
|
||||
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])
|
||||
|
||||
dumpsys_files = self._get_files_by_pattern("*/dumpsys.txt")
|
||||
if dumpsys_files:
|
||||
return self._get_file_content(dumpsys_files[0])
|
||||
|
||||
return None
|
||||
|
||||
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
|
||||
def _get_file_modification_time(self, file_path: str) -> dict:
|
||||
if self.zip_archive:
|
||||
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
||||
return datetime.datetime(*file_timetuple)
|
||||
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))
|
||||
return datetime.datetime.fromtimestamp(file_stat.st_mtime)
|
||||
@@ -22,7 +22,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -24,7 +24,7 @@ class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -22,7 +22,7 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -22,7 +22,7 @@ class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -22,7 +22,7 @@ class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -22,7 +22,7 @@ class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -24,7 +24,7 @@ class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -22,7 +22,7 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact
|
||||
from mvt.android.utils import DANGEROUS_PERMISSIONS, DANGEROUS_PERMISSIONS_THRESHOLD
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
@@ -23,7 +23,7 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -43,9 +43,8 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
|
||||
)
|
||||
return
|
||||
|
||||
content = self.extract_dumpsys_section(
|
||||
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
|
||||
)
|
||||
data = data.decode("utf-8", errors="replace")
|
||||
content = self.extract_dumpsys_section(data, "DUMP OF SERVICE package:")
|
||||
self.parse(content)
|
||||
|
||||
for result in self.results:
|
||||
|
||||
@@ -22,7 +22,7 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -42,10 +42,8 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
||||
)
|
||||
return
|
||||
|
||||
decoded_data = data.decode("utf-8", errors="replace")
|
||||
content = self.extract_dumpsys_section(
|
||||
decoded_data, "DUMP OF SERVICE platform_compat:"
|
||||
)
|
||||
data = data.decode("utf-8", errors="replace")
|
||||
content = self.extract_dumpsys_section(data, "DUMP OF SERVICE platform_compat:")
|
||||
self.parse(content)
|
||||
|
||||
self.log.info("Found %d uninstalled apps", len(self.results))
|
||||
@@ -22,7 +22,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
@@ -35,24 +35,6 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
|
||||
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:
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
|
||||
@@ -24,7 +24,7 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
file_path=file_path,
|
||||
|
||||
@@ -23,7 +23,7 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
|
||||
results_path: Optional[str] = None,
|
||||
module_options: Optional[dict] = None,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
results: Optional[ModuleResults] = None,
|
||||
results: ModuleResults = [],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
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,759 +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."""
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
# 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,
|
||||
)
|
||||
|
||||
# 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.log.warning(
|
||||
"Certificate validation failure detected: %s",
|
||||
result.get("cert_validation_failure"),
|
||||
)
|
||||
|
||||
# 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 certificate authority installations (potential MITM)
|
||||
if "cert_authority_installed" in result:
|
||||
cert_info = result["cert_authority_installed"]
|
||||
self.log.warning(
|
||||
"Certificate authority installed: %s (success: %s)",
|
||||
cert_info.get("subject", "unknown"),
|
||||
cert_info.get("success", "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),
|
||||
)
|
||||
@@ -29,6 +29,9 @@ class InvalidBackupPassword(AndroidBackupParsingError):
|
||||
pass
|
||||
|
||||
|
||||
# TODO: Need to clean all the following code and conform it to the coding style.
|
||||
|
||||
|
||||
def to_utf8_bytes(input_bytes):
|
||||
output = []
|
||||
for byte in input_bytes:
|
||||
@@ -154,13 +157,13 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
|
||||
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))
|
||||
decryptor = cipher.decryptor()
|
||||
decrypted_tar = decryptor.update(encrypted_data) + decryptor.finalize()
|
||||
|
||||
unpadder = padding.PKCS7(128).unpadder()
|
||||
return unpadder.update(decrypted_tar) + unpadder.finalize()
|
||||
return unpadder.update(decrypted_tar)
|
||||
|
||||
|
||||
def parse_backup_file(data, password=None):
|
||||
@@ -207,8 +210,6 @@ def parse_tar_for_sms(data):
|
||||
or member.name.endswith("_mms_backup")
|
||||
):
|
||||
dhandler = tar.extractfile(member)
|
||||
if not dhandler:
|
||||
continue
|
||||
res.extend(parse_sms_file(dhandler.read()))
|
||||
|
||||
return res
|
||||
@@ -230,7 +231,6 @@ def parse_sms_file(data):
|
||||
entry.pop("mms_body")
|
||||
|
||||
body = entry.get("body", None)
|
||||
message_links = None
|
||||
if body:
|
||||
message_links = check_for_links(entry["body"])
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# sources: tombstone.proto
|
||||
# plugin: python-betterproto2
|
||||
# plugin: python-betterproto
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
import betterproto2
|
||||
import betterproto
|
||||
|
||||
|
||||
class Architecture(betterproto2.Enum):
|
||||
class Architecture(betterproto.Enum):
|
||||
ARM32 = 0
|
||||
ARM64 = 1
|
||||
X86 = 2
|
||||
@@ -15,12 +16,12 @@ class Architecture(betterproto2.Enum):
|
||||
NONE = 5
|
||||
|
||||
|
||||
class MemoryErrorTool(betterproto2.Enum):
|
||||
class MemoryErrorTool(betterproto.Enum):
|
||||
GWP_ASAN = 0
|
||||
SCUDO = 1
|
||||
|
||||
|
||||
class MemoryErrorType(betterproto2.Enum):
|
||||
class MemoryErrorType(betterproto.Enum):
|
||||
UNKNOWN = 0
|
||||
USE_AFTER_FREE = 1
|
||||
DOUBLE_FREE = 2
|
||||
@@ -29,179 +30,179 @@ class MemoryErrorType(betterproto2.Enum):
|
||||
BUFFER_UNDERFLOW = 5
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class CrashDetail(betterproto2.Message):
|
||||
@dataclass
|
||||
class CrashDetail(betterproto.Message):
|
||||
"""
|
||||
NOTE TO OEMS: If you add custom fields to this proto, do not use numbers in
|
||||
the reserved range.
|
||||
"""
|
||||
|
||||
name: "bytes" = betterproto2.field(1, betterproto2.TYPE_BYTES)
|
||||
data: "bytes" = betterproto2.field(2, betterproto2.TYPE_BYTES)
|
||||
name: bytes = betterproto.bytes_field(1)
|
||||
data: bytes = betterproto.bytes_field(2)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class StackHistoryBufferEntry(betterproto2.Message):
|
||||
addr: "BacktraceFrame | None" = betterproto2.field(1, betterproto2.TYPE_MESSAGE, optional=True)
|
||||
fp: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
tag: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class StackHistoryBufferEntry(betterproto.Message):
|
||||
addr: "BacktraceFrame" = betterproto.message_field(1)
|
||||
fp: int = betterproto.uint64_field(2)
|
||||
tag: int = betterproto.uint64_field(3)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class StackHistoryBuffer(betterproto2.Message):
|
||||
tid: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64)
|
||||
entries: "list[StackHistoryBufferEntry]" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
@dataclass
|
||||
class StackHistoryBuffer(betterproto.Message):
|
||||
tid: int = betterproto.uint64_field(1)
|
||||
entries: List["StackHistoryBufferEntry"] = betterproto.message_field(2)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Tombstone(betterproto2.Message):
|
||||
arch: "Architecture" = betterproto2.field(1, betterproto2.TYPE_ENUM, default_factory=lambda: Architecture(0))
|
||||
guest_arch: "Architecture" = betterproto2.field(24, betterproto2.TYPE_ENUM, default_factory=lambda: Architecture(0))
|
||||
build_fingerprint: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
revision: "str" = betterproto2.field(3, betterproto2.TYPE_STRING)
|
||||
timestamp: "str" = betterproto2.field(4, betterproto2.TYPE_STRING)
|
||||
pid: "int" = betterproto2.field(5, betterproto2.TYPE_UINT32)
|
||||
tid: "int" = betterproto2.field(6, betterproto2.TYPE_UINT32)
|
||||
uid: "int" = betterproto2.field(7, betterproto2.TYPE_UINT32)
|
||||
selinux_label: "str" = betterproto2.field(8, betterproto2.TYPE_STRING)
|
||||
command_line: "list[str]" = betterproto2.field(9, betterproto2.TYPE_STRING, repeated=True)
|
||||
@dataclass
|
||||
class Tombstone(betterproto.Message):
|
||||
arch: "Architecture" = betterproto.enum_field(1)
|
||||
guest_arch: "Architecture" = betterproto.enum_field(24)
|
||||
build_fingerprint: str = betterproto.string_field(2)
|
||||
revision: str = betterproto.string_field(3)
|
||||
timestamp: str = betterproto.string_field(4)
|
||||
pid: int = betterproto.uint32_field(5)
|
||||
tid: int = betterproto.uint32_field(6)
|
||||
uid: int = betterproto.uint32_field(7)
|
||||
selinux_label: str = betterproto.string_field(8)
|
||||
command_line: List[str] = betterproto.string_field(9)
|
||||
# Process uptime in seconds.
|
||||
process_uptime: "int" = betterproto2.field(20, betterproto2.TYPE_UINT32)
|
||||
signal_info: "Signal | None" = betterproto2.field(10, betterproto2.TYPE_MESSAGE, optional=True)
|
||||
abort_message: "str" = betterproto2.field(14, betterproto2.TYPE_STRING)
|
||||
crash_details: "list[CrashDetail]" = betterproto2.field(21, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
causes: "list[Cause]" = betterproto2.field(15, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
threads: "dict[int, Thread]" = betterproto2.field(
|
||||
16, betterproto2.TYPE_MAP, map_meta=betterproto2.map_meta(betterproto2.TYPE_UINT32, betterproto2.TYPE_MESSAGE)
|
||||
process_uptime: int = betterproto.uint32_field(20)
|
||||
signal_info: "Signal" = betterproto.message_field(10)
|
||||
abort_message: str = betterproto.string_field(14)
|
||||
crash_details: List["CrashDetail"] = betterproto.message_field(21)
|
||||
causes: List["Cause"] = betterproto.message_field(15)
|
||||
threads: Dict[int, "Thread"] = betterproto.map_field(
|
||||
16, betterproto.TYPE_UINT32, betterproto.TYPE_MESSAGE
|
||||
)
|
||||
guest_threads: "dict[int, Thread]" = betterproto2.field(
|
||||
25, betterproto2.TYPE_MAP, map_meta=betterproto2.map_meta(betterproto2.TYPE_UINT32, betterproto2.TYPE_MESSAGE)
|
||||
guest_threads: Dict[int, "Thread"] = betterproto.map_field(
|
||||
25, betterproto.TYPE_UINT32, betterproto.TYPE_MESSAGE
|
||||
)
|
||||
memory_mappings: "list[MemoryMapping]" = betterproto2.field(17, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
log_buffers: "list[LogBuffer]" = betterproto2.field(18, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
open_fds: "list[FD]" = betterproto2.field(19, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
page_size: "int" = betterproto2.field(22, betterproto2.TYPE_UINT32)
|
||||
has_been_16kb_mode: "bool" = betterproto2.field(23, betterproto2.TYPE_BOOL)
|
||||
stack_history_buffer: "StackHistoryBuffer | None" = betterproto2.field(26, betterproto2.TYPE_MESSAGE, optional=True)
|
||||
memory_mappings: List["MemoryMapping"] = betterproto.message_field(17)
|
||||
log_buffers: List["LogBuffer"] = betterproto.message_field(18)
|
||||
open_fds: List["FD"] = betterproto.message_field(19)
|
||||
page_size: int = betterproto.uint32_field(22)
|
||||
has_been_16kb_mode: bool = betterproto.bool_field(23)
|
||||
stack_history_buffer: "StackHistoryBuffer" = betterproto.message_field(26)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Signal(betterproto2.Message):
|
||||
number: "int" = betterproto2.field(1, betterproto2.TYPE_INT32)
|
||||
name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
code: "int" = betterproto2.field(3, betterproto2.TYPE_INT32)
|
||||
code_name: "str" = betterproto2.field(4, betterproto2.TYPE_STRING)
|
||||
has_sender: "bool" = betterproto2.field(5, betterproto2.TYPE_BOOL)
|
||||
sender_uid: "int" = betterproto2.field(6, betterproto2.TYPE_INT32)
|
||||
sender_pid: "int" = betterproto2.field(7, betterproto2.TYPE_INT32)
|
||||
has_fault_address: "bool" = betterproto2.field(8, betterproto2.TYPE_BOOL)
|
||||
fault_address: "int" = betterproto2.field(9, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class Signal(betterproto.Message):
|
||||
number: int = betterproto.int32_field(1)
|
||||
name: str = betterproto.string_field(2)
|
||||
code: int = betterproto.int32_field(3)
|
||||
code_name: str = betterproto.string_field(4)
|
||||
has_sender: bool = betterproto.bool_field(5)
|
||||
sender_uid: int = betterproto.int32_field(6)
|
||||
sender_pid: int = betterproto.int32_field(7)
|
||||
has_fault_address: bool = betterproto.bool_field(8)
|
||||
fault_address: int = betterproto.uint64_field(9)
|
||||
# Note, may or may not contain the dump of the actual memory contents.
|
||||
# Currently, on arm64, we only include metadata, and not the contents.
|
||||
fault_adjacent_metadata: "MemoryDump | None" = betterproto2.field(10, betterproto2.TYPE_MESSAGE, optional=True)
|
||||
fault_adjacent_metadata: "MemoryDump" = betterproto.message_field(10)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class HeapObject(betterproto2.Message):
|
||||
address: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64)
|
||||
size: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
allocation_tid: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
allocation_backtrace: "list[BacktraceFrame]" = betterproto2.field(4, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
deallocation_tid: "int" = betterproto2.field(5, betterproto2.TYPE_UINT64)
|
||||
deallocation_backtrace: "list[BacktraceFrame]" = betterproto2.field(6, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
@dataclass
|
||||
class HeapObject(betterproto.Message):
|
||||
address: int = betterproto.uint64_field(1)
|
||||
size: int = betterproto.uint64_field(2)
|
||||
allocation_tid: int = betterproto.uint64_field(3)
|
||||
allocation_backtrace: List["BacktraceFrame"] = betterproto.message_field(4)
|
||||
deallocation_tid: int = betterproto.uint64_field(5)
|
||||
deallocation_backtrace: List["BacktraceFrame"] = betterproto.message_field(6)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class MemoryError(betterproto2.Message):
|
||||
tool: "MemoryErrorTool" = betterproto2.field(1, betterproto2.TYPE_ENUM, default_factory=lambda: MemoryErrorTool(0))
|
||||
type: "MemoryErrorType" = betterproto2.field(2, betterproto2.TYPE_ENUM, default_factory=lambda: MemoryErrorType(0))
|
||||
heap: "HeapObject | None" = betterproto2.field(3, betterproto2.TYPE_MESSAGE, optional=True, group="location")
|
||||
@dataclass
|
||||
class MemoryError(betterproto.Message):
|
||||
tool: "MemoryErrorTool" = betterproto.enum_field(1)
|
||||
type: "MemoryErrorType" = betterproto.enum_field(2)
|
||||
heap: "HeapObject" = betterproto.message_field(3, group="location")
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Cause(betterproto2.Message):
|
||||
human_readable: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
memory_error: "MemoryError | None" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, optional=True, group="details")
|
||||
@dataclass
|
||||
class Cause(betterproto.Message):
|
||||
human_readable: str = betterproto.string_field(1)
|
||||
memory_error: "MemoryError" = betterproto.message_field(2, group="details")
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Register(betterproto2.Message):
|
||||
name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
u64: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class Register(betterproto.Message):
|
||||
name: str = betterproto.string_field(1)
|
||||
u64: int = betterproto.uint64_field(2)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class Thread(betterproto2.Message):
|
||||
id: "int" = betterproto2.field(1, betterproto2.TYPE_INT32)
|
||||
name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
registers: "list[Register]" = betterproto2.field(3, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
backtrace_note: "list[str]" = betterproto2.field(7, betterproto2.TYPE_STRING, repeated=True)
|
||||
unreadable_elf_files: "list[str]" = betterproto2.field(9, betterproto2.TYPE_STRING, repeated=True)
|
||||
current_backtrace: "list[BacktraceFrame]" = betterproto2.field(4, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
memory_dump: "list[MemoryDump]" = betterproto2.field(5, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
tagged_addr_ctrl: "int" = betterproto2.field(6, betterproto2.TYPE_INT64)
|
||||
pac_enabled_keys: "int" = betterproto2.field(8, betterproto2.TYPE_INT64)
|
||||
@dataclass
|
||||
class Thread(betterproto.Message):
|
||||
id: int = betterproto.int32_field(1)
|
||||
name: str = betterproto.string_field(2)
|
||||
registers: List["Register"] = betterproto.message_field(3)
|
||||
backtrace_note: List[str] = betterproto.string_field(7)
|
||||
unreadable_elf_files: List[str] = betterproto.string_field(9)
|
||||
current_backtrace: List["BacktraceFrame"] = betterproto.message_field(4)
|
||||
memory_dump: List["MemoryDump"] = betterproto.message_field(5)
|
||||
tagged_addr_ctrl: int = betterproto.int64_field(6)
|
||||
pac_enabled_keys: int = betterproto.int64_field(8)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class BacktraceFrame(betterproto2.Message):
|
||||
rel_pc: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64)
|
||||
pc: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
sp: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
function_name: "str" = betterproto2.field(4, betterproto2.TYPE_STRING)
|
||||
function_offset: "int" = betterproto2.field(5, betterproto2.TYPE_UINT64)
|
||||
file_name: "str" = betterproto2.field(6, betterproto2.TYPE_STRING)
|
||||
file_map_offset: "int" = betterproto2.field(7, betterproto2.TYPE_UINT64)
|
||||
build_id: "str" = betterproto2.field(8, betterproto2.TYPE_STRING)
|
||||
@dataclass
|
||||
class BacktraceFrame(betterproto.Message):
|
||||
rel_pc: int = betterproto.uint64_field(1)
|
||||
pc: int = betterproto.uint64_field(2)
|
||||
sp: int = betterproto.uint64_field(3)
|
||||
function_name: str = betterproto.string_field(4)
|
||||
function_offset: int = betterproto.uint64_field(5)
|
||||
file_name: str = betterproto.string_field(6)
|
||||
file_map_offset: int = betterproto.uint64_field(7)
|
||||
build_id: str = betterproto.string_field(8)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class ArmMTEMetadata(betterproto2.Message):
|
||||
@dataclass
|
||||
class ArmMTEMetadata(betterproto.Message):
|
||||
# One memory tag per granule (e.g. every 16 bytes) of regular memory.
|
||||
memory_tags: "bytes" = betterproto2.field(1, betterproto2.TYPE_BYTES)
|
||||
memory_tags: bytes = betterproto.bytes_field(1)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class MemoryDump(betterproto2.Message):
|
||||
register_name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
mapping_name: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
begin_address: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
memory: "bytes" = betterproto2.field(4, betterproto2.TYPE_BYTES)
|
||||
arm_mte_metadata: "ArmMTEMetadata | None" = betterproto2.field(6, betterproto2.TYPE_MESSAGE, optional=True, group="metadata")
|
||||
@dataclass
|
||||
class MemoryDump(betterproto.Message):
|
||||
register_name: str = betterproto.string_field(1)
|
||||
mapping_name: str = betterproto.string_field(2)
|
||||
begin_address: int = betterproto.uint64_field(3)
|
||||
memory: bytes = betterproto.bytes_field(4)
|
||||
arm_mte_metadata: "ArmMTEMetadata" = betterproto.message_field(6, group="metadata")
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class MemoryMapping(betterproto2.Message):
|
||||
begin_address: "int" = betterproto2.field(1, betterproto2.TYPE_UINT64)
|
||||
end_address: "int" = betterproto2.field(2, betterproto2.TYPE_UINT64)
|
||||
offset: "int" = betterproto2.field(3, betterproto2.TYPE_UINT64)
|
||||
read: "bool" = betterproto2.field(4, betterproto2.TYPE_BOOL)
|
||||
write: "bool" = betterproto2.field(5, betterproto2.TYPE_BOOL)
|
||||
execute: "bool" = betterproto2.field(6, betterproto2.TYPE_BOOL)
|
||||
mapping_name: "str" = betterproto2.field(7, betterproto2.TYPE_STRING)
|
||||
build_id: "str" = betterproto2.field(8, betterproto2.TYPE_STRING)
|
||||
load_bias: "int" = betterproto2.field(9, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class MemoryMapping(betterproto.Message):
|
||||
begin_address: int = betterproto.uint64_field(1)
|
||||
end_address: int = betterproto.uint64_field(2)
|
||||
offset: int = betterproto.uint64_field(3)
|
||||
read: bool = betterproto.bool_field(4)
|
||||
write: bool = betterproto.bool_field(5)
|
||||
execute: bool = betterproto.bool_field(6)
|
||||
mapping_name: str = betterproto.string_field(7)
|
||||
build_id: str = betterproto.string_field(8)
|
||||
load_bias: int = betterproto.uint64_field(9)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class FD(betterproto2.Message):
|
||||
fd: "int" = betterproto2.field(1, betterproto2.TYPE_INT32)
|
||||
path: "str" = betterproto2.field(2, betterproto2.TYPE_STRING)
|
||||
owner: "str" = betterproto2.field(3, betterproto2.TYPE_STRING)
|
||||
tag: "int" = betterproto2.field(4, betterproto2.TYPE_UINT64)
|
||||
@dataclass
|
||||
class FD(betterproto.Message):
|
||||
fd: int = betterproto.int32_field(1)
|
||||
path: str = betterproto.string_field(2)
|
||||
owner: str = betterproto.string_field(3)
|
||||
tag: int = betterproto.uint64_field(4)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class LogBuffer(betterproto2.Message):
|
||||
name: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
logs: "list[LogMessage]" = betterproto2.field(2, betterproto2.TYPE_MESSAGE, repeated=True)
|
||||
@dataclass
|
||||
class LogBuffer(betterproto.Message):
|
||||
name: str = betterproto.string_field(1)
|
||||
logs: List["LogMessage"] = betterproto.message_field(2)
|
||||
|
||||
|
||||
@dataclass(eq=False, repr=False)
|
||||
class LogMessage(betterproto2.Message):
|
||||
timestamp: "str" = betterproto2.field(1, betterproto2.TYPE_STRING)
|
||||
pid: "int" = betterproto2.field(2, betterproto2.TYPE_UINT32)
|
||||
tid: "int" = betterproto2.field(3, betterproto2.TYPE_UINT32)
|
||||
priority: "int" = betterproto2.field(4, betterproto2.TYPE_UINT32)
|
||||
tag: "str" = betterproto2.field(5, betterproto2.TYPE_STRING)
|
||||
message: "str" = betterproto2.field(6, betterproto2.TYPE_STRING)
|
||||
@dataclass
|
||||
class LogMessage(betterproto.Message):
|
||||
timestamp: str = betterproto.string_field(1)
|
||||
pid: int = betterproto.uint32_field(2)
|
||||
tid: int = betterproto.uint32_field(3)
|
||||
priority: int = betterproto.uint32_field(4)
|
||||
tag: str = betterproto.string_field(5)
|
||||
message: str = betterproto.string_field(6)
|
||||
@@ -6,13 +6,14 @@ from datetime import datetime, timedelta
|
||||
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"""
|
||||
patch_date = datetime.strptime(patch_level, "%Y-%m-%d")
|
||||
if (datetime.now() - patch_date) > timedelta(days=6 * 31):
|
||||
warning_message = (
|
||||
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
|
||||
|
||||
|
||||
+15
-84
@@ -4,13 +4,12 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import csv
|
||||
import inspect
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass
|
||||
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
|
||||
|
||||
|
||||
@@ -29,7 +28,6 @@ class Alert:
|
||||
message: str
|
||||
event_time: str
|
||||
event: ModuleAtomicResult
|
||||
matched_indicator: Optional[Any] = None
|
||||
|
||||
|
||||
class AlertStore:
|
||||
@@ -37,138 +35,78 @@ class AlertStore:
|
||||
self.__alerts: List[Alert] = []
|
||||
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
|
||||
def alerts(self) -> List[Alert]:
|
||||
return self.__alerts
|
||||
|
||||
def add(self, alert: Alert) -> None:
|
||||
self.__alerts.append(alert)
|
||||
self.log(alert)
|
||||
|
||||
def extend(self, alerts: List[Alert]) -> None:
|
||||
for alert in alerts:
|
||||
self.add(alert)
|
||||
self.__alerts.extend(alerts)
|
||||
|
||||
def info(
|
||||
self,
|
||||
message: str,
|
||||
event_time: str,
|
||||
event: ModuleAtomicResult,
|
||||
matched_indicator: Optional[Any] = None,
|
||||
self, module: str, message: str, event_time: str, event: ModuleAtomicResult
|
||||
):
|
||||
self.add(
|
||||
Alert(
|
||||
level=AlertLevel.INFORMATIONAL,
|
||||
module=self._get_calling_module(),
|
||||
module=module,
|
||||
message=message,
|
||||
event_time=event_time,
|
||||
event=event,
|
||||
matched_indicator=matched_indicator,
|
||||
)
|
||||
)
|
||||
|
||||
def low(
|
||||
self,
|
||||
message: str,
|
||||
event_time: str,
|
||||
event: ModuleAtomicResult,
|
||||
matched_indicator: Optional[Any] = None,
|
||||
self, module: str, message: str, event_time: str, event: ModuleAtomicResult
|
||||
):
|
||||
self.add(
|
||||
Alert(
|
||||
level=AlertLevel.LOW,
|
||||
module=self._get_calling_module(),
|
||||
module=module,
|
||||
message=message,
|
||||
event_time=event_time,
|
||||
event=event,
|
||||
matched_indicator=matched_indicator,
|
||||
)
|
||||
)
|
||||
|
||||
def medium(
|
||||
self,
|
||||
message: str,
|
||||
event_time: str,
|
||||
event: ModuleAtomicResult,
|
||||
matched_indicator: Optional[Any] = None,
|
||||
self, module: str, message: str, event_time: str, event: ModuleAtomicResult
|
||||
):
|
||||
self.add(
|
||||
Alert(
|
||||
level=AlertLevel.MEDIUM,
|
||||
module=self._get_calling_module(),
|
||||
module=module,
|
||||
message=message,
|
||||
event_time=event_time,
|
||||
event=event,
|
||||
matched_indicator=matched_indicator,
|
||||
)
|
||||
)
|
||||
|
||||
def high(
|
||||
self,
|
||||
message: str,
|
||||
event_time: str,
|
||||
event: ModuleAtomicResult,
|
||||
matched_indicator: Optional[Any] = None,
|
||||
self, module: str, message: str, event_time: str, event: ModuleAtomicResult
|
||||
):
|
||||
self.add(
|
||||
Alert(
|
||||
level=AlertLevel.HIGH,
|
||||
module=self._get_calling_module(),
|
||||
module=module,
|
||||
message=message,
|
||||
event_time=event_time,
|
||||
event=event,
|
||||
matched_indicator=matched_indicator,
|
||||
)
|
||||
)
|
||||
|
||||
def critical(
|
||||
self,
|
||||
message: str,
|
||||
event_time: str,
|
||||
event: ModuleAtomicResult,
|
||||
matched_indicator: Optional[Any] = None,
|
||||
self, module: str, message: str, event_time: str, event: ModuleAtomicResult
|
||||
):
|
||||
self.add(
|
||||
Alert(
|
||||
level=AlertLevel.CRITICAL,
|
||||
module=self._get_calling_module(),
|
||||
module=module,
|
||||
message=message,
|
||||
event_time=event_time,
|
||||
event=event,
|
||||
matched_indicator=matched_indicator,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -207,19 +145,12 @@ class AlertStore:
|
||||
alert_dict = asdict(alert)
|
||||
# This is required because an Enum is not JSON serializable.
|
||||
alert_dict["level"] = alert.level.name
|
||||
if isinstance(alert_dict.get("event"), dict):
|
||||
event_matched_indicator = alert_dict["event"].pop(
|
||||
"matched_indicator", None
|
||||
)
|
||||
if alert_dict["matched_indicator"] is None:
|
||||
alert_dict["matched_indicator"] = event_matched_indicator
|
||||
|
||||
alerts.append(alert_dict)
|
||||
|
||||
return alerts
|
||||
|
||||
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(
|
||||
handle,
|
||||
delimiter=",",
|
||||
|
||||
@@ -8,6 +8,5 @@ from .module import MVTModule
|
||||
class Artifact(MVTModule):
|
||||
"""Base class for artifacts.
|
||||
|
||||
Artifacts share the MVTModule lifecycle so commands can run artifacts and
|
||||
extraction modules through the same interface.
|
||||
XXX: Inheriting from MVTModule to have the same signature as other modules. Not sure if this is a good idea.
|
||||
"""
|
||||
@@ -24,8 +24,6 @@ class CmdCheckIOCS(Command):
|
||||
module_options: Optional[dict] = None,
|
||||
hashes: Optional[bool] = False,
|
||||
sub_command: Optional[bool] = False,
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -37,8 +35,6 @@ class CmdCheckIOCS(Command):
|
||||
hashes=hashes,
|
||||
sub_command=sub_command,
|
||||
log=log,
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
)
|
||||
|
||||
self.name = "check-iocs"
|
||||
@@ -73,10 +69,6 @@ class CmdCheckIOCS(Command):
|
||||
m = iocs_module.from_json(
|
||||
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:
|
||||
m.indicators = self.iocs
|
||||
m.indicators.log = m.log
|
||||
|
||||
+21
-104
@@ -8,24 +8,20 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from heapq import heappop, heappush
|
||||
from typing import Any, Optional
|
||||
|
||||
from typing import Optional
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from .alerts import AlertLevel, AlertStore
|
||||
from .config import settings
|
||||
from .indicators import Indicators
|
||||
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
|
||||
from .module_types import ModuleTimeline
|
||||
from .module import MVTModule, run_module, save_timeline
|
||||
from .utils import (
|
||||
CustomJSONEncoder,
|
||||
convert_datetime_to_iso,
|
||||
generate_hashes_from_path,
|
||||
get_sha256_from_file_path,
|
||||
)
|
||||
from .config import settings
|
||||
from .alerts import AlertStore, AlertLevel
|
||||
from .version import MVT_VERSION
|
||||
|
||||
|
||||
@@ -42,11 +38,9 @@ class Command:
|
||||
hashes: Optional[bool] = False,
|
||||
sub_command: Optional[bool] = False,
|
||||
log: logging.Logger = logging.getLogger(__name__),
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
) -> None:
|
||||
self.name = ""
|
||||
self.modules: list[type[MVTModule]] = []
|
||||
self.modules = []
|
||||
|
||||
self.target_path = target_path
|
||||
self.results_path = results_path
|
||||
@@ -55,8 +49,6 @@ class Command:
|
||||
self.serial = serial
|
||||
self.log = log
|
||||
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
|
||||
# the Command to all modules. This can for example be used to pass
|
||||
@@ -65,10 +57,10 @@ class Command:
|
||||
|
||||
# This list will contain all executed modules.
|
||||
# We can use this to reference e.g. self.executed[0].results.
|
||||
self.executed: list[MVTModule] = []
|
||||
self.executed = []
|
||||
self.hashes = hashes
|
||||
self.hash_values: list[dict[str, Any]] = []
|
||||
self.timeline: ModuleTimeline = []
|
||||
self.hash_values = []
|
||||
self.timeline = []
|
||||
|
||||
# Load IOCs
|
||||
self._create_storage()
|
||||
@@ -144,7 +136,7 @@ class Command:
|
||||
|
||||
alerts_path = os.path.join(self.results_path, "alerts.json")
|
||||
with open(alerts_path, "w+", encoding="utf-8") as handle:
|
||||
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
|
||||
json.dump(alerts, handle, indent=4)
|
||||
|
||||
def _store_alerts_timeline(self) -> None:
|
||||
if not self.results_path:
|
||||
@@ -157,11 +149,11 @@ class Command:
|
||||
if not self.results_path:
|
||||
return
|
||||
|
||||
target_path: Optional[str] = None
|
||||
target_path = None
|
||||
if self.target_path:
|
||||
target_path = os.path.abspath(self.target_path)
|
||||
|
||||
info: dict[str, Any] = {
|
||||
info = {
|
||||
"target_path": target_path,
|
||||
"mvt_version": MVT_VERSION,
|
||||
"date": convert_datetime_to_iso(datetime.now()),
|
||||
@@ -260,85 +252,22 @@ class Command:
|
||||
console.print("")
|
||||
console.print(panel)
|
||||
|
||||
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
|
||||
"""Return enabled modules in stable topological order."""
|
||||
module_indexes = {module: index for index, module in enumerate(self.modules)}
|
||||
|
||||
if self.module_name:
|
||||
selected = [
|
||||
module for module in self.modules if module.__name__ == self.module_name
|
||||
]
|
||||
else:
|
||||
selected = [module for module in self.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:
|
||||
ordered_modules = self._ordered_modules()
|
||||
if ordered_modules is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.init()
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
executed_by_type: dict[type[MVTModule], MVTModule] = {}
|
||||
for module in ordered_modules:
|
||||
for module in self.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__)
|
||||
|
||||
m = module(
|
||||
@@ -347,10 +276,6 @@ class Command:
|
||||
module_options=self.module_options,
|
||||
log=module_logger,
|
||||
)
|
||||
m.dependency_modules = {
|
||||
dependency: executed_by_type[dependency]
|
||||
for dependency in module.dependencies
|
||||
}
|
||||
|
||||
if self.iocs.total_ioc_count:
|
||||
m.indicators = self.iocs
|
||||
@@ -364,17 +289,9 @@ class Command:
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
try:
|
||||
run_module(m)
|
||||
except EncryptedBackupError:
|
||||
self.log.critical(
|
||||
"The backup appears to be encrypted. "
|
||||
"Please decrypt it first using `mvt-ios decrypt-backup`."
|
||||
)
|
||||
return
|
||||
run_module(m)
|
||||
|
||||
self.executed.append(m)
|
||||
executed_by_type[module] = m
|
||||
self.timeline.extend(m.timeline)
|
||||
self.alertstore.extend(m.alertstore.alerts)
|
||||
|
||||
|
||||
+23
-22
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Optional, Tuple, Type
|
||||
|
||||
import yaml
|
||||
import json
|
||||
|
||||
from typing import Tuple, Type, Optional
|
||||
from appdirs import user_config_dir
|
||||
from pydantic import Field
|
||||
from pydantic import AnyHttpUrl, Field
|
||||
from pydantic_settings import (
|
||||
BaseSettings,
|
||||
InitSettingsSource,
|
||||
PydanticBaseSettingsSource,
|
||||
SettingsConfigDict,
|
||||
YamlConfigSettingsSource,
|
||||
@@ -21,51 +22,51 @@ class MVTSettings(BaseSettings):
|
||||
env_prefix="MVT_",
|
||||
env_nested_delimiter="_",
|
||||
extra="ignore",
|
||||
nested_model_default_partial_updates=True,
|
||||
)
|
||||
# Allow to decided if want to load environment variables
|
||||
load_env: bool = Field(True, exclude=True)
|
||||
|
||||
# General settings
|
||||
PYPI_UPDATE_URL: str = Field(
|
||||
default="https://pypi.org/pypi/mvt/json",
|
||||
PYPI_UPDATE_URL: AnyHttpUrl = Field(
|
||||
"https://pypi.org/pypi/mvt/json",
|
||||
validate_default=False,
|
||||
)
|
||||
NETWORK_ACCESS_ALLOWED: bool = True
|
||||
NETWORK_TIMEOUT: int = 15
|
||||
|
||||
# Command default settings, all can be specified by MVT_ prefixed environment variables too.
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
default=None, description="API key to use for VirusTotal lookups"
|
||||
None, description="API key to use for VirusTotal lookups"
|
||||
)
|
||||
PROFILE: bool = Field(
|
||||
default=False, description="Profile the execution of MVT modules"
|
||||
)
|
||||
HASH_FILES: bool = Field(default=False, description="Should MVT hash output files")
|
||||
PROFILE: bool = Field(False, description="Profile the execution of MVT modules")
|
||||
HASH_FILES: bool = Field(False, description="Should MVT hash output files")
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
cls,
|
||||
settings_cls: Type[BaseSettings],
|
||||
init_settings: PydanticBaseSettingsSource,
|
||||
init_settings: InitSettingsSource,
|
||||
env_settings: PydanticBaseSettingsSource,
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> Tuple[PydanticBaseSettingsSource, ...]:
|
||||
yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH)
|
||||
sources: Tuple[PydanticBaseSettingsSource, ...] = (
|
||||
yaml_source,
|
||||
sources = (
|
||||
YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH),
|
||||
init_settings,
|
||||
)
|
||||
# Always load env variables by default
|
||||
sources = (env_settings,) + sources
|
||||
# Load env variables if enabled
|
||||
if init_settings.init_kwargs.get("load_env", True):
|
||||
sources = (env_settings,) + sources
|
||||
return sources
|
||||
|
||||
def save_settings(
|
||||
@@ -93,11 +94,11 @@ class MVTSettings(BaseSettings):
|
||||
Afterwards we load the settings again, this time including the env variables.
|
||||
"""
|
||||
# Set invalid env prefix to avoid loading env variables.
|
||||
settings = cls(load_env=False)
|
||||
settings = MVTSettings(load_env=False)
|
||||
settings.save_settings()
|
||||
|
||||
# Load the settings again with any ENV variables.
|
||||
settings = cls(load_env=True)
|
||||
settings = MVTSettings(load_env=True)
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
|
||||
HELP_MSG_VERBOSE = "Verbose mode"
|
||||
HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided 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
|
||||
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
|
||||
@@ -47,4 +45,3 @@ HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION = (
|
||||
HELP_MSG_CHECK_BUGREPORT = "Check an Android Bug Report"
|
||||
HELP_MSG_CHECK_ANDROID_BACKUP = "Check an Android Backup"
|
||||
HELP_MSG_CHECK_ANDROIDQF = "Check data collected with AndroidQF"
|
||||
HELP_MSG_CHECK_INTRUSION_LOGS = "Check Android Intrusion Logging files"
|
||||
@@ -7,15 +7,15 @@ import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import ahocorasick
|
||||
from appdirs import user_data_dir
|
||||
|
||||
from .config import settings
|
||||
from .url import URL
|
||||
from .config import settings
|
||||
|
||||
MVT_DATA_FOLDER = user_data_dir("mvt")
|
||||
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"):
|
||||
self.parse_stix2(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)
|
||||
else:
|
||||
self.log.error(
|
||||
@@ -115,17 +117,6 @@ class Indicators:
|
||||
key, value = indicator.get("pattern", "").strip("[]").split("=")
|
||||
key = key.strip()
|
||||
|
||||
# Normalize hash algorithm keys so that both the STIX2-spec-compliant
|
||||
# form (e.g. file:hashes.'SHA-256', which requires quotes around
|
||||
# algorithm names that contain hyphens) and the non-standard lowercase
|
||||
# form (e.g. file:hashes.sha256) are accepted. Strip single quotes and
|
||||
# hyphens from the algorithm name only, then lowercase it.
|
||||
for sep in ("hashes.", "cert."):
|
||||
if sep in key:
|
||||
prefix, _, algo = key.partition(sep)
|
||||
key = prefix + sep + algo.replace("'", "").replace("-", "").lower()
|
||||
break
|
||||
|
||||
if key == "domain-name:value":
|
||||
# We force domain names to lower case.
|
||||
self._add_indicator(
|
||||
@@ -359,7 +350,7 @@ class Indicators:
|
||||
|
||||
@lru_cache()
|
||||
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:
|
||||
"""
|
||||
Build an Aho-Corasick automaton from a list of iocs (i.e indicators)
|
||||
@@ -379,9 +370,9 @@ class Indicators:
|
||||
"""
|
||||
automaton = ahocorasick.Automaton()
|
||||
if ioc_type:
|
||||
iocs: Iterator[Indicator] = self.get_iocs(ioc_type)
|
||||
iocs = self.get_iocs(ioc_type)
|
||||
elif ioc_list:
|
||||
iocs = iter(ioc_list)
|
||||
iocs = ioc_list
|
||||
else:
|
||||
raise ValueError("Must provide either ioc_type or ioc_list")
|
||||
|
||||
@@ -727,29 +718,6 @@ class Indicators:
|
||||
|
||||
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(
|
||||
self, property_name: str
|
||||
) -> Optional[IndicatorMatch]:
|
||||
|
||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user