Compare commits

...
Author SHA1 Message Date
Janik Besendorf f7e48f0c65 Handle plugin SystemExit failures 2026-08-05 23:28:54 +02:00
besendorf a66eeaa089 Merge branch 'main' into feature/custom-cli-commands 2026-08-05 23:21:38 +02:00
besendorf 93b7fb5232 Store message URLs in analysis output (#856) 2026-08-05 23:21:14 +02:00
besendorf 8617e0bf54 Alert on AndroidQF trusted ADB keys (#860) 2026-08-05 17:36:49 +02:00
Rory Flynn 53fb12aee8 Correct version.py (#855) 2026-07-29 16:08:42 +02:00
Rory Flynn 8140e350f7 Update version.py (#854) 2026-07-29 13:57:34 +02:00
Janik Besendorf 8685d1afcf Add extensible CLI commands 2026-07-29 09:40:50 +02:00
besendorf f483223e23 Add iOS sysdiagnose checking (#832)
* Add iOS sysdiagnose checking

* Clarify documentation navigation
2026-07-28 18:59:58 +02:00
besendorf 2dfe3cbcb1 Fix module audit findings (#850)
* Fix module audit findings

* Always parse paired tombstones
2026-07-28 18:58:46 +02:00
besendorf 3eff0c550d Handle mis-indented dumpsys receiver actions (#852) 2026-07-28 18:34:13 +02:00
besendorf 797411e1e5 Fix text tombstone crashing thread parsing (#848) 2026-07-27 17:58:34 +02:00
84df51c518 Scan Safari profile databases for history and browser state (#846)
* fix(ios): scan Safari profile databases for history and browser state

Safari profiles (iOS 17 and later) keep their own databases under
Library/Safari/Profiles/<UUID>/, but SafariHistory and SafariBrowserState
only ever looked at the default profile's Library/Safari/History.db and
Library/Safari/BrowserState.db.

On a device where browsing happens inside a profile, MVT silently skipped
that history and still reported no detections, so an indicator only ever
visited within a profile went unnoticed.

Both modules now also match Library/Safari/Profiles/*/ in backups and in
filesystem dumps. No helper changes were needed: the Manifest.db lookup
already translates "*" into a SQL LIKE wildcard, and the filesystem lookup
already globs.

Found while examining an encrypted iOS 26.5.2 backup that contained 14
per-profile History.db files under
AppDomain-com.apple.mobilesafari::Library/Safari/Profiles/<UUID>/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ios): scope Safari redirects to history database

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Janik Besendorf <janik@besendorf.org>
2026-07-27 15:06:44 +02:00
besendorf a6c3a805d8 Parallelize URL indicator checks (#844) 2026-07-27 15:03:02 +02:00
besendorf 123c9081ed Skip resolving Google Maps short URLs (#843) 2026-07-19 17:13:57 +02:00
FelixandJanik Besendorf 5ed8b3c1a5 fix: terminate dumpsys adb multiline values at structural lines (#842)
* fix: terminate dumpsys adb multiline values at structural lines

* fix dumpsys ADB multiline boundaries

---------

Co-authored-by: Janik Besendorf <janik@besendorf.org>
2026-07-17 18:25:47 +02:00
besendorf afcfda4720 Deduplicate AndroidQF SMS analysis (#837) 2026-07-15 09:09:04 +02:00
besendorf 911115b0c8 Match receiver indicators by package ID (#836) 2026-07-15 08:32:13 +02:00
besendorf c806fd8d61 Support rotated iOS shutdown logs (#834) 2026-07-14 23:27:44 +02:00
besendorf 516ba06cf7 Suppress benign PinStorage key generation warning (#835) 2026-07-14 23:20:53 +02:00
besendorf 1532578b39 Validate iOS backup path before checks (#825)
* Validate iOS backup path before checks

* Fix iOS backup path mypy typing

* Fix custom module test backup fixture
2026-07-14 21:58:46 +02:00
Nimrod B.andJanik Besendorf f5b0a3cd91 WIP: Addition of a timer to virustotal checks (#593)
* Add delay option to virustotal checks (#408)

* Fix missing delay argument

* Fix VirusTotal delay handling

* Fix mypy type for VirusTotal package map

---------

Co-authored-by: Janik Besendorf <janik@besendorf.org>
2026-07-01 12:36:56 +02:00
besendorf a18e632ec8 Add shell completion command (#817) 2026-07-01 12:35:21 +02:00
besendorf 2689176c0e Add custom module loading (#816) 2026-07-01 12:33:38 +02:00
besendorf 638937e838 Handle malformed AndroidQF backups (#824) 2026-06-24 12:41:00 +02:00
dependabot[bot] b9f13b8146 Bump pydantic-settings from 2.13.1 to 2.14.0
Bumps [pydantic-settings](https://github.com/pydantic/pydantic-settings) from 2.13.1 to 2.14.0.
- [Release notes](https://github.com/pydantic/pydantic-settings/releases)
- [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.13.1...v2.14.0)

---
updated-dependencies:
- dependency-name: pydantic-settings
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:17:22 +02:00
dependabot[bot] 58558fdfb7 Bump click from 8.3.2 to 8.3.3
Bumps [click](https://github.com/pallets/click) from 8.3.2 to 8.3.3.
- [Release notes](https://github.com/pallets/click/releases)
- [Changelog](https://github.com/pallets/click/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/click/compare/8.3.2...8.3.3)

---
updated-dependencies:
- dependency-name: click
  dependency-version: 8.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:16:14 +02:00
dependabot[bot] 7c7e332e7b Bump ruff from 0.9.10 to 0.15.16
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.10 to 0.15.16.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.9.10...0.15.16)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.12
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:12:58 +02:00
dependabot[bot] a232c988f9 Bump packaging from 26.0 to 26.2
Bumps [packaging](https://github.com/pypa/packaging) from 26.0 to 26.2.
- [Release notes](https://github.com/pypa/packaging/releases)
- [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pypa/packaging/compare/26.0...26.2)

---
updated-dependencies:
- dependency-name: packaging
  dependency-version: '26.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:11:51 +02:00
dependabot[bot] 2f4123222f Bump tld from 0.13.1 to 0.13.2
Bumps [tld](https://github.com/barseghyanartur/tld) from 0.13.1 to 0.13.2.
- [Release notes](https://github.com/barseghyanartur/tld/releases)
- [Changelog](https://github.com/barseghyanartur/tld/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/barseghyanartur/tld/compare/0.13.1...0.13.2)

---
updated-dependencies:
- dependency-name: tld
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:09:37 +02:00
dependabot[bot] 81a7bc977b Bump pyahocorasick from 2.2.0 to 2.3.1
Bumps [pyahocorasick](https://github.com/WojciechMula/pyahocorasick) from 2.2.0 to 2.3.1.
- [Release notes](https://github.com/WojciechMula/pyahocorasick/releases)
- [Changelog](https://github.com/WojciechMula/pyahocorasick/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/WojciechMula/pyahocorasick/compare/v2.2.0...v2.3.1)

---
updated-dependencies:
- dependency-name: pyahocorasick
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:08:29 +02:00
dependabot[bot] 3b20bd944b Bump types-requests from 2.33.0.20260503 to 2.33.0.20260508
Bumps [types-requests](https://github.com/python/typeshed) from 2.33.0.20260503 to 2.33.0.20260508.
- [Commits](https://github.com/python/typeshed/commits)

---
updated-dependencies:
- dependency-name: types-requests
  dependency-version: 2.33.0.20260508
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 18:06:44 +02:00
besendorf 6bbb5957af Fix dumpsys battery daily downgrade detection (#805) 2026-06-17 17:54:05 +02:00
6a6c1758c3 intrusion_logs: alert on certificate events and run heuristics without IOCs (#811)
* intrusion_logs: alert on certificate events and run heuristics without IOCs

SecurityEvent.check_indicators() returned early when no indicator set was
loaded, so none of its heuristic alerts (key integrity, wipe failure, crypto
self-test, certificate events) reached the alert store on a default run. On
top of that, cert_authority_installed and cert_validation_failure only emitted
log.warning and never alerted even when indicators were present.

Run the heuristic alerts independently of the loaded indicators (matching the
accessibility fix in #807) and surface the two certificate events through the
alert store at medium severity. A successfully installed root CA and a
certificate validation failure are interception/MITM-relevant signals that
belong in the alert report.

Adds regression tests for both certificate events and for heuristics firing
with no indicators loaded.

* intrusion_logs: gate certificate authority install alert on success

Failed install attempts log a warning instead of raising the
"Certificate authority installed" alert. Add a regression test
covering success encoded as bool and as int.

---------

Co-authored-by: John Kavanagh <668351+kavanista@users.noreply.github.com>
Co-authored-by: besendorf <janik@besendorf.org>
2026-06-17 17:24:06 +02:00
besendorf 174ce08812 Run mypy on pull requests (#810) 2026-06-17 17:15:11 +02:00
besendorf d590706e62 Add dependency-aware module execution (#806)
* Add dependency-aware module ordering

* Annotate module run order test state
2026-06-17 17:03:47 +02:00
besendorf 08e6a0eae2 Fix intrusion log event ID parsing (#815) 2026-06-11 19:27:26 +02:00
besendorf 1e67a343ca Document network access options (#808) 2026-06-05 20:27:42 +02:00
tesandbesendorf 0a8da50c2b fix: enforce NETWORK_ACCESS_ALLOWED and timeout on URL.unshorten (#800)
Co-authored-by: besendorf <janik@besendorf.org>
2026-06-05 20:20:34 +02:00
besendorf b8331ddac8 Replace split("\n") with splitlines() for platform compatibility and other todos and chores (#746)
* Replace split("\n") with splitlines() for platform compatibility

* Remove dead commented-out code in webkit_session_resource_log

* Remove stale FIXME comment in command.py

* Narrow bare except to specific exception types in convert_mactime_to_datetime

* Fix typo in aqf_files.py comment

* Refactor b64 encoding in configuration_profiles into helper methods

* Pass branch parameter to GitHub commits API in update checker

* Replace bare KeyError catch with explicit key check in net_base

* Remove confirmed Chrome database path TODOs

Backup IDs verified via SHA-1 of AppDomain-com.google.chrome.ios paths.

* Extract additional timestamps from WebKit ObservedDomains table

Query mostRecentUserInteractionTime and mostRecentWebPushInteractionTime
with fallback to the original 4-column query for older iOS versions.

* Clarify command_line list format matches protobuf schema in tombstone parser

* Support SHA1 and MD5 hash matching in AQF files module

* Remove resolved TODO about --output requirement in download-apks

* Clean up code TODOs and type checks

* Fix WebKit timestamp schema handling
2026-06-05 20:15:56 +02:00
besendorf 3b2f923bd9 Fix accessibility service alerts (#807) 2026-06-05 19:59:28 +02:00
tes 11d06a3a16 fix: open timeline.csv and alerts_timeline.csv with write mode to prevent cross run contamination (#798) 2026-05-15 18:37:17 +02:00
Donncha Ó Cearbhaill 1c971bd6a9 Bump version for release 2026.5.12 (#796) 2026-05-12 18:10:47 +02:00
dependabot[bot] f17de1ea88 Bump tzdata from 2026.1 to 2026.2 (#793)
Bumps [tzdata](https://github.com/python/tzdata) from 2026.1 to 2026.2.
- [Release notes](https://github.com/python/tzdata/releases)
- [Changelog](https://github.com/python/tzdata/blob/master/NEWS.md)
- [Commits](https://github.com/python/tzdata/compare/2026.1...2026.2)

---
updated-dependencies:
- dependency-name: tzdata
  dependency-version: '2026.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-12 17:49:53 +02:00
dependabot[bot]andDonncha Ó Cearbhaill 15ff05268e Bump urllib3 from 2.6.3 to 2.7.0 (#795)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.6.3 to 2.7.0.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.6.3...2.7.0)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
2026-05-12 17:46:03 +02:00
github-actions[bot]andDonnchaC 6b48d16e2d Add new iOS versions and build numbers (#787)
Co-authored-by: DonnchaC <3081375+DonnchaC@users.noreply.github.com>
2026-05-12 17:29:10 +02:00
Donncha Ó Cearbhaill 5dacc2d6fb Add documentation for Android intrusion logs (#794)
Introduces docs/android/intrusion_logs.md covering the check-intrusion-logs
command and the recommended AndroidQF + check-androidqf workflow, notes
intrusion logging as an Advanced Protection opt-in, links the page from
the Android methodology page, and wires it into the mkdocs navigation.
2026-05-12 17:27:43 +02:00
b8ea29cde5 Add Android intrusion log checks (#788)
* Add Android intrusion log checks

* Warn on unknown intrusion log event types

* Rename intrusion logs folder from intrusion-logs to instrusion_logs to match AndroidQF output

---------

Co-authored-by: tes <tesitura@users.noreply.github.com>
Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
2026-05-12 17:24:29 +02:00
tes cc32370530 Merge commit from fork
file_path from main_entry.txt inside the bugreport zip is device controlled
and was used directly to open files on the host without validation.

Validate the resolved path stays within extract_path using
Path.resolve() + is_relative_to() before opening. Unsafe paths raise
ValueError and abort the operation.

Fixes GHSA-58fm-wv78-6929
2026-05-12 17:18:06 +02:00
tes 2a3423d826 Merge commit from fork
file_id values from backup manifests are device controlled and were used
directly to construct host filesystem paths without validation, allowing
a malicious backup to read or write files outside the designated directories.

Validate each file_id with Path.resolve() + is_relative_to() before use
as a source or destination path. Unsafe entries are skipped and logged.

Fixes GHSA-5h3g-px23-w6vw
2026-05-12 17:17:48 +02:00
besendorf 436a0dc7c0 Convert dependency management to uv (#785) 2026-05-11 23:21:40 +02:00
176 changed files with 9502 additions and 783 deletions

No files matched your search

+3 -1
View File
@@ -5,7 +5,9 @@
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
- package-ecosystem: "uv" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
cooldown:
default-days: 7
+10 -4
View File
@@ -1,5 +1,8 @@
name: Mypy
on: workflow_dispatch
on:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
mypy_py3:
@@ -12,11 +15,14 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: 3.9
cache: 'pip'
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install Dependencies
run: |
pip install mypy
uv sync --locked --group dev
- name: mypy
run: |
make mypy
+7 -4
View File
@@ -14,13 +14,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: 3.9
cache: 'pip'
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install Dependencies
run: |
pip install ruff
uv sync --locked --group dev
- name: ruff
run: |
make ruff
+5 -2
View File
@@ -16,14 +16,17 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install Python dependencies
run: |
make install
make test-requirements
uv sync --locked --group dev --python ${{ matrix.python-version }}
- name: Test with pytest
run: |
set -o pipefail
+4 -1
View File
@@ -86,6 +86,9 @@ 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
@@ -138,4 +141,4 @@ dmypy.json
.idea
# Sublime Text project files
*.sublime*
*.sublime*
+1
View File
@@ -0,0 +1 @@
3.10
+6 -4
View File
@@ -6,14 +6,16 @@
version: 2
build:
os: "ubuntu-22.04"
os: "ubuntu-24.04"
tools:
python: "3.11"
mkdocs:
configuration: mkdocs.yml
# Optionally set the version of Python and requirements required to build your docs
python:
install:
- requirements: docs/requirements.txt
install:
- method: uv
command: sync
groups:
- docs
+3 -6
View File
@@ -104,6 +104,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/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -133,12 +134,8 @@ COPY --from=build-usbmuxd /build /
# Install mvt using the locally checked out source
COPY . mvt/
RUN apt-get update \
&& apt-get install -y git python3-pip \
&& PIP_NO_CACHE_DIR=1 pip3 install --break-system-packages ./mvt \
&& apt-get remove -y python3-pip git && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf mvt
RUN uv pip install --system --break-system-packages --no-cache ./mvt \
&& rm -rf mvt
# Installing ABE
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
+2 -1
View File
@@ -1,5 +1,6 @@
# 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/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -20,7 +21,7 @@ RUN apk add --no-cache \
# Install mvt
COPY ./ mvt
RUN apk add --no-cache --virtual .build-deps gcc musl-dev \
&& PIP_NO_CACHE_DIR=1 pip3 install ./mvt \
&& uv pip install --system --no-cache ./mvt \
&& apk del .build-deps gcc musl-dev && rm -rf ./mvt
# Installing ABE
+2 -1
View File
@@ -105,6 +105,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/
LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -131,7 +132,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 \
&& PIP_NO_CACHE_DIR=1 pip3 install ./mvt \
&& uv pip install --system --no-cache ./mvt \
&& apk del .build-deps git gcc musl-dev && rm -rf ./mvt
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
+11 -11
View File
@@ -1,39 +1,39 @@
PWD = $(shell pwd)
UV ?= uv
check: ruff mypy
ruff:
ruff check .
$(UV) run ruff check .
mypy:
mypy
$(UV) run mypy
test:
python3 -m pytest
$(UV) run pytest
test-ci:
python3 -m pytest -v
$(UV) run pytest -v
install:
python3 -m pip install --upgrade -e .
$(UV) sync
test-requirements:
python3 -m pip install --upgrade --group dev
$(UV) sync --group dev
generate-proto-parsers:
# Generate python parsers for protobuf files
PROTO_FILES=$$(find src/mvt/android/parsers/proto/ -iname "*.proto"); \
protoc -Isrc/mvt/android/parsers/proto/ --python_betterproto2_out=src/mvt/android/parsers/proto/ $$PROTO_FILES
$(UV) run protoc -Isrc/mvt/android/parsers/proto/ --python_betterproto2_out=src/mvt/android/parsers/proto/ $$PROTO_FILES
clean:
rm -rf $(PWD)/build $(PWD)/dist $(PWD)/src/mvt.egg-info
dist:
python3 -m pip install --upgrade build
python3 -m build
$(UV) build
upload:
python3 -m twine upload dist/*
$(UV) tool run twine upload dist/*
test-upload:
python3 -m twine upload --repository testpypi dist/*
$(UV) tool run twine upload --repository testpypi dist/*
+39
View File
@@ -41,6 +41,18 @@ 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).
@@ -48,6 +60,33 @@ For alternative installation options and known issues, please refer to the [docu
MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
### Shell completion
MVT can generate shell completion scripts for Bash, Zsh, and Fish:
```bash
mvt-ios completion
mvt-android completion
```
The commands print setup instructions by default. To generate a completion script directly, pass the shell name:
```bash
mvt-ios completion bash
mvt-android completion zsh
```
MVT only writes completion files or shell configuration when `--install` is passed. See the [command completion documentation](https://docs.mvt.re/en/latest/command_completion/) for details.
Module-running `check-*` commands can load custom Python modules with
`--load-module PATH` or from a folder set in `MVT_CUSTOM_MODULES`. See the
[development documentation](https://docs.mvt.re/en/latest/development/) for
details.
Users can also add top-level commands to `mvt-ios` and `mvt-android` from
installed Python packages or local files and folders. See the
[custom CLI command documentation](https://docs.mvt.re/en/latest/custom_commands/)
for the plugin entry points and `--load-command` interface.
## License
+6
View File
@@ -57,3 +57,9 @@ If the backup is encrypted, ABE will prompt you to enter the password.
Alternatively, [ab-decrypt](https://github.com/joernheissler/ab-decrypt) can be used for that purpose.
You can then extract SMSs with MVT by passing the folder path as parameter instead of the `.ab` file: `mvt-android check-backup --output /path/to/results/ /path/to/backup/` (the path to backup given should be the folder containing the `apps` folder).
When an output folder is specified, URLs extracted from SMS and MMS messages
are also written to `urls.json`. Each entry contains the URL, its expanded
destination when MVT resolved a shortened URL during indicator checking, the
message timestamp, and the `sms` source. The same file is created by
`check-androidqf` when its nested Android backup contains messages with URLs.
+82
View File
@@ -0,0 +1,82 @@
# 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.
+25 -1
View File
@@ -38,9 +38,33 @@ 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).
### VirusTotal package lookups
AndroidQF records APK file hashes in `packages.json`. MVT can optionally look up non-system APK hashes on VirusTotal while checking an AndroidQF acquisition:
```bash
MVT_VT_API_KEY=<key> mvt-android check-androidqf --virustotal /path/to/androidqf-output
```
The `--virustotal` option is disabled by default because it sends APK hashes to VirusTotal and requires network access. It uses the `VT_API_KEY` MVT configuration value, which can also be provided through the `MVT_VT_API_KEY` environment variable.
To avoid exhausting free VirusTotal API quotas, MVT waits 16 seconds between package hash requests by default. Use `--delay` to change the delay, or `--delay 0` to disable throttling:
```bash
mvt-android check-androidqf --virustotal --delay 30 /path/to/androidqf-output
```
## Android Intrusion Logs
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.
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. Use AndroidQF for device acquisition and `mvt-android check-androidqf` for analysis.
The ability to analyze Android devices directly over ADB has been removed from MVT. Direct extraction of data from ADB was error-prone and frequently resulted in inconsistent data collection between ADB and AndroidQF acquisitions. Use AndroidQF for device acquisition and `mvt-android check-androidqf` for analysis.
## Check an Android Backup (SMS messages)
+36 -13
View File
@@ -1,43 +1,66 @@
# Command Completion
# Command Completion
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fish.
To enable it, you need to manually register a special function with your shell, which varies depending on the shell you are using.
To enable it, you need to register a completion script with your shell, which varies depending on the shell you are using.
The following describes how to generate the command completion scripts and add them to your shell configuration.
The following describes how to generate the command completion scripts and add them to your shell configuration.
> **Note: You will need to start a new shell for the changes to take effect.**
### For Bash
```bash
# Generates bash completion scripts
echo "$(_MVT_IOS_COMPLETE=bash_source mvt-ios)" > ~/.mvt-ios-complete.bash &&
echo "$(_MVT_ANDROID_COMPLETE=bash_source mvt-android)" > ~/.mvt-android-complete.bash
# Generate bash completion scripts
mvt-ios completion bash > ~/.mvt-ios-complete.bash
mvt-android completion bash > ~/.mvt-android-complete.bash
```
Add the following to `~/.bashrc`:
```bash
# source mvt completion scripts
. ~/.mvt-ios-complete.bash && . ~/.mvt-android-complete.bash
[ -f ~/.mvt-ios-complete.bash ] && . ~/.mvt-ios-complete.bash
[ -f ~/.mvt-android-complete.bash ] && . ~/.mvt-android-complete.bash
```
### For Zsh
```bash
# Generates zsh completion scripts
echo "$(_MVT_IOS_COMPLETE=zsh_source mvt-ios)" > ~/.mvt-ios-complete.zsh &&
echo "$(_MVT_ANDROID_COMPLETE=zsh_source mvt-android)" > ~/.mvt-android-complete.zsh
# Generate zsh completion scripts
mvt-ios completion zsh > ~/.mvt-ios-complete.zsh
mvt-android completion zsh > ~/.mvt-android-complete.zsh
```
Add the following to `~/.zshrc`:
```bash
# source mvt completion scripts
. ~/.mvt-ios-complete.zsh && . ~/.mvt-android-complete.zsh
[ -f ~/.mvt-ios-complete.zsh ] && . ~/.mvt-ios-complete.zsh
[ -f ~/.mvt-android-complete.zsh ] && . ~/.mvt-android-complete.zsh
```
### For Fish
```bash
# Generate fish completion scripts
mkdir -p ~/.config/fish/completions
mvt-ios completion fish > ~/.config/fish/completions/mvt-ios.fish
mvt-android completion fish > ~/.config/fish/completions/mvt-android.fish
```
Fish loads completion files from `~/.config/fish/completions` automatically.
### Automatic Installation
MVT can write the completion file and update the relevant shell configuration for Bash and Zsh when you pass `--install`:
```bash
mvt-ios completion bash --install
mvt-android completion bash --install
```
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/completions`.
For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion).
+107
View File
@@ -0,0 +1,107 @@
# Custom CLI Commands
MVT can load additional top-level commands into `mvt-ios` and `mvt-android`.
Custom commands are different from [custom forensic modules](development.md#custom-modules):
commands add new CLI operations, while modules add analysis steps to existing
`check-*` commands.
!!! warning
Custom commands run as trusted Python code inside the MVT process. Install
or load commands only from sources you trust. MVT does not sandbox
third-party commands, and the MVT maintainers do not maintain them.
## Install a Command Package
Python packages can register a Click command or group for either MVT CLI. A
minimal package can expose this command from `my_mvt_plugin.py`:
```python
import click
@click.command()
@click.argument("path", type=click.Path(exists=True))
def summarize(path):
"""Summarize an acquisition."""
click.echo(f"Summarizing {path}")
```
Register the object in the package's `pyproject.toml`. The entry-point name is
the command users invoke:
```toml
[project.entry-points."mvt.ios.cli_plugins"]
summarize = "my_mvt_plugin:summarize"
[project.entry-points."mvt.android.cli_plugins"]
summarize = "my_mvt_plugin:summarize"
```
Use only the iOS or Android group if the command is platform-specific. After
installing the package in the same environment as MVT, it appears directly in
the appropriate CLI:
```bash
mvt-ios summarize ./ios-backup
mvt-android summarize ./androidqf-output
```
For a `pipx` installation of MVT, inject the plugin into MVT's environment:
```bash
pipx inject mvt my-mvt-plugin
```
When MVT is installed in an active virtual environment, install the plugin with
`pip` in that environment.
## Load a Command File
For local commands that are not packaged, create a Python file that exports one
Click command or group named `cli`:
```python
import click
@click.command("case-summary")
@click.argument("path", type=click.Path(exists=True))
def cli(path):
"""Summarize a case directory."""
click.echo(f"Summarizing {path}")
```
Pass the file before the custom command name:
```bash
mvt-ios --load-command ./case_summary.py case-summary ./ios-backup
```
`--load-command` can be repeated and also accepts a folder. MVT loads
non-hidden top-level `*.py` files in sorted order and skips `__init__.py`.
Every loaded file must export one `cli` object.
To load a file or folder on every invocation, set the platform-specific
environment variable:
```bash
export MVT_IOS_CUSTOM_COMMANDS=./ios_commands
export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands
```
## Naming and Errors
Built-in MVT commands cannot be replaced. External command names must also be
unique; when installed packages or environment paths collide, MVT keeps the
first command and logs a warning. A collision from an explicit
`--load-command` is a usage error.
A package entry point or environment command that cannot be imported appears
as a marked broken command without preventing other MVT commands from working.
Invoke that command to see its package or file source and the underlying error.
An invalid command supplied explicitly with `--load-command` fails immediately
with a usage error.
Installed command packages use the entry-point name as the CLI command name.
The entry point must resolve to a `click.Command` or `click.Group`.
+106 -2
View File
@@ -2,9 +2,17 @@
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 `flake8`, `ruff` and `black`. All can
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `ruff` and `mypy`. All can
be run automatically with:
```bash
@@ -13,6 +21,102 @@ 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.
## Custom modules
Module-running `check-*` commands can load custom modules from Python files that
are not installed as part of MVT. Load one file with:
```bash
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
```
You can also load a folder. MVT loads non-hidden top-level `*.py` files in
sorted order and skips `__init__.py`:
```bash
mvt-ios check-fs --load-module ./custom_modules ./filesystem-dump
```
Set `MVT_CUSTOM_MODULES` to load a folder for every module-running command. This
folder is loaded before any `--load-module` path:
```bash
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
```
Custom modules are normal `MVTModule` subclasses:
```python
from mvt.common.module import MVTModule
class ExampleCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"), ("ios", "check-fs"))
slug = "example_custom_module"
def run(self):
self.results = [{"message": "custom module ran"}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
Use `supported_commands` to declare the platform/command pairs a module
supports. Empty `supported_commands` means the module will not run and MVT logs
a warning. This explicit declaration is required for every command. Supported
pairs are:
```python
("ios", "check-backup")
("ios", "check-fs")
("ios", "check-iocs")
("ios", "check-sysdiagnose")
("android", "check-backup")
("android", "check-bugreport")
("android", "check-androidqf")
("android", "check-intrusion-logs")
("android", "check-iocs")
```
Custom modules can depend on existing MVT module classes. Dependencies are
resolved with the same ordering logic as built-in modules, and custom modules
are appended after built-ins before ordering:
```python
from mvt.common.module import MVTModule
from mvt.ios.modules.backup.manifest import Manifest
class DependentCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"),)
dependencies = (Manifest,)
def run(self):
manifest_results = self.get_dependency_results(Manifest)
self.results = [{"manifest_entries": len(manifest_results)}]
```
## Profiling
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
@@ -24,4 +128,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.
+21 -2
View File
@@ -34,6 +34,27 @@ 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. URL checks are deduplicated and run concurrently, with at
most 20 requests in progress at a time. Redirects within an individual URL
chain are still followed sequentially. 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):
@@ -54,5 +75,3 @@ 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.
+9
View File
@@ -312,6 +312,15 @@ If indicators are provided through the command-line, they are checked against th
---
### `urls.json`
This JSON file collects URLs extracted from SMS, iMessage, and WhatsApp
messages. Each entry contains the original URL, its expanded destination when
MVT resolved a shortened URL during indicator checking, the message timestamp,
and its `sms` or `whatsapp` source.
---
### `sms_attachments.json`
!!! info "Availability"
+50
View File
@@ -0,0 +1,50 @@
# Check an iOS Sysdiagnose
`mvt-ios check-sysdiagnose` prepares an iOS sysdiagnose archive for analysis by
custom MVT modules. MVT does not include built-in sysdiagnose modules. You must
load at least one custom module that explicitly supports this command.
The command accepts either an extracted sysdiagnose directory or the original
gzip-compressed tar archive.
```bash
mvt-ios check-sysdiagnose \
--load-module ./sysdiagnose_modules.py \
--output ./results \
./sysdiagnose_2024.01.02_03-04-05+0200.tar.gz
```
Use `--hashes` to include hashes for analyzed files in `info.json`, and
`--list-modules` to display the eligible custom modules without running them.
## Writing a custom module
Extend `SysdiagnoseExtraction` to access the archive contents consistently for
both directory and tar inputs. Each module must declare the command explicitly
in `supported_commands`.
```python
from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
supported_commands = (("ios", "check-sysdiagnose"),)
slug = "example_sysdiagnose"
def run(self):
paths = self._get_files_by_pattern("*/example.log")
if paths:
content = self._get_file_content(paths[0]).decode("utf-8", "replace")
self.results = [{"content": content}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
The base class provides `from_sysdiagnose_folder()` and
`from_sysdiagnose_tar()` setup hooks, as well as protected file lookup, file
reading, and timezone extraction helpers. IPS crash-report metadata is exposed
on `ips_files`.
-5
View File
@@ -1,5 +0,0 @@
mkdocs==1.6.1
mkdocs-autorefs==1.4.4
mkdocs-material==9.7.6
mkdocs-material-extensions==1.3.1
mkdocstrings==1.0.4
+6
View File
@@ -3,6 +3,8 @@ repo_url: https://github.com/mvt-project/mvt
edit_uri: edit/main/docs/
copyright: Copyright &copy; 2021-2023 MVT Project Developers
site_description: Mobile Verification Toolkit Documentation
not_in_nav: |
android/adb.md
markdown_extensions:
- attr_list
- admonition
@@ -28,6 +30,8 @@ nav:
- Welcome: "index.md"
- Introduction: "introduction.md"
- Installation: "install.md"
- Command Completion: "command_completion.md"
- Custom CLI Commands: "custom_commands.md"
- Using Docker: "docker.md"
- MVT for iOS:
- iOS Forensic Methodology: "ios/methodology.md"
@@ -39,10 +43,12 @@ nav:
- Check a Filesystem Dump:
- Dumping the filesystem: "ios/filesystem/dump.md"
- Check a Filesystem Dump with mvt-ios: "ios/filesystem/check.md"
- Check a Sysdiagnose: "ios/sysdiagnose.md"
- Records extracted by mvt-ios: "ios/records.md"
- MVT for Android:
- Android Forensic Methodology: "android/methodology.md"
- Check an Android Backup (SMS messages): "android/backup.md"
- Check Android Intrusion Logs: "android/intrusion_logs.md"
- Indicators of Compromise: "iocs.md"
- Development: "development.md"
- License: "license.md"
+17 -8
View File
@@ -17,25 +17,25 @@ classifiers = [
"Programming Language :: Python",
]
dependencies = [
"click==8.3.2",
"click==8.3.3",
"rich==14.3.3",
"tld==0.13.1",
"tld==0.13.2",
"requests==2.33.1",
"simplejson==3.20.2",
"packaging==26.0",
"packaging==26.2",
"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",
"pyahocorasick==2.3.1",
"betterproto2==0.9.1",
"pydantic==2.13.3",
"pydantic-settings==2.13.1",
"pydantic-settings==2.14.0",
"NSKeyedUnArchiver==1.5.2",
"python-dateutil==2.9.0.post0",
"tzdata==2026.1",
"tzdata==2026.2",
]
requires-python = ">= 3.10"
@@ -58,6 +58,16 @@ dev = [
"ruff>=0.1.6",
"mypy>=1.7.1",
"betterproto2-compiler",
"types-PyYAML>=6.0.12.20250516",
"types-python-dateutil>=2.9.0.20250822",
"types-requests>=2.32.4.20250913",
]
docs = [
"mkdocs==1.6.1",
"mkdocs-autorefs==1.4.4",
"mkdocs-material==9.7.6",
"mkdocs-material-extensions==1.3.1",
"mkdocstrings==1.0.4",
]
[build-system]
@@ -71,8 +81,7 @@ omit = ["tests/*"]
directory = "htmlcov"
[tool.mypy]
install_types = true
non_interactive = true
install_types = false
ignore_missing_imports = true
packages = "src"
@@ -10,16 +10,20 @@ from .artifact import AndroidArtifact
class DumpsysAccessibilityArtifact(AndroidArtifact):
def check_indicators(self) -> None:
if not self.indicators:
return
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
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
self.alertstore.medium(
f'Found accessibility service: "{result["service"]}"',
"",
result,
)
continue
def parse(self, content: str) -> None:
"""
+29 -7
View File
@@ -13,6 +13,13 @@ from .artifact import AndroidArtifact
class DumpsysADBArtifact(AndroidArtifact):
multiline_fields = ["user_keys", "keystore"]
@staticmethod
def is_structural_line(key: str, vals: list) -> bool:
if key == "}":
return True
# XML keystore continuations also split on "=", but never into an identifier.
return len(vals) == 2 and key.isidentifier()
def indented_dump_parser(self, dump_data):
"""
Parse the indented dumpsys output, generated by DualDumpOutputStream in Android.
@@ -41,10 +48,18 @@ class DumpsysADBArtifact(AndroidArtifact):
if key == "":
# If the line is empty, it's the terminator for the multiline value
in_multiline = False
stack.pop()
else:
if isinstance(stack[-1], list):
stack.pop()
continue
if not self.is_structural_line(key, vals):
current_dict.append(line.lstrip())
continue
continue
in_multiline = False
if isinstance(stack[-1], list):
stack.pop()
current_dict = stack[-1]
if key == "}":
stack.pop()
@@ -131,10 +146,17 @@ class DumpsysADBArtifact(AndroidArtifact):
)
return
# 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()
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()
parsed = self.indented_dump_parser(json_content)
if parsed.get("debugging_manager") is None:
@@ -50,13 +50,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
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
records: list[dict[str, Any]] = []
for line in output.splitlines():
if line.startswith(" Daily from "):
if len(daily_updates) > 0:
self.results.extend(daily_updates)
records.extend(daily_updates)
daily_updates = []
timeframe = line[13:].strip()
@@ -89,35 +87,53 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
"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)
if len(daily_updates) > 0:
self.results.extend(daily_updates)
records.extend(daily_updates)
self._detect_uninstalls_and_downgrades(records)
self.results.extend(records)
def _detect_uninstalls_and_downgrades(
self, records: list[dict[str, Any]]
) -> None:
package_versions: dict[str, int] = {}
for record in sorted(
records,
key=lambda record: (
record["from"],
record["to"],
record["package_name"],
),
):
package_name = record["package_name"]
vers_nr = record["vers"]
if vers_nr == "0":
self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)",
record["from"],
record,
)
package_versions.pop(package_name, None)
continue
try:
current_vers = int(vers_nr)
except ValueError:
continue
previous_vers = package_versions.get(package_name)
if previous_vers is not None and current_vers < previous_vers:
record["action"] = "downgrade"
record["previous_vers"] = str(previous_vers)
self.alertstore.medium(
f"Detected downgrade of package {package_name} "
f"from vers {previous_vers} to vers {current_vers}",
record["from"],
record,
)
package_versions[package_name] = current_vers
@@ -31,21 +31,38 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact):
if line.strip() == "":
break
time_elapsed = line.strip().split(" ", 1)[0]
time_parts = line.strip().split()
time_elapsed = time_parts[0]
if (
len(time_parts) > 1
and len(time_parts[0]) == 5
and time_parts[0][2] == "-"
and ":" in time_parts[1]
):
time_elapsed = " ".join(time_parts[:2])
event = ""
if line.find("+job") > 0:
event = "start_job"
uid = line[line.find("+job") + 5 : line.find(":")]
service = line[line.find(":") + 1 :].strip('"')
payload = line.split("+job=", 1)[1]
uid, separator, service = payload.partition(":")
if not separator:
continue
service = service.strip().strip('"')
package_name = service.split("/")[0]
elif line.find("-job") > 0:
event = "end_job"
uid = line[line.find("-job") + 5 : line.find(":")]
service = line[line.find(":") + 1 :].strip('"')
payload = line.split("-job=", 1)[1]
uid, separator, service = payload.partition(":")
if not separator:
continue
service = service.strip().strip('"')
package_name = service.split("/")[0]
elif line.find("+running +wake_lock=") > 0:
uid = line[line.find("+running +wake_lock=") + 21 : line.find(":")]
payload = line.split("+running +wake_lock=", 1)[1]
uid, separator, _ = payload.partition(":")
if not separator:
continue
event = "wake"
service = (
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
+15 -29
View File
@@ -29,10 +29,9 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
def parse(self, output: str) -> None:
rxp = re.compile(
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\].*\[Pid:\((\d+)\)\](\w+).*sql\=\"(.+?)\""
) # pylint: disable=line-too-long
rxp_no_pid = re.compile(
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\][ ]{1}(\w+).*sql\=\"(.+?)\""
r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} "
r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*"
r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\""
) # pylint: disable=line-too-long
pool = None
@@ -56,29 +55,16 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
pool = None
continue
matches = rxp.findall(line)
if not matches:
matches = rxp_no_pid.findall(line)
if not matches:
continue
match = rxp.match(line)
if not match:
continue
match = matches[0]
self.results.append(
{
"isodate": match[0],
"action": match[1],
"sql": match[2],
"path": pool,
}
)
else:
match = matches[0]
self.results.append(
{
"isodate": match[0],
"pid": match[1],
"action": match[2],
"sql": match[3],
"path": pool,
}
)
result = {
"isodate": match.group(1),
"action": match.group(3),
"sql": match.group(4),
"path": pool,
}
if match.group(2):
result["pid"] = match.group(2)
self.results.append(result)
@@ -14,9 +14,12 @@ from .artifact import AndroidArtifact
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(
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
"",
@@ -188,7 +191,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
package = []
in_package_list = False
for line in content.split("\n"):
for line in content.splitlines():
if line.startswith("Packages:"):
in_package_list = True
continue
+13 -1
View File
@@ -96,6 +96,18 @@ class DumpsysReceiversArtifact(AndroidArtifact):
self.results[intent] = []
continue
parts = line.strip().split(" ")
if len(parts) < 2:
# A single-token line here is not a receiver. Real dumpstate
# output can print an action header mis-indented (observed with
# 15 leading spaces instead of 6), which used to raise
# IndexError and abort the whole module. Treat a trailing-colon
# token as the next action, skip anything else.
if parts[0].endswith(":"):
intent = parts[0][:-1]
self.results.setdefault(intent, [])
continue
# If we are not in an intent block yet, skip.
if not intent:
continue
@@ -109,7 +121,7 @@ class DumpsysReceiversArtifact(AndroidArtifact):
# If we got this far, we are processing receivers for the
# activities we are interested in.
receiver = line.strip().split(" ")[1]
receiver = parts[1]
package_name = receiver.split("/")[0]
self.results[intent].append(
+1 -1
View File
@@ -8,7 +8,7 @@ from .artifact import AndroidArtifact
class Processes(AndroidArtifact):
def parse(self, entry: str) -> None:
for line in entry.split("\n")[1:]:
for line in entry.splitlines()[1:]:
proc = line.split()
# Skip empty lines
+9 -6
View File
@@ -67,11 +67,14 @@ 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.log.warning(
'Found suspicious "%s" setting "%s = %s" (%s)',
namespace,
key,
value,
danger["description"],
self.alertstore.medium(
f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})',
"",
{
"namespace": namespace,
"key": key,
"value": value,
"description": danger["description"],
},
)
break
@@ -101,8 +101,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
continue
if result.get("command_line", []):
command_name = result.get("command_line")[0].split("/")[-1]
command_name = result["command_line"][0]
command_name = result["command_line"][0].split("/")[-1]
ioc_match = self.indicators.check_process(command_name)
if ioc_match:
self.alertstore.critical(
@@ -200,13 +199,18 @@ class TombstoneCrashArtifact(AndroidArtifact):
# eg. "Process uptime: 40s"
tombstone[destination_key] = int(value_clean.rstrip("s"))
elif destination_key == "command_line":
# XXX: Check if command line should be a single string in a list, or a list of strings.
# Wrap in list for consistency with protobuf format (repeated string).
tombstone[destination_key] = [value_clean]
else:
tombstone[destination_key] = value_clean
return True
def _load_pid_line(self, line: str, tombstone: dict) -> bool:
# The first pid line identifies the crashing thread. Full text tombstones
# contain additional pid lines for the other threads in the process.
if "pid" in tombstone:
return True
try:
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
process_info = parts[0]
@@ -262,7 +266,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
@staticmethod
def _parse_timestamp_string(timestamp: str) -> str:
timestamp_parsed = parser.parse(timestamp)
# HACK: Swap the local timestamp to UTC, so keep the original time and avoid timezone conversion.
# Preserve the source wall-clock time while returning the project-wide ISO format.
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
return convert_datetime_to_iso(local_timestamp)
+226 -9
View File
@@ -4,10 +4,23 @@
# https://license.mvt.re/1.1/
import logging
from zipfile import BadZipFile
import click
from mvt.common.cli_plugins import (
ANDROID_CLI_PLUGIN_GROUP,
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.help import (
HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_ADB_REMOVED,
@@ -16,25 +29,33 @@ from mvt.common.help import (
HELP_MSG_CHECK_ANDROIDQF,
HELP_MSG_CHECK_BUGREPORT,
HELP_MSG_CHECK_IOCS,
HELP_MSG_CHECK_INTRUSION_LOGS,
HELP_MSG_DELAY_CHECKS,
HELP_MSG_COMPLETION,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_HASHES,
HELP_MSG_IOC,
HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE,
HELP_MSG_NONINTERACTIVE,
HELP_MSG_OUTPUT,
HELP_MSG_STIX2,
HELP_MSG_VERBOSE,
HELP_MSG_VERSION,
HELP_MSG_VIRUS_TOTAL,
)
from mvt.common.logo import logo
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.updates import IndicatorsUpdates
from mvt.common.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
@@ -56,10 +77,18 @@ def _get_disable_flags(ctx):
)
def _load_custom_modules(load_module):
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
raise click.ClickException(str(exc)) from exc
# ==============================================================================
# Main
# ==============================================================================
@click.group(invoke_without_command=False)
@load_cli_commands_option
@click.option(
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
)
@@ -73,10 +102,11 @@ 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,
)
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ==============================================================================
@@ -87,6 +117,40 @@ def version():
return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-android"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ==============================================================================
# Command: check-adb (removed)
# ==============================================================================
@@ -116,11 +180,28 @@ def check_adb(ctx):
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context
def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_path):
def check_bugreport(
ctx,
iocs,
output,
list_modules,
module,
load_module,
verbose,
bugreport_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as bug reports are small.
cmd = CmdAndroidCheckBugreport(
target_path=bugreport_path,
@@ -130,6 +211,7 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
hashes=True,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -138,7 +220,10 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
log.info("Checking Android bug report at path: %s", bugreport_path)
cmd.run()
try:
cmd.run()
except BadZipFile as exc:
raise click.ClickException(f"Invalid bugreport archive: {exc}") from exc
cmd.show_alerts_brief()
cmd.show_support_message()
@@ -161,6 +246,13 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
)
@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(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@@ -171,12 +263,14 @@ def check_backup(
iocs,
output,
list_modules,
load_module,
non_interactive,
backup_password,
verbose,
backup_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as backups are generally small.
cmd = CmdAndroidCheckBackup(
@@ -190,6 +284,7 @@ def check_backup(
},
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -220,7 +315,18 @@ def check_backup(
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--virustotal", "-V", is_flag=True, help=HELP_MSG_VIRUS_TOTAL)
@click.option(
"--delay", "-d", type=click.IntRange(min=0), default=16, help=HELP_MSG_DELAY_CHECKS
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@@ -232,13 +338,17 @@ def check_androidqf(
output,
list_modules,
module,
load_module,
hashes,
virustotal,
delay,
non_interactive,
backup_password,
verbose,
androidqf_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
cmd = CmdAndroidCheckAndroidQF(
target_path=androidqf_path,
@@ -249,9 +359,12 @@ def check_androidqf(
module_options={
"interactive": not non_interactive,
"backup_password": cli_load_android_backup_password(log, backup_password),
"virustotal": virustotal,
"virustotal_delay": delay,
},
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -266,6 +379,85 @@ 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(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option(
"--timezone",
"-t",
default=None,
help=(
"IANA timezone name for the device, for example 'Europe/Paris'. "
"When provided, event timestamps are expressed in the device's local "
"time instead of UTC."
),
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("LOGS_PATH", type=click.Path(exists=True))
@click.pass_context
def check_intrusion_logs(
ctx,
iocs,
output,
list_modules,
module,
load_module,
timezone,
verbose,
logs_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
module_options = {}
if timezone:
module_options["device_timezone"] = timezone
cmd = CmdAndroidCheckIntrusionLogs(
target_path=logs_path,
results_path=output,
ioc_files=iocs,
module_name=module,
module_options=module_options if module_options else None,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking intrusion logs at path: %s", logs_path)
cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message()
# ==============================================================================
# Command: check-iocs
# ==============================================================================
@@ -280,11 +472,29 @@ def check_androidqf(
)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.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)
cmd.modules = BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
custom_modules = _load_custom_modules(load_module)
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],
custom_modules=custom_modules,
platform="android",
)
cmd.modules = (
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
)
if list_modules:
cmd.list_modules()
@@ -302,3 +512,10 @@ def check_iocs(ctx, iocs, list_modules, module, folder):
def download_indicators():
ioc_updates = IndicatorsUpdates()
ioc_updates.update()
register_cli_plugins(
cli,
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
)
+198 -3
View File
@@ -3,16 +3,22 @@
# 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
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import List, Optional
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup
from mvt.android.artifacts.getprop import GetProp
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup, InvalidAndroidBackup
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.androidqf import ANDROIDQF_MODULES
from .modules.androidqf.base import AndroidQFModule
@@ -46,6 +52,7 @@ class CmdAndroidCheckAndroidQF(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -60,8 +67,10 @@ class CmdAndroidCheckAndroidQF(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-androidqf"
self.modules = ANDROIDQF_MODULES
@@ -86,6 +95,47 @@ class CmdAndroidCheckAndroidQF(Command):
self.__zip = zipfile.ZipFile(self.target_path)
self.__files = self.__zip.namelist()
self._load_acquisition_context()
def _load_acquisition_context(self) -> None:
"""Pass AndroidQF acquisition metadata to nested commands and modules."""
context = {}
metadata_files = [
file_path
for file_path in self.__files
if file_path.replace("\\", "/").rsplit("/", 1)[-1] == "acquisition.json"
]
for file_path in metadata_files:
try:
metadata = json.loads(self._get_file_content(file_path))
if isinstance(metadata, dict):
context["started"] = metadata.get("started")
context["adb_host_public_key"] = metadata.get("adb_host_public_key")
break
except (json.JSONDecodeError, OSError, TypeError, UnicodeDecodeError):
self.log.warning(
'Unable to read AndroidQF acquisition metadata "%s"', file_path
)
if not context.get("adb_host_public_key"):
key_files = [
file_path
for file_path in self.__files
if file_path.replace("\\", "/").rsplit("/", 1)[-1] == "adb_host_key.pub"
]
for file_path in key_files:
try:
context["adb_host_public_key"] = self._get_file_content(
file_path
).decode("utf-8")
break
except (OSError, UnicodeDecodeError):
self.log.warning(
'Unable to read AndroidQF ADB host key "%s"', file_path
)
self.module_options["androidqf_acquisition"] = context
def module_init(self, module: AndroidQFModule) -> None: # type: ignore[override]
if self.__format == "zip" and self.__zip:
module.from_zip(self.__zip, self.__files)
@@ -139,6 +189,55 @@ 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:
@@ -157,11 +256,13 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=self.module_options,
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
)
cmd.from_zip(bugreport)
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts)
finally:
if bugreport:
@@ -186,17 +287,111 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=self.module_options,
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
)
cmd.from_ab(backup)
try:
cmd.from_ab(backup)
except InvalidAndroidBackup as exc:
self.log.warning(
"Skipping backup modules as backup.ab is malformed: %s", exc
)
return False
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
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_")
temp_root = Path(temp_dir).resolve()
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 = (temp_root / relative).resolve()
if not target.is_relative_to(temp_root):
self.log.warning(
"Skipping unsafe intrusion log archive entry: %s", entry
)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with self.__zip.open(entry) as src, target.open("wb") as dst:
dst.write(src.read())
intrusion_logs_path = temp_dir
else:
return False
adv_module_options = dict(self.module_options or {})
if device_timezone := self._read_device_timezone():
adv_module_options["device_timezone"] = device_timezone
cmd = CmdAndroidCheckIntrusionLogs(
target_path=intrusion_logs_path,
results_path=self.results_path,
ioc_files=self.ioc_files,
iocs=self.iocs,
module_options=adv_module_options,
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
)
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts)
return True
finally:
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
def finish(self) -> None:
"""
Run the bugreport and backup modules if the respective files are found in the AndroidQF data.
Run nested modules if their respective files are found in AndroidQF data.
"""
self.run_bugreport_cmd()
self.run_backup_cmd()
self.run_intrusion_logs_cmd()
+26 -1
View File
@@ -21,12 +21,17 @@ from mvt.android.parsers.backup import (
)
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.backup import BACKUP_MODULES
log = logging.getLogger(__name__)
class InvalidAndroidBackup(Exception):
pass
class CmdAndroidCheckBackup(Command):
def __init__(
self,
@@ -41,6 +46,7 @@ class CmdAndroidCheckBackup(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -55,8 +61,10 @@ class CmdAndroidCheckBackup(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-backup"
self.modules = BACKUP_MODULES
@@ -68,6 +76,10 @@ class CmdAndroidCheckBackup(Command):
self.__type = "ab"
header = parse_ab_header(ab_file_bytes)
if not header["backup"]:
if self.sub_command:
raise InvalidAndroidBackup(
"Invalid backup format, file should be in .ab format"
)
log.critical("Invalid backup format, file should be in .ab format")
sys.exit(1)
@@ -83,12 +95,25 @@ class CmdAndroidCheckBackup(Command):
log.critical("Invalid backup password")
sys.exit(1)
except AndroidBackupParsingError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1)
dbytes = io.BytesIO(tardata)
self.__tar = tarfile.open(fileobj=dbytes)
try:
self.__tar = tarfile.open(fileobj=dbytes)
except tarfile.TarError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1)
for member in self.__tar:
self.__files.append(member.name)
+4
View File
@@ -12,6 +12,7 @@ from zipfile import ZipFile
from mvt.android.modules.bugreport.base import BugReportModule
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.bugreport import BUGREPORT_MODULES
@@ -32,6 +33,7 @@ class CmdAndroidCheckBugreport(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -46,8 +48,10 @@ class CmdAndroidCheckBugreport(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-bugreport"
self.modules = BUGREPORT_MODULES
+117
View File
@@ -0,0 +1,117 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
import os
from typing import Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.intrusion_logs import (
INTRUSION_LOGS_MODULES,
KNOWN_INTRUSION_LOG_EVENT_TYPES,
)
from .modules.intrusion_logs.base import IntrusionLogsModule
log = logging.getLogger(__name__)
class CmdAndroidCheckIntrusionLogs(Command):
"""Command to check Android Intrusion Logging files."""
def __init__(
self,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
ioc_files: Optional[list] = None,
iocs: Optional[Indicators] = None,
module_name: Optional[str] = None,
serial: Optional[str] = None,
module_options: Optional[dict] = None,
hashes: Optional[bool] = False,
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
results_path=results_path,
ioc_files=ioc_files,
iocs=iocs,
module_name=module_name,
serial=serial,
module_options=module_options,
hashes=hashes,
sub_command=sub_command,
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "android"
self.name = "check-intrusion-logs"
self.modules = INTRUSION_LOGS_MODULES
self._all_events: dict[str, list[dict]] = {}
def init(self) -> None:
if not self.target_path:
raise ValueError("No target path specified")
if not os.path.isdir(self.target_path) and not (
os.path.isfile(self.target_path)
and self.target_path.lower().endswith(".zip")
):
raise ValueError(
f"Target path must be a directory or a .zip file: {self.target_path}"
)
self.log.info("Checking intrusion logs at path: %s", self.target_path)
self._all_events = self._pre_load_events()
def module_init(self, module: IntrusionLogsModule) -> None: # type: ignore[override]
module.il_events_by_type = self._all_events
def finish(self) -> None:
return
def _pre_load_events(self) -> dict[str, list[dict]]:
"""Load and parse all advanced-log files once for reuse by all modules."""
self.log.info("Pre-loading intrusion log files from: %s", self.target_path)
loader = IntrusionLogsModule(
target_path=self.target_path,
log=self.log,
)
try:
all_events = loader.load_all_events(self.target_path)
except Exception as exc:
self.log.error("Failed to pre-load events: %s", exc)
return {}
total_events = sum(len(events) for events in all_events.values())
self.log.info(
"Pre-loaded %d events across %d type(s); modules will reuse this data",
total_events,
len(all_events),
)
unknown_event_types = sorted(
event_type
for event_type in all_events
if event_type not in KNOWN_INTRUSION_LOG_EVENT_TYPES
)
if unknown_event_types:
self.log.warning(
"Found unknown intrusion logging event type(s): %s. "
"Please open an issue on GitHub so MVT can add support for them.",
", ".join(unknown_event_types),
)
return all_events
@@ -5,12 +5,12 @@
from .aqf_files import AQFFiles
from .aqf_getprop import AQFGetProp
from .aqf_log_timestamps import AQFLogTimestamps
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 .sms import SMS
ANDROIDQF_MODULES = [
AQFPackages,
@@ -18,7 +18,7 @@ ANDROIDQF_MODULES = [
AQFGetProp,
AQFSettings,
AQFFiles,
SMS,
AQFLogTimestamps,
RootBinaries,
Mounts,
]
+15 -15
View File
@@ -41,7 +41,7 @@ class AQFFiles(AndroidQFModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -107,16 +107,16 @@ class AQFFiles(AndroidQFModule):
msg = f'Found {file_type}file at suspicious path "{result["path"]}"'
self.alertstore.high(msg, "", result)
if result.get("sha256", "") == "":
continue
ioc_match = self.indicators.check_file_hash(result.get("sha256") or "")
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
# TODO: adds SHA1 and MD5 when available in MVT
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
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.split("\n"):
for line in rawdata.splitlines():
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 tiem on Android device's local timezone
# Convert the UTC timestamp to local time on Android device's local timezone
local_timestamp = utc_timestamp.astimezone(device_timezone)
# 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.
# Preserve the device-local wall-clock time while using
# the project-wide ISO conversion helper.
local_timestamp = local_timestamp.replace(
tzinfo=datetime.timezone.utc
)
@@ -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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -32,7 +32,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule):
log=log,
results=results,
)
self.results: list = []
self.results: list = [] if results is None else results
def run(self) -> None:
getprop_files = self._get_files_by_pattern("*/getprop.txt")
@@ -27,7 +27,7 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -5,8 +5,11 @@
import json
import logging
import time
from typing import Optional
from rich.progress import track
from mvt.android.utils import (
BROWSER_INSTALLERS,
PLAY_STORE_INSTALLERS,
@@ -15,7 +18,8 @@ from mvt.android.utils import (
SYSTEM_UPDATE_PACKAGES,
THIRD_PARTY_STORE_INSTALLERS,
)
from mvt.common.module_types import ModuleResults
from mvt.common.module_types import ModuleAtomicResult, ModuleResults
from mvt.common.virustotal import VTNoKey, VTQuotaExceeded, virustotal_lookup
from .base import AndroidQFModule
@@ -30,7 +34,7 @@ class AQFPackages(AndroidQFModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -125,6 +129,65 @@ class AQFPackages(AndroidQFModule):
)
break
if self.module_options.get("virustotal", False):
self.check_virustotal(
delay=self.module_options.get("virustotal_delay", 0)
)
def check_virustotal(self, delay: int = 0) -> None:
files_by_hash: dict[
str, list[tuple[ModuleAtomicResult, ModuleAtomicResult]]
] = {}
for package in self.results:
if package.get("system", False):
continue
for package_file in package.get("files", []):
file_hash = package_file.get("sha256")
if not file_hash:
continue
files_by_hash.setdefault(file_hash, []).append((package, package_file))
total_hashes = len(files_by_hash)
if total_hashes == 0:
return
progress_desc = f"Looking up {total_hashes} package files on VirusTotal..."
for index, file_hash in enumerate(
track(files_by_hash, description=progress_desc)
):
try:
results = virustotal_lookup(file_hash)
except VTNoKey as exc:
self.log.warning("%s", exc)
return
except VTQuotaExceeded as exc:
self.log.warning("Unable to continue VirusTotal lookups: %s", exc)
break
if index < total_hashes - 1 and delay > 0:
time.sleep(delay)
if not results:
continue
attributes = results.get("attributes", {})
stats = attributes.get("last_analysis_stats", {})
positives = stats.get("malicious", 0)
total = len(attributes.get("last_analysis_results", {}))
detection = f"{positives}/{total}"
for package, package_file in files_by_hash[file_hash]:
package_file["virustotal"] = detection
if positives > 0:
self.alertstore.high(
f'VirusTotal flagged package "{package["name"]}" file '
f'"{package_file["path"]}" with {detection} detections',
"",
package,
)
def run(self) -> None:
packages = self._get_files_by_pattern("*/packages.json")
if not packages:
@@ -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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -32,7 +32,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
log=log,
results=results,
)
self.results: dict = {}
self.results: dict = results if results is not None else {}
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").split("\n"):
for line in data.decode("utf-8").splitlines():
line = line.strip()
try:
key, value = line.split("=", 1)
+1 -1
View File
@@ -23,7 +23,7 @@ class AndroidQFModule(MVTModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
+4 -1
View File
@@ -32,7 +32,7 @@ class Mounts(MountsArtifact, AndroidQFModule):
log=log,
results=results,
)
self.results: list = []
self.results: list = [] if results is None else results
def run(self) -> None:
"""
@@ -66,6 +66,9 @@ class Mounts(MountsArtifact, AndroidQFModule):
# 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)
-109
View File
@@ -1,109 +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
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
from mvt.android.parsers.backup import (
AndroidBackupParsingError,
InvalidBackupPassword,
parse_ab_header,
parse_backup_file,
parse_tar_for_sms,
)
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__(
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:
if not self.indicators:
return
for message in self.results:
if "body" not in message:
continue
ioc_match = self.indicators.check_domains(message.get("links", []))
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
def parse_backup(self, data):
header = parse_ab_header(data)
if not header["backup"]:
self.log.critical("Invalid backup format, backup.ab was not analysed")
return
password = None
if header["encryption"] != "none":
password = prompt_or_load_android_backup_password(
self.log, self.module_options
)
if not password:
self.log.critical("No backup password provided.")
return
try:
tardata = parse_backup_file(data, password=password)
except InvalidBackupPassword:
self.log.critical("Invalid backup password")
return
except AndroidBackupParsingError:
self.log.critical(
"Impossible to parse this backup file, please use"
" Android Backup Extractor instead"
)
return
if not tardata:
return
try:
self.results = parse_tar_for_sms(tardata)
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
def run(self) -> None:
files = self._get_files_by_pattern("*/backup.ab")
if not files:
self.log.info("No backup data found")
return
self.parse_backup(self._get_file_content(files[0]))
self.log.info("Identified %d SMS in backup data", len(self.results))
+1 -1
View File
@@ -22,7 +22,7 @@ class BackupModule(MVTModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
+2 -3
View File
@@ -4,9 +4,8 @@
# https://license.mvt.re/1.1/
from rich.prompt import Prompt
from mvt.common.config import settings
from mvt.common.password import prompt_password
MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD"
@@ -49,7 +48,7 @@ def prompt_or_load_android_backup_password(log, module_options):
# The default is to allow interactivity
elif module_options.get("interactive", True):
backup_password = Prompt.ask(prompt="Enter backup password", password=True)
backup_password = prompt_password("Enter backup password: ")
else:
log.critical(
"Cannot decrypt backup because interactivity"
+14 -3
View File
@@ -20,7 +20,7 @@ class SMS(BackupModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -36,6 +36,8 @@ class SMS(BackupModule):
if not self.indicators:
return
messages = []
url_batches = []
for message in self.results:
if "body" not in message:
continue
@@ -44,12 +46,21 @@ class SMS(BackupModule):
if message_links == []:
message_links = check_for_links(message.get("text", ""))
ioc_match = self.indicators.check_urls(message_links)
messages.append(message)
url_batches.append(message_links)
for message, ioc_match in zip(
messages, self.indicators.check_url_batches(url_batches)
):
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
continue
def collect_url_results(self) -> None:
for message in self.results:
for url in message.get("links", []):
self.add_url_result(url, message.get("isodate"), "sms")
def run(self) -> None:
sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup"
+6 -2
View File
@@ -6,6 +6,7 @@ import datetime
import fnmatch
import logging
import os
from pathlib import Path
from typing import List, Optional
from zipfile import ZipFile
@@ -22,7 +23,7 @@ class BugReportModule(MVTModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -70,7 +71,10 @@ class BugReportModule(MVTModule):
else:
if not self.extract_path:
raise ValueError("extract_path is not set")
handle = open(os.path.join(self.extract_path, file_path), "rb")
joined = os.path.join(self.extract_path, file_path)
if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()):
raise ValueError("unsafe file_path")
handle = open(joined, "rb")
data = handle.read()
handle.close()
@@ -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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> 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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -3,11 +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 base64
import binascii
import datetime
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
from mvt.common.module_types import ModuleResults
from mvt.common.utils import convert_datetime_to_iso
from .base import BugReportModule
@@ -22,7 +26,7 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -53,3 +57,122 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
"Identified a total of %d trusted ADB keys",
len(self.results[0].get("user_keys", [])),
)
@staticmethod
def _key_material(public_key: object) -> str:
if isinstance(public_key, bytes):
public_key = public_key.decode("utf-8", errors="replace")
if not isinstance(public_key, str):
return ""
return public_key.strip().split(" ", 1)[0]
@staticmethod
def _is_valid_key(public_key: str) -> bool:
if not public_key:
return False
try:
return bool(base64.b64decode(public_key, validate=True))
except (binascii.Error, ValueError):
return False
@staticmethod
def _parse_acquisition_time(value: object) -> Optional[datetime.datetime]:
if not isinstance(value, str):
return None
try:
timestamp = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if not timestamp.tzinfo:
timestamp = timestamp.replace(tzinfo=datetime.timezone.utc)
return timestamp.astimezone(datetime.timezone.utc)
@staticmethod
def _parse_last_connected(value: object) -> Optional[datetime.datetime]:
try:
return datetime.datetime.fromtimestamp(
int(str(value)) / 1000,
tz=datetime.timezone.utc,
)
except (OSError, OverflowError, TypeError, ValueError):
return None
def _trusted_keys(self) -> list[dict]:
"""Return unique trusted keys, preferring keystore connection metadata."""
trusted_keys = []
seen = set()
for result in self.results:
keystore = result.get("keystore", [])
candidates = keystore if isinstance(keystore, list) else []
candidates = [*candidates, *result.get("user_keys", [])]
for candidate in candidates:
if not isinstance(candidate, dict):
continue
key = self._key_material(candidate.get("key"))
identity = key or repr(candidate)
if identity in seen:
continue
seen.add(identity)
trusted_keys.append(candidate)
return trusted_keys
def check_indicators(self) -> None:
if "androidqf_acquisition" not in self.module_options:
return super().check_indicators()
context = self.module_options.get("androidqf_acquisition")
if not isinstance(context, dict):
context = {}
acquisition_key = self._key_material(context.get("adb_host_public_key"))
if acquisition_key and not self._is_valid_key(acquisition_key):
acquisition_key = ""
acquisition_time = self._parse_acquisition_time(context.get("started"))
cutoff = (
acquisition_time - datetime.timedelta(days=1) if acquisition_time else None
)
for trusted_key in self._trusted_keys():
key = self._key_material(trusted_key.get("key"))
fingerprint = trusted_key.get("fingerprint") or "<unknown key>"
user = trusted_key.get("user") or "unknown user"
description = f"{fingerprint} ({user})"
last_connected = self._parse_last_connected(
trusted_key.get("last_connected")
)
event_time = (
convert_datetime_to_iso(last_connected) if last_connected else ""
)
if not self._is_valid_key(key):
self.alertstore.low(
f"Found an invalid trusted ADB host key: {description}",
event_time,
trusted_key,
)
continue
if not acquisition_key:
self.alertstore.low(
"Found a trusted ADB host key, but the AndroidQF acquisition "
f"does not include its host key: {description}",
event_time,
trusted_key,
)
continue
if key != acquisition_key:
self.alertstore.low(
"Found a trusted ADB host key different from the AndroidQF "
f"acquisition host: {description}",
event_time,
trusted_key,
)
continue
if cutoff and last_connected and last_connected <= cutoff:
self.alertstore.info(
"Found a trusted ADB host key last connected at least one day "
f"before the AndroidQF acquisition: {description}",
event_time,
trusted_key,
)
@@ -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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> 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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> 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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> 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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> 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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -42,8 +42,10 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
)
return
data = data.decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(data, "DUMP OF SERVICE platform_compat:")
decoded_data = data.decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(
decoded_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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> 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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> 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: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -0,0 +1,20 @@
# 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",
}
@@ -0,0 +1,395 @@
# 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")
@@ -0,0 +1,121 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import 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))
@@ -0,0 +1,141 @@
# 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))
@@ -0,0 +1,789 @@
# 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",
}
# Known platform failures that remain in the timeline but do not need a warning.
KNOWN_BENIGN_KEY_GENERATION_FAILURES = {
("PinStorage_crossReboot_key", 1001),
}
class SecurityEvent(IntrusionLogsModule):
"""This module analyzes security events from intrusion logs."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.event_type_counts: dict[str, int] = {}
def _get_event_tag(self, event_data: dict) -> Optional[str]:
"""Return the security-event tag key, including tags unknown to MVT."""
for key in event_data:
if key not in SECURITY_EVENT_METADATA_KEYS:
return key
return None
def check_indicators(self) -> None:
"""Check security events against indicators of compromise."""
for result in self.results:
# Heuristic alerts are intrinsic to the event, so they run even
# when no indicator set is loaded.
self._check_security_heuristics(result)
# The remaining checks match events against loaded indicators.
if not self.indicators:
continue
# Check app process start events for suspicious package names
if "app_process_start" in result:
process_info = result["app_process_start"]
process_name = process_info.get("process", "")
if process_name:
# Check the full process name
ioc = self.indicators.check_app_id(process_name)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Also check process components after the first colon
# Example: "com.google.android.webview:sandboxed_process0:org.chromium.content.app.SandboxedProcessService0:0"
# We want to check "sandboxed_process0" and subsequent components
if ":" in process_name:
components = process_name.split(":")
for component in components[
1:
]: # Skip the first component (main package name)
if component:
ioc = self.indicators.check_app_id(component)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
break
# Check package operations for suspicious packages
for pkg_event in [
"package_installed",
"package_updated",
"package_uninstalled",
]:
if pkg_event in result:
pkg_info = result[pkg_event]
pkg_name = pkg_info.get("package_name", "")
if pkg_name:
ioc = self.indicators.check_app_id(pkg_name)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Check ADB shell commands for suspicious patterns
if "adb_shell_cmd" in result:
cmd_info = result["adb_shell_cmd"]
command = cmd_info.get("command", "")
if command:
# Check if command contains any suspicious app IDs
ioc = self.indicators.check_app_id(command)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
# Check ADB file sync events for suspicious paths
for adb_event in ["adb_sync_recv_file", "adb_sync_send_file"]:
if adb_event in result:
file_info = result[adb_event]
file_path = file_info.get("path", "")
if file_path:
ioc = self.indicators.check_file_path(file_path)
if ioc:
self.alertstore.critical(
ioc.message,
result.get("timestamp") or "",
result,
matched_indicator=ioc.ioc,
)
def _check_security_heuristics(self, result: dict) -> None:
"""Raise alerts for events that are intrinsically suspicious,
independent of any loaded indicators."""
# Flag failed cryptographic operations as potentially suspicious
if "key_generated" in result:
key_info = result["key_generated"]
key_id = key_info.get("key_id", "unknown")
uid = key_info.get("uid")
is_known_benign_failure = (
key_id,
uid,
) in KNOWN_BENIGN_KEY_GENERATION_FAILURES
if not key_info.get("success", True) and not is_known_benign_failure:
self.log.warning(
"Failed key generation detected for key_id: %s",
key_id,
)
# Flag certificate validation failures
if "cert_validation_failure" in result:
self.alertstore.medium(
"Certificate validation failure detected: "
f"{result.get('cert_validation_failure')}",
result.get("timestamp") or "",
result,
)
# Flag key integrity violations
if "key_integrity_violation" in result:
self.alertstore.medium(
f"Key integrity violation detected: {result.get('key_integrity_violation')}",
result.get("timestamp") or "",
result,
)
# Flag successful certificate authority installations (potential
# MITM); a missing success field is treated as installed
if "cert_authority_installed" in result:
cert_info = result["cert_authority_installed"]
if cert_info.get("success", True):
self.alertstore.medium(
"Certificate authority installed: "
f"{cert_info.get('subject', 'unknown')}",
result.get("timestamp") or "",
result,
)
else:
self.log.warning(
"Failed certificate authority install attempt: %s",
cert_info.get("subject", "unknown"),
)
# Flag wipe failures
if "wipe_failure" in result:
self.alertstore.medium(
"Device wipe failure detected",
result.get("timestamp") or "",
result,
)
# Flag crypto self test failures
if "crypto_self_test_completed" in result:
test_result = result["crypto_self_test_completed"]
if isinstance(test_result, dict):
success = test_result.get("success", True)
else:
success = test_result == 1
if not success:
self.alertstore.medium(
"Cryptographic self test failed",
result.get("timestamp") or "",
result,
)
def serialize(self, record: dict) -> Union[dict, list]:
"""Serialize a security event record for timeline output."""
# Determine the event sub-type
event_subtype = None
event_data_str = ""
event_subtype = self._get_event_tag(record)
if event_subtype:
event_info = record[event_subtype]
if event_subtype in SECURITY_EVENT_TAGS:
# ADB events
if event_subtype == "adb_shell_interactive":
event_data_str = "ADB interactive shell opened"
elif event_subtype == "adb_shell_cmd":
command = event_info.get("command", "")
event_data_str = f"ADB shell command: {command}"
elif event_subtype == "adb_sync_recv_file":
path = event_info.get("path", "")
event_data_str = f"File pulled via ADB: {path}"
elif event_subtype == "adb_sync_send_file":
path = event_info.get("path", "")
event_data_str = f"File pushed via ADB: {path}"
# App process events
elif event_subtype == "app_process_start":
process_name = event_info.get("process", "")
uid = event_info.get("uid", "")
pid = event_info.get("pid", "")
event_data_str = (
f"Process started: {process_name} (UID: {uid}, PID: {pid})"
)
# Keyguard events
elif event_subtype == "keyguard_dismiss_auth_attempt":
success = event_info.get("success", False)
method = event_info.get("method_strength", 0)
event_data_str = f"Auth attempt: {'Success' if success else 'Failed'} (method strength: {method})"
elif event_subtype == "keyguard_dismissed":
event_data_str = "Keyguard dismissed"
elif event_subtype == "keyguard_secured":
event_data_str = "Device locked"
elif event_subtype == "keyguard_disabled_features_set":
admin = event_info.get("admin_package", "")
features = event_info.get("disabled_features", "")
event_data_str = (
f"Keyguard features disabled by {admin}: {features}"
)
# Key events
elif event_subtype == "key_generated":
success = event_info.get("success", False)
key_id = event_info.get("key_id", "unknown")
uid = event_info.get("uid", "")
event_data_str = f"Key {'generated' if success else 'generation failed'}: {key_id} (UID: {uid})"
elif event_subtype == "key_destruction":
success = event_info.get("success", False)
key_id = event_info.get("key_id", "unknown")
uid = event_info.get("uid", "")
event_data_str = f"Key {'destroyed' if success else 'destruction failed'}: {key_id} (UID: {uid})"
elif event_subtype == "key_import":
success = event_info.get("success", False)
key_id = event_info.get("key_id", "unknown")
event_data_str = (
f"Key {'imported' if success else 'import failed'}: {key_id}"
)
elif event_subtype == "key_integrity_violation":
key_id = event_info.get("key_id", "unknown")
event_data_str = f"Key integrity violation: {key_id}"
# Certificate events
elif event_subtype == "cert_authority_installed":
success = event_info.get("success", False)
subject = event_info.get("subject", "unknown")
event_data_str = f"Cert {'installed' if success else 'install failed'}: {subject}"
elif event_subtype == "cert_authority_removed":
success = event_info.get("success", False)
subject = event_info.get("subject", "unknown")
event_data_str = (
f"Cert {'removed' if success else 'removal failed'}: {subject}"
)
elif event_subtype == "cert_validation_failure":
reason = (
event_info if isinstance(event_info, str) else str(event_info)
)
event_data_str = f"Certificate validation failure: {reason}"
elif event_subtype == "crypto_self_test_completed":
if isinstance(event_info, dict):
success = event_info.get("success", False)
else:
success = event_info == 1
event_data_str = (
f"Crypto self test: {'passed' if success else 'FAILED'}"
)
# Package events
elif event_subtype in [
"package_installed",
"package_updated",
"package_uninstalled",
]:
pkg_name = event_info.get("package_name", "")
version = event_info.get("version_code", "")
user_id = event_info.get("user_id", "")
action = event_subtype.replace("package_", "").title()
event_data_str = (
f"Package {action}: {pkg_name} (v{version}, user: {user_id})"
)
# OS events
elif event_subtype == "os_startup":
verified_boot = event_info.get("verified_boot_state", "")
dm_verity = event_info.get("dm_verity_mode", "")
event_data_str = f"OS startup (verified boot: {verified_boot}, dm-verity: {dm_verity})"
elif event_subtype == "os_shutdown":
event_data_str = "OS shutdown"
# Logging events
elif event_subtype == "logging_started":
event_data_str = "Audit logging started"
elif event_subtype == "logging_stopped":
event_data_str = "Audit logging stopped"
elif event_subtype == "log_buffer_size_critical":
event_data_str = "Log buffer at 90% capacity"
# Media events
elif event_subtype == "media_mount":
mount_point = event_info.get("mount_point", "")
label = event_info.get("volume_label", "")
event_data_str = f"Media mounted: {mount_point} ({label})"
elif event_subtype == "media_unmount":
mount_point = event_info.get("mount_point", "")
label = event_info.get("volume_label", "")
event_data_str = f"Media unmounted: {mount_point} ({label})"
# Password policy events
elif event_subtype == "password_expiration_set":
admin = event_info.get("admin_package", "")
timeout = event_info.get("timeout_ms", "")
event_data_str = f"Password expiration set by {admin}: {timeout}ms"
elif event_subtype == "password_complexity_set":
admin = event_info.get("admin_package", "")
event_data_str = f"Password complexity set by {admin}"
elif event_subtype == "password_complexity_required":
admin = event_info.get("admin_package", "")
complexity = event_info.get("complexity", "")
event_data_str = (
f"Password complexity required by {admin}: {complexity}"
)
elif event_subtype == "password_history_length_set":
admin = event_info.get("admin_package", "")
length = event_info.get("length", "")
event_data_str = f"Password history length set by {admin}: {length}"
elif event_subtype == "password_changed":
complexity = event_info.get("complexity", "")
user_id = event_info.get("user_id", "")
event_data_str = (
f"Password changed (complexity: {complexity}, user: {user_id})"
)
elif event_subtype == "max_screen_lock_timeout_set":
admin = event_info.get("admin_package", "")
timeout = event_info.get("timeout_ms", "")
event_data_str = (
f"Max screen lock timeout set by {admin}: {timeout}ms"
)
elif event_subtype == "max_password_attempts_set":
admin = event_info.get("admin_package", "")
attempts = event_info.get("max_attempts", "")
event_data_str = f"Max password attempts set by {admin}: {attempts}"
# Remote lock and wipe events
elif event_subtype == "remote_lock":
admin = event_info.get("admin_package", "")
event_data_str = f"Device remotely locked by {admin}"
elif event_subtype == "wipe_failure":
event_data_str = "Device wipe failed"
# User restriction events
elif event_subtype == "user_restriction_added":
admin = event_info.get("admin_package", "")
restriction = event_info.get("restriction", "")
event_data_str = f"User restriction added by {admin}: {restriction}"
elif event_subtype == "user_restriction_removed":
admin = event_info.get("admin_package", "")
restriction = event_info.get("restriction", "")
event_data_str = (
f"User restriction removed by {admin}: {restriction}"
)
# WiFi events
elif event_subtype == "wifi_connection":
bssid = event_info.get("bssid", "")
event_type = event_info.get("event_type", "")
reason = event_info.get("reason", "")
event_data_str = f"WiFi connection: {event_type} (BSSID: {bssid})"
if reason:
event_data_str += f" - {reason}"
elif event_subtype == "wifi_disconnection":
bssid = event_info.get("bssid", "")
reason = event_info.get("reason", "")
event_data_str = f"WiFi disconnection (BSSID: {bssid})"
if reason:
event_data_str += f" - {reason}"
# Bluetooth events
elif event_subtype == "bluetooth_connection":
mac = event_info.get("mac_address", "")
success = event_info.get("success", False)
reason = event_info.get("reason", "")
event_data_str = f"Bluetooth {'connected' if success else 'connection failed'}: {mac}"
if reason:
event_data_str += f" - {reason}"
elif event_subtype == "bluetooth_disconnection":
mac = event_info.get("mac_address", "")
reason = event_info.get("reason", "")
event_data_str = f"Bluetooth disconnected: {mac}"
if reason:
event_data_str += f" - {reason}"
# Camera policy event
elif event_subtype == "camera_policy_set":
admin = event_info.get("admin_package", "")
disabled = event_info.get("disabled", False)
event_data_str = (
f"Camera {'disabled' if disabled else 'enabled'} by {admin}"
)
# Backup service event
elif event_subtype == "backup_service_toggled":
admin = event_info.get("admin_package", "")
enabled = event_info.get("enabled", False)
event_data_str = f"Backup service {'enabled' if enabled else 'disabled'} by {admin}"
# NFC events
elif event_subtype == "nfc_enabled":
event_data_str = "NFC enabled"
elif event_subtype == "nfc_disabled":
event_data_str = "NFC disabled"
else:
event_data_str = (
f"{SECURITY_EVENT_TAGS.get(event_subtype, {}).get('name', event_subtype)}: "
f"{event_info}"
)
else:
event_data_str = f"{event_subtype}: {event_info}"
if not event_subtype:
event_subtype = "unknown"
event_data_str = str(record)
return {
"timestamp": record.get("timestamp"),
"module": self.__class__.__name__,
"event": event_subtype,
"data": event_data_str,
}
def process_event(self, event_data: dict) -> None:
"""Process a security event and add it to results."""
# Convert event_time to ISO format
# Security events use nanoseconds since epoch
event_time = event_data.get("event_time")
if event_time:
# Convert nanoseconds to seconds
event_data["timestamp"] = self._localize_timestamp(
event_time / 1_000_000_000.0
)
else:
event_data["timestamp"] = None
# Track event type statistics, including future tags unknown to MVT.
event_tag = self._get_event_tag(event_data)
if event_tag:
self.event_type_counts[event_tag] = (
self.event_type_counts.get(event_tag, 0) + 1
)
self.results.append(event_data)
def run(self) -> None:
"""Extract and analyze security events from intrusion logs."""
if not self.target_path:
self.log.error("No target path specified")
return
self.collect_txt(self.target_path)
self.parse_collected_txt("security_event")
self.log.info("Identified %d security events", len(self.results))
# Log event type breakdown
if self.event_type_counts:
self.log.info("Security event breakdown:")
for event_type, count in sorted(
self.event_type_counts.items(), key=lambda x: x[1], reverse=True
):
event_name = SECURITY_EVENT_TAGS.get(event_type, {}).get(
"name", event_type
)
self.log.info(" - %s: %d", event_name, count)
unknown_event_types = sorted(
event_type
for event_type in self.event_type_counts
if event_type not in SECURITY_EVENT_TAGS
)
if unknown_event_types:
self.log.warning(
"Found unknown intrusion logging security event type(s): %s. "
"Please open an issue on GitHub so MVT can add support for them.",
", ".join(unknown_event_types),
)
+36 -29
View File
@@ -29,9 +29,6 @@ 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:
@@ -51,13 +48,16 @@ def parse_ab_header(data):
'encryption': "none", 'version': 4}
"""
if data.startswith(b"ANDROID BACKUP"):
[_, version, is_compressed, encryption, _] = data.split(b"\n", 4)
return {
"backup": True,
"compression": (is_compressed == b"1"),
"version": int(version),
"encryption": encryption.decode("utf-8"),
}
try:
[_, version, is_compressed, encryption, _] = data.split(b"\n", 4)
return {
"backup": True,
"compression": (is_compressed == b"1"),
"version": int(version),
"encryption": encryption.decode("utf-8"),
}
except (UnicodeDecodeError, ValueError):
pass
return {"backup": False, "compression": None, "version": None, "encryption": None}
@@ -131,20 +131,23 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
if password is None:
raise InvalidBackupPassword()
[
user_salt,
checksum_salt,
pbkdf2_rounds,
user_iv,
master_key_blob,
encrypted_data,
] = encrypted_backup.split(b"\n", 5)
try:
[
user_salt,
checksum_salt,
pbkdf2_rounds,
user_iv,
master_key_blob,
encrypted_data,
] = encrypted_backup.split(b"\n", 5)
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
pbkdf2_rounds = int(pbkdf2_rounds)
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
pbkdf2_rounds = int(pbkdf2_rounds)
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
except (UnicodeDecodeError, ValueError) as exc:
raise AndroidBackupParsingError("Invalid encrypted backup header") from exc
# Derive decryption master key from password.
master_key, master_iv = decrypt_master_key(
@@ -157,13 +160,13 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
checksum_salt=checksum_salt,
)
# Decrypt and unpad backup data using derivied key.
# Decrypt and unpad backup data using derived 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)
return unpadder.update(decrypted_tar) + unpadder.finalize()
def parse_backup_file(data, password=None):
@@ -174,10 +177,12 @@ def parse_backup_file(data, password=None):
if not data.startswith(b"ANDROID BACKUP"):
raise AndroidBackupParsingError("Invalid file header")
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
version = int(version)
is_compressed = int(is_compressed)
try:
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
version = int(version)
is_compressed = int(is_compressed)
except ValueError as exc:
raise AndroidBackupParsingError("Invalid file header") from exc
if encryption_algo != b"none":
tar_data = decrypt_backup_data(
@@ -210,6 +215,8 @@ 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
+1 -1
View File
@@ -219,7 +219,7 @@ class AlertStore:
return alerts
def save_timeline(self, timeline_path: str) -> None:
with open(timeline_path, "a+", encoding="utf-8") as handle:
with open(timeline_path, "w", encoding="utf-8") as handle:
csvoutput = csv.writer(
handle,
delimiter=",",
+2 -1
View File
@@ -8,5 +8,6 @@ from .module import MVTModule
class Artifact(MVTModule):
"""Base class for artifacts.
XXX: Inheriting from MVTModule to have the same signature as other modules. Not sure if this is a good idea.
Artifacts share the MVTModule lifecycle so commands can run artifacts and
extraction modules through the same interface.
"""
+297
View File
@@ -0,0 +1,297 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import hashlib
import importlib.metadata
import importlib.util
import logging
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Iterable
import click
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
log = logging.getLogger(__name__)
class CustomCommandLoadError(Exception):
pass
class BrokenPluginCommand(click.Command):
"""A placeholder for an installed or configured command that failed to load."""
def __init__(self, name: str, source: str, exception: BaseException):
super().__init__(
name,
help=(
f"Unable to load external command from {source}.\n\n"
f"{type(exception).__name__}: {exception}"
),
short_help="Warning: external command could not be loaded.",
)
self.source = source
self.exception = exception
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
return args
def invoke(self, ctx: click.Context) -> None:
raise click.ClickException(
f"Unable to load external command '{self.name}' from {self.source}: "
f"{type(self.exception).__name__}: {self.exception}"
)
def _module_name_for_path(path: Path) -> str:
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
return f"_mvt_custom_command_{path.stem}_{digest}"
def _iter_command_files(path: Path) -> Iterable[Path]:
if path.is_file():
if path.suffix != ".py":
raise CustomCommandLoadError(
f"Custom command file is not a Python file: {path}"
)
yield path
return
if path.is_dir():
for child in sorted(path.iterdir()):
if child.name.startswith(".") or child.name == "__init__.py":
continue
if child.is_file() and child.suffix == ".py":
yield child
return
raise CustomCommandLoadError(f"Custom command path does not exist: {path}")
def _load_python_file(path: Path) -> ModuleType:
module_name = _module_name_for_path(path)
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise CustomCommandLoadError(f"Unable to load custom command file: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except (Exception, SystemExit) as exc:
raise CustomCommandLoadError(
f"Unable to import custom command {path}: {exc}"
) from exc
return module
def load_cli_command_file(path: Path) -> click.Command:
module = _load_python_file(path)
command = getattr(module, "cli", None)
if not isinstance(command, click.Command):
raise CustomCommandLoadError(
f"Custom command {path} must export a Click command or group named 'cli'"
)
if command.name is None:
raise CustomCommandLoadError(f"Custom command {path} has no command name")
return command
def _register_command(
group: click.Group,
command: click.Command,
*,
name: str,
source: str,
collision_is_error: bool,
) -> bool:
if name in group.commands:
registered_sources = getattr(group, "_mvt_external_command_sources", {})
if registered_sources.get(name) == source:
return False
message = (
f"Unable to register external command '{name}' from {source}: "
"the command name is already registered"
)
if collision_is_error:
raise CustomCommandLoadError(message)
log.warning(message)
return False
group.add_command(command, name=name)
registered_sources = getattr(group, "_mvt_external_command_sources", {})
registered_sources[name] = source
setattr(group, "_mvt_external_command_sources", registered_sources)
return True
def register_cli_commands_from_path(
group: click.Group,
path: str | Path,
*,
collision_is_error: bool = False,
failures_are_errors: bool = False,
) -> list[str]:
resolved_path = Path(path).expanduser().resolve()
registered: list[str] = []
try:
command_files = list(_iter_command_files(resolved_path))
except CustomCommandLoadError as exc:
if failures_are_errors:
raise
name = resolved_path.stem.replace("_", "-") or "custom-command"
if _register_command(
group,
BrokenPluginCommand(name, str(resolved_path), exc),
name=name,
source=str(resolved_path),
collision_is_error=collision_is_error,
):
registered.append(name)
return registered
for command_file in command_files:
try:
command = load_cli_command_file(command_file)
except CustomCommandLoadError as exc:
if failures_are_errors:
raise
name = command_file.stem.replace("_", "-")
command = BrokenPluginCommand(name, str(command_file), exc)
command_name = command.name
if command_name is None:
raise CustomCommandLoadError(
f"Custom command {command_file} has no command name"
)
if _register_command(
group,
command,
name=command_name,
source=str(command_file),
collision_is_error=collision_is_error,
):
registered.append(command_name)
return registered
def _entry_point_source(entry_point: importlib.metadata.EntryPoint) -> str:
try:
distribution = getattr(entry_point, "dist", None)
if distribution is None:
return f"entry point {entry_point.value}"
name = distribution.metadata.get("Name", "unknown distribution")
version = getattr(distribution, "version", None)
if version:
return f"{name} {version} ({entry_point.value})"
return f"{name} ({entry_point.value})"
except Exception:
return f"entry point {entry_point.value}"
def register_installed_cli_commands(
group: click.Group,
entry_point_group: str,
) -> list[str]:
try:
entry_points = importlib.metadata.entry_points(group=entry_point_group)
except Exception as exc:
log.warning(
"Unable to discover external commands in entry-point group %s: %s",
entry_point_group,
exc,
)
return []
ordered_entry_points = sorted(
entry_points,
key=lambda entry_point: (
entry_point.name,
_entry_point_source(entry_point),
entry_point.value,
),
)
registered: list[str] = []
for entry_point in ordered_entry_points:
source = _entry_point_source(entry_point)
try:
command = entry_point.load()
if not isinstance(command, click.Command):
raise TypeError(
f"entry point must resolve to a Click command or group, "
f"not {type(command).__name__}"
)
except (Exception, SystemExit) as exc:
command = BrokenPluginCommand(entry_point.name, source, exc)
if _register_command(
group,
command,
name=entry_point.name,
source=source,
collision_is_error=False,
):
registered.append(entry_point.name)
return registered
def register_cli_plugins(
group: click.Group,
*,
entry_point_group: str,
environment_variable: str,
) -> None:
environment_path = os.environ.get(environment_variable)
if environment_path:
register_cli_commands_from_path(group, environment_path)
register_installed_cli_commands(group, entry_point_group)
def _load_command_option_callback(
ctx: click.Context,
param: click.Parameter,
paths: tuple[str, ...],
) -> tuple[str, ...]:
if not isinstance(ctx.command, click.Group):
raise click.ClickException("--load-command requires a Click command group")
for path in paths:
try:
register_cli_commands_from_path(
ctx.command,
path,
collision_is_error=True,
failures_are_errors=True,
)
except CustomCommandLoadError as exc:
raise click.BadParameter(str(exc), ctx=ctx, param=param) from exc
return paths
load_cli_commands_option = click.option(
"--load-command",
"load_commands",
type=click.Path(exists=True),
multiple=True,
expose_value=False,
is_eager=True,
callback=_load_command_option_callback,
help=(
"Load a custom CLI command from a Python file or folder "
"(can be invoked multiple times)"
),
)
+6 -1
View File
@@ -8,6 +8,7 @@ import os
from typing import Optional
from mvt.common.command import Command
from mvt.common.module import MVTModule
from mvt.common.utils import exec_or_profile
log = logging.getLogger(__name__)
@@ -26,6 +27,8 @@ class CmdCheckIOCS(Command):
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
platform: str = "",
) -> None:
super().__init__(
target_path=target_path,
@@ -39,14 +42,16 @@ class CmdCheckIOCS(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = platform
self.name = "check-iocs"
def run(self) -> None:
assert self.target_path is not None
all_modules = []
for entry in self.modules:
for entry in self._available_modules():
if entry not in all_modules:
all_modules.append(entry)
+114 -13
View File
@@ -8,6 +8,7 @@ import logging
import os
import sys
from datetime import datetime
from heapq import heappop, heappush
from typing import Any, Optional
from rich.console import Console
@@ -18,6 +19,8 @@ from .alerts import AlertLevel, AlertStore
from .config import settings
from .indicators import Indicators
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
from .module_loader import module_supports_command
from .module_types import ModuleTimeline, URLResult
from .utils import (
CustomJSONEncoder,
convert_datetime_to_iso,
@@ -42,9 +45,12 @@ class Command:
log: logging.Logger = logging.getLogger(__name__),
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
self.name = ""
self.modules: list[Any] = []
self.platform = ""
self.modules: list[type[MVTModule]] = []
self.custom_modules = custom_modules if custom_modules else []
self.target_path = target_path
self.results_path = results_path
@@ -63,10 +69,11 @@ class Command:
# This list will contain all executed modules.
# We can use this to reference e.g. self.executed[0].results.
self.executed: list[Any] = []
self.executed: list[MVTModule] = []
self.hashes = hashes
self.hash_values: list[dict[str, Any]] = []
self.timeline: list[dict[str, Any]] = []
self.timeline: ModuleTimeline = []
self.url_results: list[URLResult] = []
# Load IOCs
self._create_storage()
@@ -144,6 +151,14 @@ class Command:
with open(alerts_path, "w+", encoding="utf-8") as handle:
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
def _store_urls(self) -> None:
if not self.results_path or not self.url_results:
return
urls_path = os.path.join(self.results_path, "urls.json")
with open(urls_path, "w", encoding="utf-8") as handle:
json.dump(self.url_results, handle, indent=4, cls=CustomJSONEncoder)
def _store_alerts_timeline(self) -> None:
if not self.results_path:
return
@@ -197,9 +212,24 @@ class Command:
def list_modules(self) -> None:
self.log.info("Following is the list of available %s modules:", self.name)
for module in self.modules:
for module in self._available_modules():
self.log.info(" - %s", module.__name__)
def _available_modules(self) -> list[type[MVTModule]]:
modules = list(self.modules)
modules.extend(
module
for module in self.custom_modules
if module_supports_command(module, self.platform, self.name)
)
deduplicated = []
for module in modules:
if module not in deduplicated:
deduplicated.append(module)
return deduplicated
def init(self) -> None:
raise NotImplementedError
@@ -258,22 +288,86 @@ class Command:
console.print("")
console.print(panel)
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
"""Return enabled modules in stable topological order."""
modules = self._available_modules()
module_indexes = {module: index for index, module in enumerate(modules)}
if self.module_name:
selected = [
module for module in modules if module.__name__ == self.module_name
]
else:
selected = [module for module in modules if module.enabled]
required = set(selected)
pending = list(selected)
while pending:
module = pending.pop()
for dependency in module.dependencies:
if dependency not in module_indexes:
self.log.warning(
"Module %s depends on unavailable module %s. "
"No modules will be run.",
module.__name__,
dependency.__name__,
)
return None
if dependency not in required:
required.add(dependency)
pending.append(dependency)
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
module: [] for module in required
}
indegree = {module: 0 for module in required}
for module in required:
for dependency in module.dependencies:
if dependency not in required:
continue
dependents[dependency].append(module)
indegree[module] += 1
ready: list[tuple[int, type[MVTModule]]] = []
for module, count in indegree.items():
if count == 0:
heappush(ready, (module_indexes[module], module))
ordered = []
while ready:
_, module = heappop(ready)
ordered.append(module)
for dependent in dependents[module]:
indegree[dependent] -= 1
if indegree[dependent] == 0:
heappush(ready, (module_indexes[dependent], dependent))
if len(ordered) != len(required):
cyclic_modules = sorted(
(module.__name__ for module, count in indegree.items() if count > 0)
)
self.log.warning(
"Circular module dependency detected involving: %s. "
"No modules will be run.",
", ".join(cyclic_modules),
)
return None
return ordered
def run(self) -> None:
ordered_modules = self._ordered_modules()
if ordered_modules is None:
return
try:
self.init()
except NotImplementedError:
pass
for module in self.modules:
if self.module_name and module.__name__ != self.module_name:
continue
executed_by_type: dict[type[MVTModule], MVTModule] = {}
for module in ordered_modules:
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(
@@ -282,6 +376,10 @@ 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
@@ -305,7 +403,9 @@ class Command:
return
self.executed.append(m)
executed_by_type[module] = m
self.timeline.extend(m.timeline)
self.url_results.extend(m.url_results)
self.alertstore.extend(m.alertstore.alerts)
try:
@@ -320,4 +420,5 @@ class Command:
self._store_timeline()
self._store_alerts_timeline()
self._store_alerts()
self._store_urls()
self._store_info()
+94
View File
@@ -0,0 +1,94 @@
# 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 pathlib import Path
import shlex
import click
from click.shell_completion import get_completion_class
SUPPORTED_SHELLS = ("bash", "zsh", "fish")
def completion_instructions(program_name: str) -> str:
return f"""Shell completion for {program_name}
Print a completion script:
{program_name} completion bash > ~/.{program_name}-complete.bash
{program_name} completion zsh > ~/.{program_name}-complete.zsh
mkdir -p ~/.config/fish/completions
{program_name} completion fish > ~/.config/fish/completions/{program_name}.fish
Load the generated Bash script from ~/.bashrc:
[ -f ~/.{program_name}-complete.bash ] && . ~/.{program_name}-complete.bash
Load the generated Zsh script from ~/.zshrc:
[ -f ~/.{program_name}-complete.zsh ] && . ~/.{program_name}-complete.zsh
Fish loads completion files from ~/.config/fish/completions automatically.
To write these files and update Bash/Zsh shell configuration automatically:
{program_name} completion bash --install
{program_name} completion zsh --install
{program_name} completion fish --install
"""
def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str:
completion_class = get_completion_class(shell)
if completion_class is None:
raise click.ClickException(f"Unsupported shell: {shell}")
complete_var = f"_{program_name.upper().replace('-', '_')}_COMPLETE"
return completion_class(cli, {}, program_name, complete_var).source()
def install_completion_script(
cli: click.Command,
program_name: str,
shell: str,
) -> Path:
script = generate_completion_script(cli, program_name, shell)
script_path = _completion_script_path(program_name, shell)
script_path.parent.mkdir(parents=True, exist_ok=True)
script_path.write_text(script, encoding="utf-8")
if shell in ("bash", "zsh"):
_install_shell_source_line(program_name, shell, script_path)
return script_path
def _completion_script_path(program_name: str, shell: str) -> Path:
home = Path.home()
if shell == "fish":
return home / ".config" / "fish" / "completions" / f"{program_name}.fish"
return home / f".{program_name}-complete.{shell}"
def _install_shell_source_line(program_name: str, shell: str, script_path: Path) -> None:
shell_config_path = Path.home() / f".{shell}rc"
source_line = (
f"[ -f {shlex.quote(str(script_path))} ] && "
f". {shlex.quote(str(script_path))}"
)
block = (
f"# MVT shell completion for {program_name}\n"
f"{source_line}\n"
)
if shell_config_path.exists():
shell_config = shell_config_path.read_text(encoding="utf-8")
if source_line in shell_config:
return
else:
shell_config = ""
separator = "" if not shell_config or shell_config.endswith("\n") else "\n"
with shell_config_path.open("a", encoding="utf-8") as handle:
handle.write(f"{separator}{block}")
+9
View File
@@ -10,6 +10,10 @@ HELP_MSG_IOC = "Path to indicators file (can be invoked multiple time)"
HELP_MSG_FAST = "Avoid running time/resource consuming features"
HELP_MSG_LIST_MODULES = "Print list of available modules and exit"
HELP_MSG_MODULE = "Name of a single module you would like to run instead of all"
HELP_MSG_LOAD_MODULE = (
"Load custom MVT module(s) from a Python file or folder "
"(can be invoked multiple times)"
)
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
HELP_MSG_VERBOSE = "Verbose mode"
@@ -17,6 +21,7 @@ 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"
HELP_MSG_COMPLETION = "Generate or install shell completion"
# IOS Specific
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
@@ -33,6 +38,7 @@ HELP_MSG_BACKUP_KEYFILE = (
HELP_MSG_EXTRACT_KEY = "Extract decryption key from an iTunes backup"
HELP_MSG_CHECK_IOS_BACKUP = "Extract artifacts from an iTunes backup"
HELP_MSG_CHECK_FS = "Extract artifacts from a full filesystem dump"
HELP_MSG_CHECK_SYSDIAGNOSE = "Extract artifacts from an iOS sysdiagnose archive"
# Android Specific
HELP_MSG_ANDROID_BACKUP_PASSWORD = "The backup password to use for an Android backup"
@@ -47,3 +53,6 @@ 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"
HELP_MSG_VIRUS_TOTAL = "Check package APK hashes on VirusTotal"
HELP_MSG_DELAY_CHECKS = "Delay in seconds between VirusTotal requests"
+52 -30
View File
@@ -7,9 +7,10 @@ import glob
import json
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, Iterator, List, Optional
from typing import Any, Dict, Iterator, List, Optional, Sequence
import ahocorasick
from appdirs import user_data_dir
@@ -22,6 +23,8 @@ MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
logger = logging.getLogger(__name__)
URL_CHECK_MAX_WORKERS = 20
@dataclass
class Indicator:
@@ -46,6 +49,7 @@ class Indicators:
self.log = log
self.ioc_collections: List[Dict[str, Any]] = []
self.total_ioc_count = 0
self.resolved_urls: Dict[str, str] = {}
def _load_downloaded_indicators(self) -> None:
if not os.path.isdir(MVT_INDICATORS_FOLDER):
@@ -436,9 +440,14 @@ class Indicators:
orig_url.url,
dest_url.url,
)
return self.check_url(dest_url.url)
match = self.check_url(dest_url.url)
self.resolved_urls[url] = self.resolved_urls.get(
dest_url.url, dest_url.url
)
return match
final_url = dest_url
self.resolved_urls[url] = final_url.url
else:
# If it's not shortened, we just use the original URL object.
final_url = orig_url
@@ -479,6 +488,13 @@ class Indicators:
return None
def get_expanded_url(self, url: str) -> Optional[str]:
"""Return the final URL recorded while checking a shortened URL."""
expanded_url = self.resolved_urls.get(url)
if expanded_url and expanded_url != url:
return expanded_url
return None
def check_urls(self, urls: list) -> Optional[IndicatorMatch]:
"""Check a list of URLs against the provided list of domain indicators.
@@ -490,12 +506,41 @@ class Indicators:
if not urls:
return None
for url in urls:
check = self.check_url(url)
if check:
return check
return self.check_url_batches([urls])[0]
return None
def check_url_batches(
self, url_batches: Sequence[Optional[Sequence[str]]]
) -> List[Optional[IndicatorMatch]]:
"""Check batches of URLs concurrently while preserving batch order.
URLs are deduplicated across batches before checking. Each returned item
is the first indicator match from the corresponding input batch, using
the original URL order.
"""
batches = [list(urls) if urls else [] for urls in url_batches]
unique_urls = list(
dict.fromkeys(url for urls in batches for url in urls)
)
if not unique_urls:
return [None] * len(batches)
if settings.NETWORK_ACCESS_ALLOWED and len(unique_urls) > 1:
worker_count = min(URL_CHECK_MAX_WORKERS, len(unique_urls))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
url_matches = dict(
zip(unique_urls, executor.map(self.check_url, unique_urls))
)
else:
url_matches = {url: self.check_url(url) for url in unique_urls}
return [
next(
(url_matches[url] for url in urls if url_matches[url] is not None),
None,
)
for urls in batches
]
def check_process(self, process: str) -> Optional[IndicatorMatch]:
"""Check the provided process name against the list of process
@@ -727,29 +772,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]:
+47 -8
View File
@@ -9,7 +9,7 @@ import logging
import os
import re
from dataclasses import asdict, is_dataclass
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Sequence
from .alerts import AlertStore
from .indicators import Indicators
@@ -18,6 +18,7 @@ from .module_types import (
ModuleResults,
ModuleSerializedResult,
ModuleTimeline,
URLResult,
)
from .utils import CustomJSONEncoder, exec_or_profile
@@ -43,6 +44,8 @@ class MVTModule:
enabled: bool = True
slug: Optional[str] = None
dependencies: Sequence[type["MVTModule"]] = ()
supported_commands: Sequence[tuple[str, str]] = ()
def __init__(
self,
@@ -51,7 +54,7 @@ class MVTModule:
results_path: Optional[str] = None,
module_options: Optional[Dict[str, Any]] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
"""Initialize module.
@@ -71,16 +74,23 @@ class MVTModule:
self.file_path: Optional[str] = file_path
self.target_path: Optional[str] = target_path
self.results_path: Optional[str] = results_path
self.module_options: Optional[Dict[str, Any]] = (
module_options if module_options else {}
)
self.serial: Optional[str] = None
self.module_options: Dict[str, Any] = module_options if module_options else {}
self.log = log
self.indicators: Optional[Indicators] = None
self.alertstore: AlertStore = AlertStore(log=log)
self.results: ModuleResults = results if results else []
self.results: ModuleResults = results if results is not None else []
self.timeline: ModuleTimeline = []
self.url_results: list[URLResult] = []
self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {}
def get_dependency_results(
self, module_class: type["MVTModule"]
) -> ModuleResults:
"""Return the results produced by a prerequisite module."""
return self.dependency_modules[module_class].results
@classmethod
def from_json(cls, json_path: str, log: logging.Logger):
@@ -102,6 +112,23 @@ class MVTModule:
def check_indicators(self) -> None:
raise NotImplementedError
def collect_url_results(self) -> None:
"""Collect URL records exposed by this module."""
def add_url_result(self, url: str, timestamp: Optional[str], source: str) -> None:
expanded_url = None
if self.indicators:
expanded_url = self.indicators.get_expanded_url(url)
self.url_results.append(
{
"url": url,
"expanded_url": expanded_url,
"timestamp": timestamp,
"source": source,
}
)
def save_to_json(self) -> None:
if not self.results_path:
return
@@ -109,11 +136,14 @@ class MVTModule:
name = self.get_slug()
if self.results:
converted_results: Any
if isinstance(self.results, dict):
converted_results = self.results
else:
converted_results = [
asdict(result) if is_dataclass(result) else result
asdict(result)
if is_dataclass(result) and not isinstance(result, type)
else result
for result in self.results
]
results_file_name = f"{name}.json"
@@ -238,6 +268,15 @@ def run_module(module: MVTModule) -> None:
"The %s module produced no detections!", module.__class__.__name__
)
try:
module.collect_url_results()
except Exception as exc:
module.log.exception(
"Error when collecting URLs from module %s: %s",
module.__class__.__name__,
exc,
)
try:
module.to_timeline()
except NotImplementedError:
@@ -259,7 +298,7 @@ def save_timeline(timeline: list, timeline_path: str, is_utc: bool = True) -> No
:param timeline_path: Path to the csv file to store the timeline to
"""
with open(timeline_path, "a+", encoding="utf-8") as handle:
with open(timeline_path, "w", encoding="utf-8") as handle:
csvoutput = csv.writer(
handle, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL, escapechar="\\"
)
+134
View File
@@ -0,0 +1,134 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import hashlib
import importlib.util
import inspect
import logging
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Iterable, Optional
from .module import MVTModule
MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES"
log = logging.getLogger(__name__)
class CustomModuleLoadError(Exception):
pass
def _module_name_for_path(path: Path) -> str:
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
return f"_mvt_custom_module_{path.stem}_{digest}"
def _iter_module_files(path: Path) -> Iterable[Path]:
if path.is_file():
if path.suffix != ".py":
raise CustomModuleLoadError(f"Custom module file is not a Python file: {path}")
yield path
return
if path.is_dir():
for child in sorted(path.iterdir()):
if child.name.startswith("."):
continue
if child.name == "__init__.py":
continue
if child.is_file() and child.suffix == ".py":
yield child
return
raise CustomModuleLoadError(f"Custom module path does not exist: {path}")
def _load_python_file(path: Path) -> ModuleType:
module_name = _module_name_for_path(path)
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise CustomModuleLoadError(f"Unable to load custom module file: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception as exc:
raise CustomModuleLoadError(f"Unable to import custom module {path}: {exc}") from exc
return module
def discover_mvt_modules(module: ModuleType) -> list[type[MVTModule]]:
modules = []
for _, obj in inspect.getmembers(module, inspect.isclass):
if obj is MVTModule:
continue
if obj.__module__ != module.__name__:
continue
if not issubclass(obj, MVTModule):
continue
modules.append(obj)
return modules
def load_custom_modules_from_path(path: str) -> list[type[MVTModule]]:
custom_modules: list[type[MVTModule]] = []
seen: set[tuple[str, str]] = set()
resolved_path = Path(path).expanduser().resolve()
for module_file in _iter_module_files(resolved_path):
loaded_module = _load_python_file(module_file)
for module_class in discover_mvt_modules(loaded_module):
key = (str(module_file), module_class.__qualname__)
if key in seen:
continue
seen.add(key)
custom_modules.append(module_class)
return custom_modules
def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTModule]]:
search_paths: list[str] = []
env_path = os.environ.get(MVT_CUSTOM_MODULES_ENV)
if env_path:
search_paths.append(env_path)
if paths:
search_paths.extend(paths)
custom_modules: list[type[MVTModule]] = []
seen: set[tuple[str, str]] = set()
for path in search_paths:
for module_class in load_custom_modules_from_path(path):
source = Path(inspect.getfile(module_class)).resolve()
key = (str(source), module_class.__qualname__)
if key in seen:
continue
seen.add(key)
custom_modules.append(module_class)
return custom_modules
def module_supports_command(
module_class: type[MVTModule],
platform: str,
command: str,
) -> bool:
supported_commands = getattr(module_class, "supported_commands", None)
if not supported_commands:
log.warning(
"Custom module %s has no supported_commands and will not be run. "
"Declare the platform/command pairs it supports.",
module_class.__name__,
)
return False
return (platform, command) in {tuple(entry) for entry in supported_commands}
+12 -2
View File
@@ -4,7 +4,7 @@
# https://license.mvt.re/1.1/
from dataclasses import dataclass
from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Optional, TypedDict, Union
# ModuleAtomicResult is a flexible dictionary that can contain any data.
@@ -16,7 +16,17 @@ from typing import Any, Dict, List, Union
ModuleAtomicResult = Dict[str, Any]
ModuleResults = List[ModuleAtomicResult]
# Extraction modules historically use either a list of records or grouped
# dictionaries keyed by source path. Keep this alias broad until those shapes
# are modeled per module.
ModuleResults = Any
class URLResult(TypedDict):
url: str
expanded_url: Optional[str]
timestamp: Optional[str]
source: str
@dataclass
+143
View File
@@ -0,0 +1,143 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Password prompts with masked keyboard feedback.
This is based on the ``echo_char`` support added to :mod:`getpass` in Python
3.14. MVT supports Python 3.10 and later, so it cannot use that API directly.
"""
import contextlib
import io
import os
import sys
import warnings
from typing import TextIO
def prompt_password(prompt: str) -> str:
"""Return a password while displaying an asterisk for each character."""
try:
import termios
return _unix_getpass(prompt, termios)
except ImportError:
try:
import msvcrt
return _windows_getpass(prompt, msvcrt)
except ImportError:
return _fallback_getpass(prompt)
def _unix_getpass(prompt: str, termios) -> str:
password = None
input_stream: TextIO
output_stream: TextIO
with contextlib.ExitStack() as stack:
try:
fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
tty = io.FileIO(fd, "w+")
stack.enter_context(tty)
input_stream = io.TextIOWrapper(tty)
stack.enter_context(input_stream)
output_stream = input_stream
except OSError:
stack.close()
try:
fd = sys.stdin.fileno()
except (AttributeError, ValueError):
return _fallback_getpass(prompt)
input_stream = sys.stdin
output_stream = sys.stderr
try:
old = termios.tcgetattr(fd)
new = old[:]
new[3] &= ~termios.ECHO
new[3] &= ~termios.ICANON
try:
termios.tcsetattr(fd, termios.TCSAFLUSH, new)
password = _readline_with_asterisks(output_stream, input_stream, prompt)
finally:
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
output_stream.flush()
except termios.error:
if password is not None:
raise
password = _fallback_getpass(prompt)
output_stream.write("\n")
return password
def _windows_getpass(prompt: str, msvcrt) -> str:
if sys.stdin is not sys.__stdin__:
return _fallback_getpass(prompt)
for char in prompt:
msvcrt.putwch(char)
password = ""
while True:
char = msvcrt.getwch()
if char in ("\r", "\n"):
break
if char == "\x03":
raise KeyboardInterrupt
if char == "\b":
if password:
msvcrt.putwch("\b")
msvcrt.putwch(" ")
msvcrt.putwch("\b")
password = password[:-1]
else:
password += char
msvcrt.putwch("*")
msvcrt.putwch("\r")
msvcrt.putwch("\n")
return password
def _fallback_getpass(prompt: str) -> str:
warnings.warn(
"Can not control echo on the terminal.",
category=UserWarning,
stacklevel=2,
)
print("Warning: Password input may be echoed.", file=sys.stderr)
return input(prompt)
def _readline_with_asterisks(
output_stream: TextIO, input_stream: TextIO, prompt: str
) -> str:
output_stream.write(prompt)
output_stream.flush()
password = ""
eof_pressed = False
while True:
char = input_stream.read(1)
if char in ("\n", "\r"):
break
if char == "\x03":
raise KeyboardInterrupt
if char in ("\x7f", "\b"):
if password:
output_stream.write("\b \b")
output_stream.flush()
password = password[:-1]
elif char == "\x04":
if eof_pressed:
break
eof_pressed = True
elif char != "\x00":
password += char
output_stream.write("*")
output_stream.flush()
eof_pressed = False
return password
+1 -3
View File
@@ -180,10 +180,8 @@ class IndicatorsUpdates:
def _get_remote_file_latest_commit(
self, owner: str, repo: str, branch: str, path: str
) -> int:
# TODO: The branch is currently not taken into consideration.
# How do we specify which branch to look up to the API?
file_commit_url = (
f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}"
f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}"
)
try:
res = requests.get(file_commit_url, timeout=5)
+20 -1
View File
@@ -3,11 +3,17 @@
# 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 urllib.parse import urlparse
import requests
from tld import get_tld
from .config import settings
log = logging.getLogger(__name__)
SHORTENER_DOMAINS = [
"0rz.tw",
"1drv.ms",
@@ -368,6 +374,10 @@ class URL:
:rtype: bool
"""
parsed_url = urlparse(self.url if "://" in self.url else f"//{self.url}")
if self.domain.lower() == "goo.gl" and parsed_url.path.startswith("/maps/"):
return False
if self.domain.lower() in SHORTENER_DOMAINS:
self.is_shortened = True
@@ -375,7 +385,16 @@ class URL:
def unshorten(self) -> Optional[str]:
"""Unshorten the URL by requesting an HTTP HEAD response."""
res = requests.head(self.url)
if settings.NETWORK_ACCESS_ALLOWED is False:
log.info(
"Network access disabled (MVT_NETWORK_ACCESS_ALLOWED=False), "
"skipping unshorten for %s",
self.url,
)
return ""
res = requests.head(self.url, timeout=settings.NETWORK_TIMEOUT)
if str(res.status_code).startswith("30"):
return res.headers["Location"]
+1 -2
View File
@@ -123,10 +123,9 @@ def convert_mactime_to_datetime(timestamp: Union[int, float], from_2001: bool =
if from_2001:
timestamp = timestamp + 978307200
# TODO: This is rather ugly. Happens sometimes with invalid timestamps.
try:
return convert_unix_to_utc_datetime(timestamp)
except Exception:
except (OSError, OverflowError, ValueError):
return None
+1 -1
View File
@@ -3,4 +3,4 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
MVT_VERSION = "2026.4.28"
MVT_VERSION = "2026.7.29"
+53
View File
@@ -0,0 +1,53 @@
# 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 Any, Optional
import requests
from .config import settings
log = logging.getLogger(__name__)
class VTNoKey(Exception):
pass
class VTQuotaExceeded(Exception):
pass
def virustotal_lookup(file_hash: str) -> Optional[dict[str, Any]]:
if not settings.VT_API_KEY:
raise VTNoKey(
"No VirusTotal API key provided: to use VirusTotal lookups please set "
"MVT_VT_API_KEY or VT_API_KEY in the MVT configuration file"
)
headers = {
"User-Agent": "VirusTotal",
"Content-Type": "application/json",
"x-apikey": settings.VT_API_KEY,
}
res = requests.get(
f"https://www.virustotal.com/api/v3/files/{file_hash}",
headers=headers,
timeout=settings.NETWORK_TIMEOUT,
)
if res.status_code == 200:
report = res.json()
return report["data"]
if res.status_code == 404:
log.info("Could not find results for file with hash %s", file_hash)
elif res.status_code == 429:
raise VTQuotaExceeded("You have exceeded the quota for your VirusTotal API key")
else:
raise RuntimeError(f"Unexpected response from VirusTotal: {res.status_code}")
return None
+200 -12
View File
@@ -8,9 +8,19 @@ import logging
import os
import click
from rich.prompt import Prompt
from mvt.common.cli_plugins import (
IOS_CLI_PLUGIN_GROUP,
MVT_IOS_CUSTOM_COMMANDS_ENV,
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.logo import logo
from mvt.common.options import MutuallyExclusiveOption
from mvt.common.updates import IndicatorsUpdates
@@ -31,17 +41,23 @@ from mvt.common.help import (
HELP_MSG_OUTPUT,
HELP_MSG_FAST,
HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE,
HELP_MSG_VERBOSE,
HELP_MSG_CHECK_FS,
HELP_MSG_CHECK_IOCS,
HELP_MSG_STIX2,
HELP_MSG_CHECK_IOS_BACKUP,
HELP_MSG_CHECK_SYSDIAGNOSE,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_COMPLETION,
)
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.password import prompt_password
from .cmd_check_backup import CmdIOSCheckBackup
from .cmd_check_fs import CmdIOSCheckFS
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
from .decrypt import DecryptBackup
from .modules.backup import BACKUP_MODULES
from .modules.fs import FS_MODULES
@@ -65,10 +81,18 @@ def _get_disable_flags(ctx):
)
def _load_custom_modules(load_module):
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
raise click.ClickException(str(exc)) from exc
# ==============================================================================
# Main
# ==============================================================================
@click.group(invoke_without_command=False)
@load_cli_commands_option
@click.option(
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
)
@@ -82,10 +106,11 @@ 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,
)
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ==============================================================================
@@ -96,6 +121,40 @@ def version():
return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-ios"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ==============================================================================
# Command: decrypt-backup
# ==============================================================================
@@ -150,7 +209,7 @@ def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD)
backup.decrypt_with_password(os.environ[MVT_IOS_BACKUP_PASSWORD])
else:
sekrit = Prompt.ask("Enter backup password", password=True)
sekrit = prompt_password("Enter backup password: ")
backup.decrypt_with_password(sekrit)
if not backup.can_process():
@@ -202,7 +261,7 @@ def extract_key(password, key_file, backup_path):
log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD)
password = os.environ[MVT_IOS_BACKUP_PASSWORD]
else:
password = Prompt.ask("Enter backup password", password=True)
password = prompt_password("Enter backup password: ")
backup.decrypt_with_password(password)
backup.get_key()
@@ -229,15 +288,32 @@ def extract_key(password, key_file, backup_path):
@click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
ctx, iocs, output, fast, list_modules, module, hashes, verbose, backup_path
ctx,
iocs,
output,
fast,
list_modules,
module,
load_module,
hashes,
verbose,
backup_path,
):
set_verbose_logging(verbose)
module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckBackup(
target_path=backup_path,
@@ -248,13 +324,17 @@ def check_backup(
hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking iTunes backup located at: %s", backup_path)
if not cmd.resolve_backup_path():
ctx.exit(1)
log.info("Checking iTunes backup located at: %s", cmd.target_path)
cmd.run()
cmd.show_alerts_brief()
@@ -277,13 +357,32 @@ def check_backup(
@click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("DUMP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dump_path):
def check_fs(
ctx,
iocs,
output,
fast,
list_modules,
module,
load_module,
hashes,
verbose,
dump_path,
):
set_verbose_logging(verbose)
module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckFS(
target_path=dump_path,
@@ -294,6 +393,7 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if list_modules:
@@ -307,6 +407,77 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
cmd.show_support_message()
# ==============================================================================
# Command: check-sysdiagnose
# ==============================================================================
@cli.command(
"check-sysdiagnose",
context_settings=CONTEXT_SETTINGS,
help=HELP_MSG_CHECK_SYSDIAGNOSE,
)
@click.option(
"--iocs",
"-i",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_IOC,
)
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
@click.pass_context
def check_sysdiagnose(
ctx,
iocs,
output,
list_modules,
module,
load_module,
hashes,
verbose,
sysdiagnose_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckSysdiagnose(
target_path=sysdiagnose_path,
results_path=output,
ioc_files=iocs,
module_name=module,
hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if not cmd._available_modules():
raise click.ClickException(
"No custom modules support mvt-ios check-sysdiagnose. "
"Load a module that declares supported_commands = "
"((\"ios\", \"check-sysdiagnose\"),)."
)
if list_modules:
cmd.list_modules()
return
log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path)
cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message()
# ==============================================================================
# Command: check-iocs
# ==============================================================================
@@ -321,15 +492,25 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context
def check_iocs(ctx, iocs, list_modules, module, folder):
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
custom_modules = _load_custom_modules(load_module)
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],
custom_modules=custom_modules,
platform="ios",
)
cmd.modules = BACKUP_MODULES + FS_MODULES + MIXED_MODULES
@@ -349,3 +530,10 @@ def check_iocs(ctx, iocs, list_modules, module, folder):
def download_iocs():
ioc_updates = IndicatorsUpdates()
ioc_updates.update()
register_cli_plugins(
cli,
entry_point_group=IOS_CLI_PLUGIN_GROUP,
environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV,
)
+52
View File
@@ -4,10 +4,12 @@
# https://license.mvt.re/1.1/
import logging
import os
from typing import Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.backup import BACKUP_MODULES
from .modules.mixed import MIXED_MODULES
@@ -15,6 +17,12 @@ from .modules.mixed import MIXED_MODULES
log = logging.getLogger(__name__)
def is_ios_backup_folder(path: str) -> bool:
return os.path.isfile(os.path.join(path, "Manifest.db")) and os.path.isfile(
os.path.join(path, "Info.plist")
)
class CmdIOSCheckBackup(Command):
def __init__(
self,
@@ -29,6 +37,7 @@ class CmdIOSCheckBackup(Command):
sub_command: bool = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -43,10 +52,53 @@ class CmdIOSCheckBackup(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "ios"
self.name = "check-backup"
self.modules = BACKUP_MODULES + MIXED_MODULES
def resolve_backup_path(self) -> bool:
target_path = getattr(self, "target_path", None)
if not isinstance(target_path, str) or not target_path:
return False
if is_ios_backup_folder(target_path):
return True
if not os.path.isdir(target_path):
self.log.critical(
"%s does not appear to be an iTunes backup folder. "
"Expected Manifest.db and Info.plist.",
target_path,
)
return False
candidates = []
for entry_name in sorted(os.listdir(target_path)):
entry_path = os.path.join(target_path, entry_name)
if os.path.isdir(entry_path) and is_ios_backup_folder(entry_path):
candidates.append(entry_path)
if len(candidates) == 1:
self.log.info("Found iTunes backup in subfolder: %s", candidates[0])
self.target_path = candidates[0]
return True
if candidates:
self.log.critical(
"Found multiple iTunes backups in %s. Please specify one backup folder.",
target_path,
)
return False
self.log.critical(
"%s does not appear to be an iTunes backup folder. "
"Expected Manifest.db and Info.plist.",
target_path,
)
return False
def module_init(self, module):
module.is_backup = True
+4
View File
@@ -8,6 +8,7 @@ from typing import Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.fs import FS_MODULES
from .modules.mixed import MIXED_MODULES
@@ -29,6 +30,7 @@ class CmdIOSCheckFS(Command):
sub_command: bool = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
@@ -42,8 +44,10 @@ class CmdIOSCheckFS(Command):
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "ios"
self.name = "check-fs"
self.modules = FS_MODULES + MIXED_MODULES
+119
View File
@@ -0,0 +1,119 @@
# 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
import os
import tarfile
from pathlib import Path
from typing import Any, Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
log = logging.getLogger(__name__)
class CmdIOSCheckSysdiagnose(Command):
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: bool = False,
sub_command: bool = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
results_path=results_path,
ioc_files=ioc_files,
iocs=iocs,
module_name=module_name,
serial=serial,
module_options=module_options,
hashes=hashes,
sub_command=sub_command,
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "ios"
self.name = "check-sysdiagnose"
self.sysdiagnose_format: Optional[str] = None
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
self.sysdiagnose_files: list[str] = []
self.ips_files: list[dict[str, Any]] = []
@staticmethod
def _parse_bugtype_header(data: bytes) -> Optional[int]:
try:
header = json.loads(data.split(b"\n", 1)[0].decode("utf-8"))
return int(header["bug_type"])
except (json.JSONDecodeError, KeyError, UnicodeDecodeError, ValueError):
return None
def _add_ips_file(self, file_path: str, data: bytes) -> None:
bug_type = self._parse_bugtype_header(data)
if bug_type is not None:
self.ips_files.append({"file_path": file_path, "bug_type": bug_type})
def init(self) -> None:
if not self.target_path:
raise ValueError("A sysdiagnose path is required")
if os.path.isdir(self.target_path):
self.sysdiagnose_format = "dir"
parent_path = Path(self.target_path).absolute().parent
for root, _, filenames in os.walk(self.target_path):
for filename in filenames:
absolute_path = os.path.join(root, filename)
file_path = os.path.relpath(absolute_path, parent_path)
self.sysdiagnose_files.append(file_path)
if filename.endswith(".ips"):
with open(absolute_path, "rb") as handle:
self._add_ips_file(absolute_path, handle.read())
return
if not os.path.isfile(self.target_path):
raise ValueError(f"Sysdiagnose path does not exist: {self.target_path}")
self.log.info("Parsing sysdiagnose archive. This might take a while...")
self.sysdiagnose_format = "tar"
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
for member in self.sysdiagnose_archive:
self.sysdiagnose_files.append(member.name)
if member.isfile() and member.name.endswith(".ips"):
archive_handle = self.sysdiagnose_archive.extractfile(member)
if archive_handle is not None:
with archive_handle:
self._add_ips_file(member.name, archive_handle.read())
def module_init(self, module) -> None:
module.ips_files = self.ips_files
if self.sysdiagnose_format == "tar":
if self.sysdiagnose_archive is None:
raise RuntimeError("Sysdiagnose archive has not been initialized")
module.from_sysdiagnose_tar(
self.sysdiagnose_archive, self.sysdiagnose_files
)
return
if self.sysdiagnose_format == "dir" and self.target_path:
module.from_sysdiagnose_folder(self.target_path, self.sysdiagnose_files)
return
raise RuntimeError("Sysdiagnose input has not been initialized")
def finish(self) -> None:
if self.sysdiagnose_archive is not None:
self.sysdiagnose_archive.close()
self.sysdiagnose_archive = None
+16
View File
@@ -911,6 +911,10 @@
"version": "15.8.7",
"build": "19H411"
},
{
"version": "15.8.8",
"build": "19H422"
},
{
"build": "20A362",
"version": "16.0"
@@ -1028,6 +1032,10 @@
"version": "16.7.15",
"build": "20H380"
},
{
"version": "16.7.16",
"build": "20H392"
},
{
"version": "17.0",
"build": "21A327"
@@ -1204,6 +1212,10 @@
"version": "18.7.8",
"build": "22H352"
},
{
"version": "18.7.9",
"build": "22H355"
},
{
"version": "26",
"build": "23A341"
@@ -1239,5 +1251,9 @@
{
"version": "26.4.2",
"build": "23E261"
},
{
"version": "26.5",
"build": "23F77"
}
]
+7
View File
@@ -11,6 +11,7 @@ import os
import os.path
import shutil
import sqlite3
from pathlib import Path
from typing import Optional
from iOSbackup import iOSbackup
@@ -96,6 +97,9 @@ class DecryptBackup:
# This may be a partial backup. Skip files from the manifest
# which do not exist locally.
source_file_path = os.path.join(self.backup_path, file_id[0:2], file_id)
if not Path(source_file_path).resolve().is_relative_to(Path(self.backup_path).resolve()):
log.warning("Skipping unsafe file_id: %r", file_id)
continue
if not os.path.exists(source_file_path):
log.debug(
"Skipping file %s. File not found in encrypted backup directory.",
@@ -104,6 +108,9 @@ class DecryptBackup:
continue
item_folder = os.path.join(self.dest_path, file_id[0:2]) # type: ignore[arg-type]
if not Path(os.path.join(item_folder, file_id)).resolve().is_relative_to(Path(self.dest_path).resolve()):
log.warning("Skipping unsafe file_id: %r", file_id)
continue
if not os.path.exists(item_folder):
os.makedirs(item_folder)
+2 -2
View File
@@ -25,7 +25,7 @@ class BackupInfo(IOSExtraction):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -36,7 +36,7 @@ class BackupInfo(IOSExtraction):
results=results,
)
self.results: dict = {}
self.results: dict = results if results is not None else {}
def run(self) -> None:
if not self.target_path:
@@ -33,7 +33,7 @@ class ConfigurationProfiles(IOSExtraction):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -85,6 +85,35 @@ class ConfigurationProfiles(IOSExtraction):
self.alertstore.medium(warning_message, "", result)
continue
@staticmethod
def _b64encode_key(d: dict, key: str) -> None:
if key in d:
d[key] = b64encode(d[key])
@staticmethod
def _b64encode_keys(d: dict, keys: list) -> None:
for key in keys:
if key in d:
d[key] = b64encode(d[key])
def _b64encode_plist_bytes(self, plist: dict) -> None:
"""Encode binary plist values to base64 for JSON serialization."""
if "SignerCerts" in plist:
plist["SignerCerts"] = [b64encode(x) for x in plist["SignerCerts"]]
self._b64encode_keys(plist, ["PushTokenDataSentToServerKey", "LastPushTokenHash"])
if "OTAProfileStub" in plist:
stub = plist["OTAProfileStub"]
if "SignerCerts" in stub:
stub["SignerCerts"] = [b64encode(x) for x in stub["SignerCerts"]]
if "PayloadContent" in stub:
self._b64encode_key(stub["PayloadContent"], "EnrollmentIdentityPersistentID")
if "PayloadContent" in plist:
for entry in plist["PayloadContent"]:
self._b64encode_keys(entry, ["PERSISTENT_REF", "IdentityPersistentRef"])
def run(self) -> None:
for conf_file in self._get_backup_files_from_manifest(
domain=CONF_PROFILES_DOMAIN
@@ -113,65 +142,7 @@ class ConfigurationProfiles(IOSExtraction):
except Exception:
conf_plist = {}
# TODO: Tidy up the following code hell.
if "SignerCerts" in conf_plist:
conf_plist["SignerCerts"] = [
b64encode(x) for x in conf_plist["SignerCerts"]
]
if "OTAProfileStub" in conf_plist:
if "SignerCerts" in conf_plist["OTAProfileStub"]:
conf_plist["OTAProfileStub"]["SignerCerts"] = [
b64encode(x)
for x in conf_plist["OTAProfileStub"]["SignerCerts"]
]
if "PayloadContent" in conf_plist["OTAProfileStub"]:
if (
"EnrollmentIdentityPersistentID"
in conf_plist["OTAProfileStub"]["PayloadContent"]
):
conf_plist["OTAProfileStub"]["PayloadContent"][
"EnrollmentIdentityPersistentID"
] = b64encode(
conf_plist["OTAProfileStub"]["PayloadContent"][
"EnrollmentIdentityPersistentID"
]
)
if "PushTokenDataSentToServerKey" in conf_plist:
conf_plist["PushTokenDataSentToServerKey"] = b64encode(
conf_plist["PushTokenDataSentToServerKey"]
)
if "LastPushTokenHash" in conf_plist:
conf_plist["LastPushTokenHash"] = b64encode(
conf_plist["LastPushTokenHash"]
)
if "PayloadContent" in conf_plist:
for content_entry in range(len(conf_plist["PayloadContent"])):
if "PERSISTENT_REF" in conf_plist["PayloadContent"][content_entry]:
conf_plist["PayloadContent"][content_entry][
"PERSISTENT_REF"
] = b64encode(
conf_plist["PayloadContent"][content_entry][
"PERSISTENT_REF"
]
)
if (
"IdentityPersistentRef"
in conf_plist["PayloadContent"][content_entry]
):
conf_plist["PayloadContent"][content_entry][
"IdentityPersistentRef"
] = b64encode(
conf_plist["PayloadContent"][content_entry][
"IdentityPersistentRef"
]
)
self._b64encode_plist_bytes(conf_plist)
self.results.append(
{
+1 -1
View File
@@ -33,7 +33,7 @@ class Manifest(IOSExtraction):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
+1 -1
View File
@@ -34,7 +34,7 @@ class ProfileEvents(IOSExtraction):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
+66 -20
View File
@@ -9,6 +9,8 @@ import os
import shutil
import sqlite3
import subprocess
import tempfile
from pathlib import Path
from typing import Iterator, Optional, Union
from mvt.common.module import (
@@ -19,6 +21,20 @@ from mvt.common.module import (
)
class TemporarySQLiteConnection(sqlite3.Connection):
"""SQLite connection that owns a temporary copy of a database."""
temporary_directory: Optional[tempfile.TemporaryDirectory] = None
def close(self) -> None:
try:
super().close()
finally:
if self.temporary_directory:
self.temporary_directory.cleanup()
self.temporary_directory = None
class IOSExtraction(MVTModule):
"""This class provides a base for all iOS filesystem/backup extraction
modules."""
@@ -30,7 +46,7 @@ class IOSExtraction(MVTModule):
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
@@ -43,6 +59,8 @@ class IOSExtraction(MVTModule):
self.is_backup = False
self.is_fs_dump = False
self._recovered_sqlite_paths: dict[str, str] = {}
self._sqlite_temp_directories: list[tempfile.TemporaryDirectory] = []
def _recover_sqlite_db_if_needed(
self, file_path: str, forced: bool = False
@@ -52,17 +70,12 @@ class IOSExtraction(MVTModule):
:param file_path: Path to the malformed database file.
"""
# TODO: Find a better solution.
if not forced:
# If the database is open, do not use immutable
if os.path.isfile(file_path + "-shm"):
conn = sqlite3.connect(file_path)
else:
conn = self._open_sqlite_db(file_path)
conn = self._open_sqlite_db(file_path)
cur = conn.cursor()
recover = False
try:
recover = False
cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
except sqlite3.DatabaseError as exc:
if "database disk image is malformed" in str(exc):
@@ -81,27 +94,54 @@ class IOSExtraction(MVTModule):
raise DatabaseCorruptedError(
"failed to recover without sqlite3 binary: please install sqlite3!"
)
if '"' in file_path:
raise DatabaseCorruptedError(
f"database at path '{file_path}' is corrupted. unable to "
'recover because it has a quotation mark (") in its name'
)
bak_path = f"{file_path}.bak"
shutil.move(file_path, bak_path)
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_recover_")
temporary_path = Path(temporary_directory.name)
source_path = temporary_path / "source.db"
recovered_path = temporary_path / "recovered.db"
shutil.copy2(file_path, source_path)
for suffix in ("-wal", "-shm"):
sidecar = Path(file_path + suffix)
if sidecar.is_file():
shutil.copy2(sidecar, Path(str(source_path) + suffix))
ret = subprocess.call(
["sqlite3", bak_path, f'.clone "{file_path}"'],
["sqlite3", str(source_path), f'.clone "{recovered_path}"'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if ret != 0:
temporary_directory.cleanup()
raise DatabaseCorruptedError("failed to recover database")
self._sqlite_temp_directories.append(temporary_directory)
self._recovered_sqlite_paths[file_path] = str(recovered_path)
self.log.info("Database at path %s recovered successfully!", file_path)
def _open_sqlite_db(self, file_path: str) -> sqlite3.Connection:
return sqlite3.connect(f"file:{file_path}?immutable=1", uri=True)
database_path = self._recovered_sqlite_paths.get(file_path, file_path)
if not os.path.isfile(database_path + "-wal"):
uri = Path(database_path).resolve().as_uri() + "?mode=ro&immutable=1"
return sqlite3.connect(uri, uri=True)
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_")
temporary_path = Path(temporary_directory.name) / Path(database_path).name
shutil.copy2(database_path, temporary_path)
for suffix in ("-wal", "-shm"):
sidecar = Path(database_path + suffix)
if sidecar.is_file():
shutil.copy2(sidecar, Path(str(temporary_path) + suffix))
try:
conn = sqlite3.connect(
temporary_path.resolve().as_uri() + "?mode=ro",
uri=True,
factory=TemporarySQLiteConnection,
)
except Exception:
temporary_directory.cleanup()
raise
conn.temporary_directory = temporary_directory
return conn
def _get_backup_files_from_manifest(
self, relative_path: Optional[str] = None, domain: Optional[str] = None
@@ -165,6 +205,12 @@ class IOSExtraction(MVTModule):
if not self.target_path:
return None
file_path = os.path.join(self.target_path, file_id[0:2], file_id)
if (
not Path(file_path)
.resolve()
.is_relative_to(Path(self.target_path).resolve())
):
return None
if os.path.exists(file_path):
return file_path
@@ -194,9 +240,9 @@ class IOSExtraction(MVTModule):
:param backup_ids: Default value = None)
"""
file_path: Optional[str] = None
file_path: Optional[str] = self.file_path
# First we check if the was an explicit file path specified.
if not self.file_path:
if not file_path:
# Type narrowing: we know self.file_path is None here, work with local file_path
# If not, we first try with backups.
# We construct the path to the file according to the iTunes backup
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more