Compare commits

...
Author SHA1 Message Date
Janik Besendorf dd96ebd409 Add Android bugreport schema version 2026-08-22 14:22:35 +02:00
Janik Besendorf fd27fe3378 Fix module registry type annotations 2026-08-22 14:21:51 +02:00
Janik Besendorf 7ef26779bf Register added bugreport parsers 2026-08-22 14:21:51 +02:00
Janik Besendorf 3a43f6fcc7 Merge plaintext and protobuf tombstones 2026-08-22 14:21:51 +02:00
Janik Besendorf 45fdf85790 Add bugreport mountinfo parser 2026-08-22 14:21:51 +02:00
Janik Besendorf 627ab32d42 Add bugreport settings parser 2026-08-22 14:21:51 +02:00
Janik Besendorf 8b85972cc3 Add bugreport process table parser 2026-08-22 14:21:51 +02:00
Janik Besendorf f747751b09 Add timezone provenance to file timestamps 2026-08-22 14:21:51 +02:00
Janik Besendorf 832e46604d Parse all platform compatibility overrides 2026-08-22 14:21:51 +02:00
Janik Besendorf a3c8b56102 Parse typed multi-user package details 2026-08-22 14:21:51 +02:00
Janik Besendorf e9bf197ec6 Parse all database operation details 2026-08-22 14:20:59 +02:00
Janik Besendorf 288b313649 Parse complete battery history events 2026-08-22 14:20:59 +02:00
Janik Besendorf 87a27f6e37 Normalize battery daily update records 2026-08-22 14:20:59 +02:00
Janik Besendorf 1bf2f9b35a Retain AppOps UID and event details 2026-08-22 14:20:59 +02:00
Janik Besendorf b904414bb0 Parse ADB binary XML keys 2026-08-22 14:20:59 +02:00
Janik Besendorf 008844b440 Parse accessibility service states per user 2026-08-22 14:20:59 +02:00
Janik Besendorf ab879bb23b Parse all package resolver categories 2026-08-22 14:20:59 +02:00
Janik Besendorf dc0650bf76 Fix bugreport command section extraction 2026-08-22 14:20:59 +02:00
Donncha Ó Cearbhaill dac4acb180 Load installed module packages via entry points (#883)
* Load installed module packages via entry points

Python packages can already register custom CLI commands which load
automatically, but custom modules still require --load-module or the
MVT_CUSTOM_MODULES environment variable on every invocation.

Add an mvt.modules entry-point group so installed packages can register
forensic modules which load automatically into every module-running
check-* command. An entry point resolves to an iterable of MVTModule
subclasses, or a callable returning one. Broken entry points are
skipped with a warning so a faulty package cannot break MVT.

* Record the source of loaded modules for auditability

Now that installed module packages load automatically, record where every
module came from:

- --list-modules groups the available modules by source, one line per
  source with the modules comma-separated: MVT itself with its version,
  each installed package with its version and VCS commit when recorded
  (PEP 610 direct_url.json), and each --load-module/MVT_CUSTOM_MODULES
  file with its SHA-256 hash.
- Commands log one line per module source with its version or hash and
  the modules loaded from it, so command.log records exactly which
  modules ran and where they came from.
- Make init_logging() idempotent: a loaded module package importing an
  MVT CLI module would previously add a second console handler and
  duplicate every console log line.

* Route loaded module logging under the mvt.ext namespace

Modules loaded from installed packages or file paths live outside the
mvt logger hierarchy, so their log records never reach MVT's console
and file handlers and instead fall through to logging.lastResort:
alerts print as bare unformatted lines and INFO messages are dropped
entirely.

Add get_module_logger() and use it everywhere module loggers are
created. Built-in mvt.* modules keep their existing logger names, and
everything external is parented under a dedicated mvt.ext namespace so
records reach the handlers and external names can never collide with
MVT's internal logger tree. File-path modules are named after their
file (mvt.ext.<stem>) instead of the mangled internal import name.

Document a naming convention for community module packages:
distribute as mvt-plugin-<name> with import package mvt_plugin_<name>,
including the publishing organization in the name. The prefix is
advisory (loading is by entry point, and it is no mark of
authenticity), but conforming packages get a cleaner logger namespace:
the mvt_plugin_ prefix is stripped, so mvt_plugin_amnesty_custom logs
as mvt.ext.amnesty_custom.
2026-08-19 23:15:48 +02:00
besendorfandDonncha Ó Cearbhaill dd8bd2cb01 Group custom docs under development (#878)
Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
2026-08-19 16:46:31 +02:00
Donncha Ó Cearbhaill 30c11f68c7 Add WhatsApp contacts module and fix InteractionC contact resolution (#882)
* Add WhatsappContacts module to extract WhatsApp disappearing messages state

WhatsApp on iOS stores the disappearing messages timer for 1:1 chats on
the contact records in ContactsV2.sqlite, not in ChatStorage.sqlite. Add
a new WhatsappContacts module which extracts contact records from this
database, including phone numbers, WhatsApp and LID identifiers, and the
per-contact disappearing messages duration, and emits a timeline event
when a disappearing messages timer was set.

The database is often missing from incremental backups, so the module
logs a clear warning and returns no results instead of failing. Columns
are selected based on the actual table schema to tolerate changes across
WhatsApp versions, and if the disappearing messages column is absent the
state is reported as unknown rather than off.

The test fixture is a synthetic ContactsV2.sqlite with fictional
contacts, stored under the backup file ID derived from the WhatsApp
shared app group domain.

* Fix InteractionC contact resolution and resolve WhatsApp LIDs to contacts

The two primary InteractionC queries contained a SQL syntax error in
their direction CASE expression (a double column alias), so they always
failed and the module silently fell back to a reduced query without the
recipient join. As a result outgoing messages were serialized with no
counterpart at all ("from None (None)"). Fix the syntax so recipient
names and identifiers are extracted again, and normalize the raw 0/1
direction values from the fallback queries to INCOMING/OUTGOING.

WhatsApp identifies chat peers in interactionC.db by LID and stores the
peer LID in the domain identifier, which InteractionC could not map to a
person. Declare a dependency on the WhatsappContacts module and resolve
sender, recipient and domain identifiers (LID, JID or phone number)
against the WhatsApp contacts database, adding resolved phone number and
name fields to WhatsApp records.

Rewrite the timeline serialization to use the resolved values, fall back
to the chat peer from the domain identifier when no recipient was
recorded, label the local user instead of printing None, and include the
message direction and group name.

* Add timeline events for all WhatsApp contact timestamps

Extract ZABOUTEXPIRATIONTIMESTAMP and emit a timeline event for each
timestamp stored on a WhatsApp contact record: disappearing messages
timer changes, "about" text changes and scheduled expiry, and contact
record updates. ContactsV2.sqlite stores no other date attributes in
any released schema version.

* Add first and last interaction timeline events for WhatsApp chats

Extract one record per ZWACHATSESSION with the first and last stored
message dates, the session's own last-message date, the group creation
date and message counts. Each chat produces chat_first_message and
chat_last_message timeline events, and groups a group_created event.
The session last-message date is preferred over the newest stored
message because it survives message deletion.

* Resolve WhatsApp LID chat identifiers via the LID pair table

Recent WhatsApp versions key 1:1 chat sessions by an opaque LID rather
than the contact's phone number. Extract the ZWAPHONENUMBERLIDPAIR
table from the dedicated LID.sqlite database (or from ChatStorage
itself in versions that store it there) and use it to populate
partner_resolved_phone_number on chat session records and in timeline
events, without requiring the often-missing ContactsV2.sqlite. Each
pair is also extracted as a record and produces a lid_pair_recorded
timeline event marking when the association was learned.

* Reduce duplicate InteractionC timeline events

The interaction record's creation date normally trails its start date
by milliseconds, so serializing both nearly doubled the timeline with
duplicate entries. Only emit the creation date when it diverges from
the start date by more than an hour, with explicit wording, since a
record created long after its event indicates backfill by sync,
restore or tampering.

Per-contact aggregate dates from ZCONTACTS repeat on every interaction
row of the same contact and carried that row's message text. Serialize
them with contact-centric data strings instead, so timeline
de-duplication collapses them into one first/last-seen event per
contact.
2026-08-19 14:07:27 +02:00
besendorfandDonncha Ó Cearbhaill a92ec7f963 Build Docker images for ARM64 (#880)
Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
2026-08-19 11:46:48 +02:00
Donncha Ó Cearbhaill 10a5bccbe0 Fix usability nits in the Docker images (#881)
* Fix error in ADB key generation commmand

* Add useful forensic analysis tools
2026-08-19 11:43:40 +02:00
github-actions[bot]andDonnchaC 3b21c9347c Add new iOS versions and build numbers (#879)
Co-authored-by: DonnchaC <3081375+DonnchaC@users.noreply.github.com>
2026-08-18 07:04:35 +02:00
besendorf 0fdc2c34b0 Document iOS device access from Docker (#875)
* Document iOS device access from Docker

* Simplify docker.md instructions

Removed unnecessary note about mounting the entire '/var/run' directory.
2026-08-17 13:42:22 +02:00
besendorf 0ee25edf0a Fix dumpsys package system flag parsing (#874) 2026-08-14 15:58:05 +02:00
besendorf fa24b5465b Scope package fields to the primary user (#872) 2026-08-14 14:33:28 +02:00
besendorf 683b8ba133 Parse package installer from bugreports (#868) 2026-08-14 09:40:34 +02:00
besendorf 7b64463727 Update GitHub Actions (#866) 2026-08-12 15:42:15 +02:00
github-actions[bot]andDonnchaC 47fd771b2a Add new iOS versions and build numbers (#865)
Co-authored-by: DonnchaC <3081375+DonnchaC@users.noreply.github.com>
2026-08-11 10:07:52 +02:00
besendorf 8123db26d9 Fix iOS update workflow and refresh dependencies (#864) 2026-08-10 22:06:15 +02:00
besendorf d92a60c9be Preserve tombstone crash causes (#863) 2026-08-10 20:59:56 +02:00
besendorf 0b48d9fe1d Speed up compressed sysdiagnose analysis (#861)
* Speed up compressed sysdiagnose analysis

* ci: retrigger Ruff check
2026-08-07 09:07:33 +02:00
besendorf 067f053627 Add extensible CLI commands (#853)
* Add extensible CLI commands

* Handle plugin SystemExit failures
2026-08-05 23:30:28 +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
107 changed files with 5573 additions and 1544 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v0.5.0
- uses: actions/add-to-project@v2
with:
# You can target a project in a different organization
# to the issue
+3 -3
View File
@@ -11,13 +11,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v10.0.0
with:
enable-cache: true
- name: Install Dependencies
+9 -6
View File
@@ -36,10 +36,10 @@ jobs:
tag-suffix: "-android"
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -47,7 +47,7 @@ jobs:
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
@@ -59,21 +59,24 @@ jobs:
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
type=sha,suffix=${{ matrix.platform.tag-suffix }}
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
# Register emulators so Buildx can build the ARM64 images on the AMD64 runner.
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
# This step sets up some additional capabilities to generate the provenance and sbom attestations
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
file: ${{ matrix.platform.dockerfile }}
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max
sbom: true
+3 -3
View File
@@ -12,13 +12,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v10.0.0
with:
enable-cache: true
- name: Install Dependencies
+3 -3
View File
@@ -15,13 +15,13 @@ jobs:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v10.0.0
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- name: Install Python dependencies
+5 -3
View File
@@ -11,10 +11,12 @@ jobs:
update-ios-version:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- name: Install script dependencies
run: python -m pip install "packaging==26.3"
- name: Run script to fetch latest iOS releases from Apple RSS feed.
run: python3 .github/workflows/scripts/update-ios-releases.py
run: python .github/workflows/scripts/update-ios-releases.py
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
+13 -2
View File
@@ -119,12 +119,23 @@ ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y \
adb \
binutils \
default-jre-headless \
file \
jq \
less \
libcurl4 \
libimage-exiftool-perl \
libssl3 \
libusb-1.0-0 \
moreutils \
p7zip-full \
python3 \
sqlite3
ripgrep \
sqlite3 \
tree \
unzip \
xxd
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libtatsu /build /
@@ -144,7 +155,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2
RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc
# Generate adb key folder
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc
RUN mkdir /root/.android
# Setup investigations environment
+1 -1
View File
@@ -31,7 +31,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2
RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc
# Generate adb key folder
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc
RUN mkdir /root/.android
ENTRYPOINT [ "/usr/local/bin/mvt-android" ]
+5
View File
@@ -82,6 +82,11 @@ Module-running `check-*` commands can load custom Python modules with
[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.
+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`.
+87
View File
@@ -117,6 +117,93 @@ class DependentCustomModule(MVTModule):
self.results = [{"manifest_entries": len(manifest_results)}]
```
## Installed module packages
Python packages can register modules so they load automatically in every
module-running `check-*` command, without `--load-module` or
`MVT_CUSTOM_MODULES`. Register an entry point in the `mvt.modules` group in
the package's `pyproject.toml`:
```toml
[project.entry-points."mvt.modules"]
mvt-plugin-amnesty-custom = "mvt_plugin_amnesty_custom:get_modules"
```
The entry point must resolve to an iterable of `MVTModule` subclasses, or to
a callable returning one:
```python
from mvt.common.module import MVTModule
class PackagedModule(MVTModule):
supported_commands = (("ios", "check-backup"),)
def run(self):
self.results = [{"message": "packaged module ran"}]
def get_modules() -> list[type[MVTModule]]:
return [PackagedModule]
```
Installed modules follow the same rules as other custom modules: each module
must declare `supported_commands`, and dependencies are resolved with the
standard ordering logic. A broken entry point is skipped with a warning and
does not prevent MVT from running. As with custom commands, installed module
packages run as trusted code inside the MVT process, so install only packages
from sources you trust.
For a `pipx` installation of MVT, inject the package into MVT's environment:
```bash
pipx inject mvt mvt-plugin-amnesty-custom
```
### Naming module packages
Name module packages `mvt-plugin-<name>` (import package `mvt_plugin_<name>`),
and include the name of the publishing organization or author so packages from
different groups do not collide: for example, Amnesty International's custom
modules would be distributed as `mvt-plugin-amnesty-custom` with the import
package `mvt_plugin_amnesty_custom`.
The prefix makes module packages easy to find on PyPI and keeps their import
names from clashing with unrelated Python packages. It is a convention, not a
technical requirement: modules load through the `mvt.modules` entry point
regardless of what the package is called, and existing packages with other
names keep working. Note that the prefix is also not a mark of authenticity —
anyone can publish a package with any available name, so vet a module package
and its publisher before installing it, whatever it is called.
### Module logging
Modules log through `self.log`, and MVT names the logger for where the module
came from. MVT's own modules log under their dotted path (for example
`mvt.ios.modules.mixed.whatsapp`). Everything external is namespaced under
`mvt.ext` to keep it visually distinct from built-in modules and isolated from
MVT's internal logger tree:
- Installed packages log under `mvt.ext.<package>`, with the `mvt_plugin_`
prefix stripped: modules in `mvt_plugin_amnesty_custom` log as
`mvt.ext.amnesty_custom.*`.
- Files loaded with `--load-module` or `MVT_CUSTOM_MODULES` log as
`mvt.ext.<file name>`.
## Auditing loaded modules
Because installed module packages load automatically, MVT records where every
module came from:
- `--list-modules` groups the available modules by source: MVT itself
(with its version), each installed package (with its version and, when
installed directly from a repository, the commit), and each file loaded
with `--load-module` or `MVT_CUSTOM_MODULES` (with the SHA-256 hash of the
file).
- When a command runs with an `--output` folder, the `command.log` file
records one line per module source with the source's version or hash and
the list of modules loaded from it.
## 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
+15 -1
View File
@@ -31,4 +31,18 @@ Test if the image was created successfully:
docker run -it mvt
```
If a prompt is spawned successfully, you can close it with `exit`.
If a prompt is spawned successfully, you can close it with `exit`.
## Access an iOS device from Docker
On the Linux host, install and start [usbmuxd](https://github.com/libimobiledevice/usbmuxd), then connect and unlock the iOS device. The daemon exposes the device through the `/var/run/usbmuxd` socket.
Bind that socket into the container to let MVT communicate with the device:
```bash
docker run -it \
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
ghcr.io/mvt-project/mvt
```
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
+24 -1
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"
@@ -408,7 +417,21 @@ If indicators are provided through the command-line, they are checked against th
Backup: :material-check:
Full filesystem dump: :material-check:
This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*.
This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*, along with one record per chat session (marked with `"record_type": "chat_session"`) containing the first and last interaction dates of each conversation. Chat sessions produce `chat_first_message` and `chat_last_message` timeline events, and group chats additionally produce a `group_created` event. A chat session's last-message date can postdate its newest stored message when the most recent messages in the chat were deleted.
Recent WhatsApp versions key 1:1 chat sessions by an opaque LID identifier rather than the contact's phone number. The module resolves these using the `ZWAPHONENUMBERLIDPAIR` table from the *LID.sqlite* database in the same app group (or from *ChatStorage.sqlite* itself in versions that store it there), populating `partner_resolved_phone_number` on chat session records and using the phone number in timeline events. Each LID-phone number pair is also extracted as a record (`"record_type": "lid_phone_number_pair"`) and produces a `lid_pair_recorded` timeline event marking when WhatsApp learned the association.
If indicators are provided through the command-line, they are checked against the extracted HTTP links. Any matches are stored in *whatsapp_detected.json*.
---
### `whatsapp_contacts.json`
!!! info "Availability"
Backup: :material-check:
Full filesystem dump: :material-check:
This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extracts WhatsApp contact records from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ContactsV2.sqlite*, including each contact's phone number, WhatsApp and LID identifiers, and the per-contact disappearing messages timer, which is not recorded in *ChatStorage.sqlite*. Each timestamp stored on a contact record produces a timeline event: `disappearing_mode_set` (when the disappearing messages timer was last changed), `about_changed` (when the contact last changed their "about" text), `about_expiration` (when a timed "about" is scheduled to expire) and `contact_last_updated` (when the contact record was last updated).
This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup.
+3 -1
View File
@@ -49,5 +49,7 @@ nav:
- 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"
- Development:
- Development Instructions: "development.md"
- Custom CLI Commands: "custom_commands.md"
- License: "license.md"
+13 -13
View File
@@ -17,25 +17,25 @@ classifiers = [
"Programming Language :: Python",
]
dependencies = [
"click==8.3.3",
"rich==14.3.3",
"click==8.4.2",
"rich==15.0.0",
"tld==0.13.2",
"requests==2.33.1",
"simplejson==3.20.2",
"packaging==26.2",
"requests==2.34.2",
"simplejson==4.1.1",
"packaging==26.3",
"appdirs==1.4.4",
"iOSbackup==0.9.925",
"adb-shell[usb]==0.4.4",
"libusb1==3.3.1",
"cryptography==47.0.0",
"libusb1==3.4.0",
"cryptography==50.0.0",
"PyYAML>=6.0.2",
"pyahocorasick==2.3.1",
"betterproto2==0.9.1",
"pydantic==2.13.3",
"pydantic-settings==2.14.0",
"betterproto2==0.10.0",
"pydantic==2.13.4",
"pydantic-settings==2.15.0",
"NSKeyedUnArchiver==1.5.2",
"python-dateutil==2.9.0.post0",
"tzdata==2026.2",
"tzdata==2026.3",
]
requires-python = ">= 3.10"
@@ -65,9 +65,9 @@ dev = [
docs = [
"mkdocs==1.6.1",
"mkdocs-autorefs==1.4.4",
"mkdocs-material==9.7.6",
"mkdocs-material==9.7.7",
"mkdocs-material-extensions==1.3.1",
"mkdocstrings==1.0.4",
"mkdocstrings==1.0.6",
]
[build-system]
@@ -4,6 +4,7 @@
# https://license.mvt.re/1.1/
import re
from typing import Any
from .artifact import AndroidArtifact
@@ -20,10 +21,10 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
continue
self.alertstore.medium(
f'Found accessibility service: "{result["service"]}"',
f'Found accessibility service: "{result["component"]}"',
"",
result,
)
)
def parse(self, content: str) -> None:
"""
@@ -33,41 +34,69 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
:param content: content of the accessibility section (string)
"""
# "Old" syntax
in_services = False
self.results: list[dict[str, Any]] = []
services: dict[tuple[int | None, str], dict] = {}
user_id: int | None = None
state: str | None = None
for line in content.splitlines():
if line.strip().startswith("installed services:"):
in_services = True
continue
user_match = re.search(r"attributes:\{id=(\d+)", line)
if user_match:
user_id = int(user_match.group(1))
if not in_services:
continue
if line.strip() == "}":
# At end of installed services
break
service = line.split(":")[1].strip()
self.results.append(
{
"package_name": service.split("/")[0],
"service": service,
}
stripped = line.strip()
state_match = re.match(
r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)",
stripped,
)
# "New" syntax - AOSP >= 14 (?)
# Looks like:
# Enabled services:{{com.azure.authenticator/com.microsoft.brooklyn.module.accessibility.BrooklynAccessibilityService}, {com.agilebits.onepassword/com.agilebits.onepassword.filling.accessibility.FillingAccessibilityService}}
for line in content.splitlines():
if line.strip().startswith("Enabled services:"):
matches = re.finditer(r"{([^{]+?)}", line)
for match in matches:
# Each match is in format: <package_name>/<service>
package_name, _, service = match.group(1).partition("/")
self.results.append(
{"package_name": package_name, "service": service}
if state_match:
state = state_match.group(1).lower()
inline = state_match.group(2)
for component in re.findall(
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
):
service = services.setdefault(
(user_id, component), self._new_service(component, user_id)
)
service[self._state_field(state)] = True
service["accessibility_tool"] = "(A11yTool)" in inline
continue
if not state:
continue
if stripped == "}" or stripped.startswith("AccessibilityInputFilter"):
state = None
continue
component_match = re.search(
r"(?:\d+\s*:\s*)?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?",
stripped,
)
if component_match:
component = component_match.group(1)
service = services.setdefault(
(user_id, component), self._new_service(component, user_id)
)
service[self._state_field(state)] = True
service["accessibility_tool"] = "(A11yTool)" in stripped
self.results.extend(services.values())
@staticmethod
def _state_field(state: str) -> str:
return {"binding": "binding", "bound": "bound"}.get(state, state)
@staticmethod
def _new_service(component: str, user_id: int | None) -> dict:
package_name, service_name = component.split("/", 1)
return {
"user_id": user_id,
"component": component,
"package_name": package_name,
"service_name": service_name,
"installed": False,
"enabled": False,
"binding": False,
"bound": False,
"crashed": False,
"accessibility_tool": False,
}
+41 -1
View File
@@ -6,6 +6,7 @@
import base64
import binascii
import hashlib
import re
from .artifact import AndroidArtifact
@@ -98,6 +99,34 @@ class DumpsysADBArtifact(AndroidArtifact):
return keystore
def parse_binary_xml(self, data: bytes) -> list[dict]:
"""Recover ADB key records from Android binary XML (ABX).
Some dumpstate implementations embed ABX in a text stream and replace
binary token bytes. The public key remains intact, while unavailable
numeric metadata is represented as ``None`` rather than corrupt text.
"""
keystore = []
seen = set()
for match in re.finditer(
rb"(?<![A-Za-z0-9+/])([A-Za-z0-9+/]{300,}={0,2})"
rb"(?: ([A-Za-z0-9_.@-]+))?",
data,
):
key = match.group(1)
try:
base64.b64decode(key, validate=True)
except (binascii.Error, ValueError):
continue
if key in seen:
continue
seen.add(key)
full_key = key + (b" " + match.group(2) if match.group(2) else b"")
key_info = self.calculate_key_info(full_key)
key_info["last_connected"] = None
keystore.append(key_info)
return keystore
@staticmethod
def calculate_key_info(user_key: bytes) -> dict:
if b" " in user_key:
@@ -118,7 +147,7 @@ class DumpsysADBArtifact(AndroidArtifact):
return {
"user": user.decode("utf-8"),
"fingerprint": key_fingerprint_colon,
"key": key_base64,
"key": key_base64.decode("ascii", errors="replace"),
}
def check_indicators(self) -> None:
@@ -175,12 +204,23 @@ class DumpsysADBArtifact(AndroidArtifact):
# Keystore is in XML format on some devices and we need to parse it
if keystore_data and keystore_data.startswith(b"<?xml"):
parsed["debugging_manager"]["keystore"] = self.parse_xml(keystore_data)
elif keystore_data and keystore_data.startswith(b"ABX\x00"):
parsed["debugging_manager"]["keystore"] = self.parse_binary_xml(
keystore_data
)
else:
# Keystore is not XML format
parsed["debugging_manager"]["keystore"] = keystore_data
parsed = parsed["debugging_manager"]
for key, value in list(parsed.items()):
if isinstance(value, bytes):
decoded = value.decode("utf-8", errors="replace")
parsed[key] = (
decoded == "true" if decoded in ("true", "false") else decoded
)
# Calculate key fingerprints for better readability
key_info = []
for user_key in parsed.get("user_keys", []):
+122 -81
View File
@@ -4,6 +4,7 @@
# https://license.mvt.re/1.1/
from datetime import datetime
import re
from typing import Any
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
@@ -27,14 +28,14 @@ class DumpsysAppopsArtifact(AndroidArtifact):
continue
for entry in perm["entries"]:
if "timestamp" in entry:
if entry.get("timestamp"):
records.append(
{
"timestamp": entry["timestamp"],
"module": self.__class__.__name__,
"event": entry["access"],
"event": entry["event"],
"data": f"{result['package_name']} access to "
f"{perm['name']}: {entry['access']}",
f"{perm['name']}: {entry['event']}",
}
)
@@ -51,7 +52,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
continue
# We use a placeholder entry to create a basic alert even without permission entries.
placeholder_entry = {"access": "Unknown", "timestamp": ""}
placeholder_entry = {"event": "unknown", "timestamp": ""}
for perm in result["permissions"]:
if (
@@ -60,12 +61,12 @@ class DumpsysAppopsArtifact(AndroidArtifact):
):
for entry in sorted(
perm["entries"] or [placeholder_entry],
key=lambda x: x["timestamp"],
key=lambda x: x.get("timestamp") or "",
):
cleaned_result = result.copy()
cleaned_result["permissions"] = [perm]
self.alertstore.medium(
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['event']}' at {entry['timestamp']}",
entry["timestamp"],
cleaned_result,
)
@@ -73,111 +74,151 @@ class DumpsysAppopsArtifact(AndroidArtifact):
elif result["package_name"] in RISKY_PACKAGES:
for entry in sorted(
perm["entries"] or [placeholder_entry],
key=lambda x: x["timestamp"],
key=lambda x: x.get("timestamp") or "",
):
cleaned_result = result.copy()
cleaned_result["permissions"] = [perm]
self.alertstore.medium(
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['event']}' at {entry['timestamp']}",
entry["timestamp"],
cleaned_result,
)
def parse(self, output: str) -> None:
# self.results: List[Dict[str, Any]] = []
perm: dict[str, Any] = {}
package: dict[str, Any] = {}
entry: dict[str, Any] = {}
uid = None
self.results: list[dict[str, Any]] = []
permission: dict[str, Any] | None = None
package: dict[str, Any] | None = None
uid: str | None = None
uid_details: dict[str, Any] = {}
attribution: str | None = None
in_packages = False
def finish_permission() -> None:
nonlocal permission
if package is not None and permission is not None:
package["permissions"].append(permission)
permission = None
def finish_package() -> None:
nonlocal package
finish_permission()
if package is not None:
self.results.append(package)
package = None
for line in output.splitlines():
if line.startswith(" Uid 0:"):
uid_match = re.match(r"^ Uid ([^:]+):$", line)
if uid_match:
in_packages = True
finish_package()
uid = uid_match.group(1)
uid_details = {
"uid_state": None,
"capability": None,
"app_widget_visible": None,
"default_modes": {},
}
continue
if not in_packages:
continue
if line.startswith(" Uid "):
uid = line[6:-1]
if entry:
perm["entries"].append(entry)
entry = {}
if package:
if perm:
package["permissions"].append(perm)
uid_property = re.match(
r"^ (state|capability|appWidgetVisible)=(.*)$", line
)
if uid_property:
key = {
"state": "uid_state",
"appWidgetVisible": "app_widget_visible",
}.get(uid_property.group(1), uid_property.group(1))
value: Any = uid_property.group(2)
if value in ("true", "false"):
value = value == "true"
uid_details[key] = value
continue
perm = {}
self.results.append(package)
package = {}
default_mode = re.match(r"^ ([A-Z0-9_]+): mode=([^\s]+)", line)
if default_mode and package is None:
uid_details["default_modes"][default_mode.group(1)] = (
default_mode.group(2)
)
continue
if line.startswith(" Package "):
if entry:
perm["entries"].append(entry)
entry = {}
if package:
if perm:
package["permissions"].append(perm)
perm = {}
self.results.append(package)
finish_package()
package = {
"package_name": line[12:-1],
"permissions": [],
"uid": uid,
**uid_details,
}
continue
if package and line.startswith(" ") and line[6] != " ":
if entry:
perm["entries"].append(entry)
entry = {}
if perm:
package["permissions"].append(perm)
perm = {}
perm["name"] = line.split()[0]
perm["entries"] = []
if len(line.split()) > 1:
perm["access"] = line.split()[1][1:-2]
operation_match = re.match(
r"^ ([A-Z0-9_]+)(?: \(([^)]+)\))?:\s*$", line
)
if package is not None and operation_match:
finish_permission()
permission = {
"name": operation_match.group(1),
"mode": operation_match.group(2),
"entries": [],
}
attribution = None
continue
if line.startswith(" "):
# Permission entry like:
# Reject: [fg-s]2021-05-19 22:02:52.054 (-314d1h25m2s33ms)
access_type = line.split(":")[0].strip()
if access_type not in ["Access", "Reject"]:
# Skipping invalid access type. Some entries are not in the format we expect
continue
attribution_match = re.match(r"^\s{8,}([^=]+)=\[$", line)
if attribution_match:
attribution = attribution_match.group(1).strip()
continue
if line.strip() == "]":
attribution = None
continue
if entry:
perm["entries"].append(entry)
entry = {}
entry["access"] = access_type
entry["type"] = line[line.find("[") + 1 : line.find("]")]
try:
entry["timestamp"] = convert_datetime_to_iso(
datetime.strptime(
line[line.find("]") + 1 : line.find("(")].strip(),
"%Y-%m-%d %H:%M:%S.%f",
)
if permission is None:
continue
event_match = re.match(
r"^\s*(Access|Reject):\s*\[([^]]+)\]\s*"
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s*"
r"(\([^)]*\))?(?:\s+duration=([^\s]+))?",
line,
)
running_match = re.match(
r"^\s*Running start at:\s*(\S+(?: \S+)?)",
line,
)
if event_match:
entry = {
"event": event_match.group(1).lower(),
"access": event_match.group(1),
"uid_state": event_match.group(2),
"timestamp": convert_datetime_to_iso(
datetime.strptime(event_match.group(3), "%Y-%m-%d %H:%M:%S.%f")
),
"relative_time": event_match.group(4),
"duration": event_match.group(5),
"attribution": attribution,
}
permission["entries"].append(entry)
elif running_match:
raw_start = running_match.group(1)
timestamp = None
if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", raw_start):
timestamp = convert_datetime_to_iso(
datetime.strptime(raw_start, "%Y-%m-%d %H:%M:%S.%f")
)
except ValueError:
# Invalid date format
pass
permission["entries"].append(
{
"event": "running",
"access": "Running",
"uid_state": None,
"timestamp": timestamp,
"relative_time": raw_start
if raw_start.startswith("+")
else None,
"duration": None,
"attribution": attribution,
}
)
if line.strip() == "":
break
if entry:
perm["entries"].append(entry)
if perm:
package["permissions"].append(perm)
if package:
self.results.append(package)
finish_package()
@@ -18,18 +18,18 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
action = record.get("action", "update")
package_name = record["package_name"]
vers = record["vers"]
vers = record["version_code"]
if vers == "0":
if vers == 0:
data = f"Recorded uninstall of package {package_name} (vers 0)"
elif action == "downgrade":
prev_vers = record.get("previous_vers", "unknown")
prev_vers = record.get("previous_version_code", "unknown")
data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}"
else:
data = f"Recorded update of package {package_name} with vers {vers}"
return {
"timestamp": record["from"],
"timestamp": record["period_start"],
"module": self.__class__.__name__,
"event": "battery_daily",
"data": data,
@@ -48,6 +48,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
continue
def parse(self, output: str) -> None:
self.results = []
daily = None
daily_updates: list[dict[str, Any]] = []
records: list[dict[str, Any]] = []
@@ -59,7 +60,10 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
timeframe = line[13:].strip()
date_from, date_to = timeframe.strip(":").split(" to ", 1)
daily = {"from": date_from[0:10], "to": date_to[0:10]}
daily = {
"period_start": self._format_daily_timestamp(date_from),
"period_end": self._format_daily_timestamp(date_to),
}
continue
if not daily:
@@ -70,21 +74,30 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
line = line.strip().replace("Update ", "")
package_name, vers = line.split(" ", 1)
vers_nr = vers.split("=", 1)[1]
vers_raw = vers.split("=", 1)[1]
try:
version_code: int | str = int(vers_raw)
except ValueError:
version_code = vers_raw
already_seen = False
for update in daily_updates:
if package_name == update["package_name"] and vers_nr == update["vers"]:
if (
package_name == update["package_name"]
and version_code == update["version_code"]
):
update["occurrences"] += 1
already_seen = True
break
if not already_seen:
update_record: dict[str, Any] = {
"action": "update",
"from": daily["from"],
"to": daily["to"],
"period_start": daily["period_start"],
"period_end": daily["period_end"],
"package_name": package_name,
"vers": vers_nr,
"version_code": version_code,
"occurrences": 1,
}
daily_updates.append(update_record)
@@ -95,26 +108,31 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
self._detect_uninstalls_and_downgrades(records)
self.results.extend(records)
def _detect_uninstalls_and_downgrades(
self, records: list[dict[str, Any]]
) -> None:
@staticmethod
def _format_daily_timestamp(value: str) -> str:
if len(value) >= 19 and value[10] == "-":
return f"{value[:10]} {value[11:].replace('-', ':')}"
return value
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["period_start"],
record["period_end"],
record["package_name"],
),
):
package_name = record["package_name"]
vers_nr = record["vers"]
vers_nr = record["version_code"]
if vers_nr == "0":
if vers_nr == 0:
record["action"] = "uninstall"
self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)",
record["from"],
record["period_start"],
record,
)
package_versions.pop(package_name, None)
@@ -128,11 +146,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
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)
record["previous_version_code"] = previous_vers
self.alertstore.medium(
f"Detected downgrade of package {package_name} "
f"from vers {previous_vers} to vers {current_vers}",
record["from"],
record["period_start"],
record,
)
@@ -1,96 +1,177 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# 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 datetime
import re
from mvt.common.utils import convert_datetime_to_iso
from .artifact import AndroidArtifact
class DumpsysBatteryHistoryArtifact(AndroidArtifact):
"""
Parser for dumpsys dattery history events.
"""
"""Parser for package-related events in dumpsys batterystats history."""
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"])
package_name = result.get("package_name")
if not package_name:
continue
ioc_match = self.indicators.check_app_id(package_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
continue
@staticmethod
def _parse_wall_time(value: str) -> datetime.datetime | None:
if re.fullmatch(r"\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", value):
value = f"1900-{value}"
for date_format in (
"%Y-%m-%d-%H-%M-%S-%f",
"%Y-%m-%d-%H-%M-%S",
"%Y-%m-%d %H:%M:%S.%f",
):
try:
return datetime.datetime.strptime(value, date_format)
except ValueError:
pass
return None
@staticmethod
def _elapsed_seconds(value: str) -> float | None:
if not value.startswith("+"):
return None
units = {"d": 86400, "h": 3600, "m": 60, "s": 1, "ms": 0.001}
total = 0.0
for number, unit in re.findall(r"(\d+)(ms|d|h|m|s)", value):
total += int(number) * units[unit]
return total
@staticmethod
def _package_from_name(name: str) -> str | None:
clean = name.removeprefix("*walarm*:").removeprefix("*alarm*:")
slash_parts = clean.split("/")
if len(slash_parts) > 1:
first = slash_parts[0].lstrip("@")
if first.startswith(("com.", "org.", "net.")):
return first
for part in reversed(slash_parts[1:]):
candidate = part.lstrip("@").split(":", 1)[0]
if candidate.startswith(("com.", "org.", "net.")):
return candidate
parts = clean.split(".")
package_parts = []
for part in parts:
if part and part[0].islower():
package_parts.append(part)
else:
break
return ".".join(package_parts) if len(package_parts) >= 2 else None
@staticmethod
def _normalize_service(service: str) -> str:
# WorkManager decorates jobs with one or more scheduler prefixes.
if "@" in service:
candidates = [part for part in service.split("@") if "/" in part]
if candidates:
return candidates[-1]
return service
def parse(self, data: str) -> None:
self.results: list[dict[str, str | None]] = []
anchor_time: datetime.datetime | None = None
anchor_elapsed = 0.0
has_history_heading = any(
line.startswith("Battery History") for line in data.splitlines()
)
in_history = not has_history_heading
for line in data.splitlines():
if line.startswith("Battery History "):
stripped = line.strip()
if line.startswith("Battery History"):
if in_history:
break
in_history = True
continue
if line.strip() == "":
if not in_history:
continue
if has_history_heading and not stripped:
break
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"
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"
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:
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()
)
if service == "" or "/" not in service:
continue
package_name = service.split("/")[0]
elif (line.find("+top=") > 0) or (line.find("-top") > 0):
if line.find("+top=") > 0:
event = "start_top"
top_pos = line.find("+top=")
else:
event = "end_top"
top_pos = line.find("-top=")
colon_pos = top_pos + line[top_pos:].find(":")
uid = line[top_pos + 5 : colon_pos]
service = ""
package_name = line[colon_pos + 1 :].strip('"')
else:
reset_match = re.search(r"(?:RESET:)?TIME:\s*(\S+)", stripped)
if reset_match:
parsed_time = self._parse_wall_time(reset_match.group(1))
if parsed_time is not None:
elapsed_token = stripped.split()[0]
anchor_elapsed = self._elapsed_seconds(elapsed_token) or 0.0
anchor_time = parsed_time
continue
self.results.append(
{
"time_elapsed": time_elapsed,
"event": event,
"uid": uid,
"package_name": package_name,
"service": service,
}
)
fields = stripped.split()
if not fields:
continue
if len(fields) > 1 and re.fullmatch(r"\d{2}-\d{2}", fields[0]):
time_elapsed = " ".join(fields[:2])
line_time = self._parse_wall_time(time_elapsed)
elapsed = None
else:
time_elapsed = fields[0]
elapsed = self._elapsed_seconds(time_elapsed)
line_time = None
timestamp = line_time
if timestamp is None and anchor_time is not None and elapsed is not None:
timestamp = anchor_time + datetime.timedelta(
seconds=elapsed - anchor_elapsed
)
def add(
event: str, uid: str, service: str, package_name: str | None
) -> None:
self.results.append(
{
"time_elapsed": time_elapsed,
"timestamp": convert_datetime_to_iso(timestamp)
if timestamp
else None,
"event": event,
"uid": uid,
"package_name": package_name,
"service": service,
}
)
for sign, uid, raw_service in re.findall(
r"([+-])job=([^:\s]+):\"([^\"]+)\"", line
):
service = self._normalize_service(raw_service)
add(
"start_job" if sign == "+" else "end_job",
uid,
service,
self._package_from_name(service) or service.split("/", 1)[0],
)
for sign, uid, package_name in re.findall(
r"([+-])top=([^:\s]+):\"([^\"]+)\"", line
):
add(
"start_top" if sign == "+" else "end_top",
uid,
"",
package_name,
)
wake_match = re.search(r"\+wake_lock=([^:\s]+):\"([^\"]+)\"", line)
if wake_match:
wake_name = wake_match.group(2)
add(
"wake",
wake_match.group(1),
wake_name,
self._package_from_name(wake_name),
)
+25 -11
View File
@@ -29,16 +29,27 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
def parse(self, output: str) -> None:
rxp = re.compile(
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
r"^\s*\d+:\s*\[((?:\d{4}-)?\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]\s*"
r"(?:\[Pid:\((\d+)\)\])?\s*([\w-]+) took (\d+)ms - ([^,]+),"
r"\s*sql=\"(.*)\"(?:, path=(.*))?$"
)
pool = None
pool: str | None = None
connection_number: int | None = None
is_primary: bool | None = None
in_operations = False
for line in output.splitlines():
if line.startswith("Connection pool for "):
pool = line.replace("Connection pool for ", "").rstrip(":")
in_operations = False
connection_match = re.match(r"\s+Connection #(\d+):", line)
if connection_match:
connection_number = int(connection_match.group(1))
is_primary = None
if line.strip().startswith("isPrimaryConnection:"):
is_primary = line.strip().split(":", 1)[1].strip() == "true"
if not pool:
continue
@@ -52,7 +63,6 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
if not line.startswith(" "):
in_operations = False
pool = None
continue
match = rxp.match(line)
@@ -60,11 +70,15 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
continue
result = {
"isodate": match.group(1),
"timestamp": match.group(1),
"pid": int(match.group(2)) if match.group(2) else None,
"action": match.group(3),
"sql": match.group(4),
"path": pool,
"duration_ms": int(match.group(4)),
"status": match.group(5),
"sql": match.group(6),
"path": match.group(7) or pool,
"pool_path": pool,
"connection_number": connection_number,
"is_primary": is_primary,
}
if match.group(2):
result["pid"] = match.group(2)
self.results.append(result)
@@ -4,6 +4,7 @@
# https://license.mvt.re/1.1/
from .artifact import AndroidArtifact
from .package_resolvers import parse_resolver_table
class DumpsysPackageActivitiesArtifact(AndroidArtifact):
@@ -19,67 +20,11 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
)
continue
def parse(self, content: str):
def parse(self, content: str) -> None:
"""
Parse the Dumpsys Package section for activities
Adds results to self.results
:param content: content of the package section (string)
"""
self.results = []
in_activity_resolver_table = False
in_non_data_actions = False
intent = None
for line in content.splitlines():
if line.startswith("Activity Resolver Table:"):
in_activity_resolver_table = True
continue
if not in_activity_resolver_table:
continue
if line.startswith(" Non-Data Actions:"):
in_non_data_actions = True
continue
if not in_non_data_actions:
continue
# If we hit an empty line, the Non-Data Actions section should be
# finished.
if line.strip() == "":
break
# We detect the action name.
if (
line.startswith(" " * 6)
and not line.startswith(" " * 8)
and ":" in line
):
intent = line.strip().replace(":", "")
continue
# If we are not in an intent block yet, skip.
if not intent:
continue
# If we are in a block but the line does not start with 8 spaces
# it means the block ended a new one started, so we reset and
# continue.
if not line.startswith(" " * 8):
intent = None
continue
# If we got this far, we are processing receivers for the
# activities we are interested in.
activity = line.strip().split(" ")[1]
package_name = activity.split("/")[0]
self.results.append(
{
"intent": intent,
"package_name": package_name,
"activity": activity,
}
)
self.results = parse_resolver_table(content, "Activity")
+206 -158
View File
@@ -1,10 +1,10 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# 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 re
from typing import Any, Dict, List
from typing import Any
from mvt.android.utils import ROOT_PACKAGES
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
@@ -12,196 +12,244 @@ from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from .artifact import AndroidArtifact
def _value(raw: str) -> Any:
if raw == "null":
return None
if raw in ("true", "false"):
return raw == "true"
try:
return int(raw)
except ValueError:
return raw
class DumpsysPackagesArtifact(AndroidArtifact):
def check_indicators(self) -> None:
alerted_root_packages = set()
for result in self.results:
if result["package_name"] in ROOT_PACKAGES:
if result["package_name"] in alerted_root_packages:
continue
alerted_root_packages.add(result["package_name"])
package_name = result["package_name"]
if (
package_name in ROOT_PACKAGES
and package_name not in alerted_root_packages
):
alerted_root_packages.add(package_name)
self.alertstore.medium(
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
f'Found an installed package related to rooting/jailbreaking: "{package_name}"',
"",
result,
)
continue
if not self.indicators:
continue
ioc_match = self.indicators.check_app_id(result.get("package_name", ""))
ioc_match = self.indicators.check_app_id(package_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
records = []
timestamps = [
{"event": "package_install", "timestamp": record["timestamp"]},
("package_install", record.get("timestamp")),
("package_last_update", record.get("last_update_time")),
]
timestamps.extend(
("package_first_install", user.get("first_install_time"))
for user in record.get("users", [])
)
return [
{
"event": "package_first_install",
"timestamp": record["first_install_time"],
},
{"event": "package_last_update", "timestamp": record["last_update_time"]},
"timestamp": timestamp,
"module": self.__class__.__name__,
"event": event,
"data": f"Install or update of package {record['package_name']}",
}
for event, timestamp in timestamps
if timestamp
]
for timestamp in timestamps:
records.append(
{
"timestamp": timestamp["timestamp"],
"module": self.__class__.__name__,
"event": timestamp["event"],
"data": f"Install or update of package {record['package_name']}",
}
)
return records
@staticmethod
def parse_dumpsys_package_for_details(output: str) -> Dict[str, Any]:
"""
Parse one entry of a dumpsys package information
"""
details: Dict[str, Any] = {
"uid": "",
"version_name": "",
"version_code": "",
"timestamp": "",
"first_install_time": "",
"last_update_time": "",
"permissions": list(),
"requested_permissions": list(),
def _permission(line: str, permission_type: str) -> dict:
name, _, details = line.strip().partition(":")
granted_match = re.search(r"granted=(true|false)", details)
flags_match = re.search(r"flags=\[\s*([^]]*)\]", details)
return {
"name": name,
"type": permission_type,
"granted": granted_match.group(1) == "true" if granted_match else None,
"flags": [
flag.strip()
for flag in (flags_match.group(1).split("|") if flags_match else [])
if flag.strip()
],
}
in_install_permissions = False
in_runtime_permissions = False
in_declared_permissions = False
in_requested_permissions = True
@classmethod
def parse_dumpsys_package_for_details(cls, output: str) -> dict[str, Any]:
details: dict[str, Any] = {
"app_id": None,
"version_name": None,
"version_code": None,
"min_sdk": None,
"target_sdk": None,
"timestamp": None,
"last_update_time": None,
"installer": None,
"system": False,
"permissions": [],
"requested_permissions": [],
"users": [],
}
permission_section: str | None = None
current_user: dict[str, Any] | None = None
legacy_first_install: str | None = None
for line in output.splitlines():
if in_install_permissions:
if line.startswith(" " * 4) and not line.startswith(" " * 6):
in_install_permissions = False
else:
lineinfo = line.strip().split(":")
permission = lineinfo[0]
granted = None
if "granted=" in lineinfo[1]:
granted = "granted=true" in lineinfo[1]
stripped = line.strip()
user_match = re.match(r"User (\d+):\s*(.*)", stripped)
if user_match:
current_user = {"user_id": int(user_match.group(1)), "permissions": []}
for key, raw in re.findall(r"(\w+)=([^\s]+)", user_match.group(2)):
clean_key = {
"notLaunched": "not_launched",
"installReason": "install_reason",
"uninstallReason": "uninstall_reason",
"dataDir": "data_dir",
}.get(key, re.sub(r"(?<!^)(?=[A-Z])", "_", key).lower())
current_user[clean_key] = _value(raw)
details["users"].append(current_user)
permission_section = None
continue
details["permissions"].append(
{"name": permission, "granted": granted, "type": "install"}
)
if in_runtime_permissions:
if not line.startswith(" " * 8):
in_runtime_permissions = False
else:
lineinfo = line.strip().split(":")
permission = lineinfo[0]
granted = None
if "granted=" in lineinfo[1]:
granted = "granted=true" in lineinfo[1]
header = stripped.lower()
if header in {
"declared permissions:",
"install permissions:",
"requested permissions:",
"runtime permissions:",
}:
permission_section = header.split()[0]
continue
details["permissions"].append(
{"name": permission, "granted": granted, "type": "runtime"}
)
if in_declared_permissions:
if not line.startswith(" " * 6):
in_declared_permissions = False
else:
permission = line.strip().split(":")[0]
details["permissions"].append(
{"name": permission, "type": "declared"}
)
if in_requested_permissions:
if not line.startswith(" " * 6):
in_requested_permissions = False
else:
details["requested_permissions"].append(line.strip())
if line.strip().startswith("userId="):
details["uid"] = line.split("=")[1].strip()
elif line.strip().startswith("versionName="):
details["version_name"] = line.split("=")[1].strip()
elif line.strip().startswith("versionCode="):
details["version_code"] = line.split("=", 1)[1].strip()
elif line.strip().startswith("timeStamp="):
details["timestamp"] = line.split("=")[1].strip()
elif line.strip().startswith("firstInstallTime="):
details["first_install_time"] = line.split("=")[1].strip()
elif line.strip().startswith("lastUpdateTime="):
details["last_update_time"] = line.split("=")[1].strip()
elif line.strip() == "install permissions:":
in_install_permissions = True
elif line.strip() == "runtime permissions:":
in_runtime_permissions = True
elif line.strip() == "declared permissions:":
in_declared_permissions = True
elif line.strip() == "requested permissions:":
in_requested_permissions = True
return details
def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]:
"""
Parse the dumpsys package service data
"""
pkg_rxp = re.compile(r" Package \[(.+?)\].*")
results = []
package_name = None
package = {}
lines: list[str] = []
for line in output.splitlines():
if line.startswith(" Package ["):
if len(lines) > 0:
details = self.parse_dumpsys_package_for_details("\n".join(lines))
package.update(details)
results.append(package)
lines = []
package = {}
matches = pkg_rxp.findall(line)
if not matches:
if current_user is not None:
user_property = re.match(
r"(installReason|uninstallReason|dataDir|firstInstallTime)=(.*)",
stripped,
)
if user_property:
key = {
"installReason": "install_reason",
"uninstallReason": "uninstall_reason",
"dataDir": "data_dir",
"firstInstallTime": "first_install_time",
}[user_property.group(1)]
current_user[key] = _value(user_property.group(2))
continue
package_name = matches[0]
package["package_name"] = package_name
if permission_section == "requested" and line.startswith(" "):
details["requested_permissions"].append(stripped)
continue
if permission_section in ("declared", "install") and line.startswith(
" "
):
details["permissions"].append(
cls._permission(stripped, permission_section)
)
continue
if (
permission_section == "runtime"
and line.startswith(" ")
and current_user is not None
):
current_user["permissions"].append(cls._permission(stripped, "runtime"))
continue
if not package_name:
simple_match = re.match(
r"(appId|userId|versionName|timeStamp|lastUpdateTime|installerPackageName)=(.*)",
stripped,
)
if simple_match:
key = {
"appId": "app_id",
"userId": "app_id",
"versionName": "version_name",
"timeStamp": "timestamp",
"lastUpdateTime": "last_update_time",
"installerPackageName": "installer",
}[simple_match.group(1)]
raw_value = simple_match.group(2)
details[key] = (
_value(raw_value)
if key == "app_id" or raw_value == "null"
else raw_value
)
continue
if stripped.startswith("pkgFlags="):
details["system"] = "SYSTEM" in stripped.split("=", 1)[1].split()
continue
version_match = re.match(
r"versionCode=([^\s]+)(?:\s+minSdk=([^\s]+))?(?:\s+targetSdk=([^\s]+))?",
stripped,
)
if version_match:
details["version_code"] = _value(version_match.group(1))
details["min_sdk"] = (
_value(version_match.group(2)) if version_match.group(2) else None
)
details["target_sdk"] = (
_value(version_match.group(3)) if version_match.group(3) else None
)
elif stripped.startswith("firstInstallTime="):
legacy_first_install = stripped.split("=", 1)[1]
lines.append(line)
if legacy_first_install:
user_zero = next(
(user for user in details["users"] if user["user_id"] == 0), None
)
if user_zero is None:
user_zero = {"user_id": 0, "permissions": []}
details["users"].append(user_zero)
user_zero.setdefault("first_install_time", legacy_first_install)
return details
if len(lines) > 0:
details = self.parse_dumpsys_package_for_details("\n".join(lines))
package.update(details)
results.append(package)
def parse(self, content: str) -> None:
self.results: list[dict[str, Any]] = []
category: str | None = None
package: dict[str, Any] | None = None
block: list[str] = []
return results
def finish() -> None:
nonlocal package, block
if package is not None:
package.update(self.parse_dumpsys_package_for_details("\n".join(block)))
self.results.append(package)
package = None
block = []
def parse(self, content: str):
"""
Parse the Dumpsys Package section for activities
Adds results to self.results
:param content: content of the package section (string)
"""
self.results = []
package = []
in_package_list = False
for line in content.splitlines():
if line.startswith("Packages:"):
in_package_list = True
if line == "Packages:":
finish()
category = "active"
continue
if not in_package_list:
if line == "Hidden system packages:":
finish()
category = "hidden_system"
continue
if line.strip() == "":
break
package.append(line)
self.results = self.parse_dumpsys_packages("\n".join(package))
package_match = re.match(r"^ Package \[([^]]+)\]", line)
if package_match and category:
finish()
package = {
"package_name": package_match.group(1),
"package_type": category,
}
continue
if (
category
and line
and not line.startswith(" ")
and not line.endswith(" overlay paths:")
):
finish()
category = None
continue
if package is not None:
block.append(line)
finish()
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import re
from .artifact import AndroidArtifact
@@ -25,19 +27,37 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
def parse(self, data: str) -> None:
for line in data.splitlines():
if not line.startswith("ChangeId(168419799; name=DOWNSCALED;"):
match = re.match(r"ChangeId\((\d+);\s*(.*)\)$", line.strip())
if not match or "rawOverrides={" not in line:
continue
if line.strip() == "":
break
# Look for rawOverrides field
if "rawOverrides={" in line:
# Extract the content inside the braces for rawOverrides
overrides_field = line.split("rawOverrides={", 1)[1].split("};", 1)[0]
for entry in overrides_field.split(", "):
# Extract app name
uninstall_app = entry.split("=")[0].strip()
self.results.append({"package_name": uninstall_app})
body = match.group(2)
name_match = re.search(r"(?:^|;\s*)name=([^;]+)", body)
state = (
"enabled"
if re.search(r"(?:^|;\s*)enabled(?:;|$)", body)
else "disabled"
)
overridable = bool(re.search(r"(?:^|;\s*)overridable(?:;|$)", body))
overrides_field = body.split("rawOverrides={", 1)[1].split("}", 1)[0]
for entry in overrides_field.split(","):
package_name, separator, raw_value = entry.strip().partition("=")
if not separator:
continue
value: bool | int | str
if raw_value in ("true", "false"):
value = raw_value == "true"
else:
try:
value = int(raw_value)
except ValueError:
value = raw_value
self.results.append(
{
"change_id": int(match.group(1)),
"change_name": name_match.group(1) if name_match else None,
"change_state": state,
"overridable": overridable,
"package_name": package_name,
"override_value": value,
}
)
+39 -108
View File
@@ -4,6 +4,7 @@
# https://license.mvt.re/1.1/
from .artifact import AndroidArtifact
from .package_resolvers import parse_resolver_table
INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS"
INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED"
@@ -18,115 +19,45 @@ class DumpsysReceiversArtifact(AndroidArtifact):
"""
def check_indicators(self) -> None:
for intent, receivers in self.results.items():
for receiver in receivers:
if intent == INTENT_NEW_OUTGOING_SMS:
self.log.info(
'Found a receiver to intercept outgoing SMS messages: "%s"',
receiver["receiver"],
)
elif intent == INTENT_SMS_RECEIVED:
self.log.info(
'Found a receiver to intercept incoming SMS messages: "%s"',
receiver["receiver"],
)
elif intent == INTENT_DATA_SMS_RECEIVED:
self.log.info(
'Found a receiver to intercept incoming data SMS message: "%s"',
receiver["receiver"],
)
elif intent == INTENT_PHONE_STATE:
self.log.info(
"Found a receiver monitoring "
'telephony state/incoming calls: "%s"',
receiver["receiver"],
)
elif intent == INTENT_NEW_OUTGOING_CALL:
self.log.info(
'Found a receiver monitoring outgoing calls: "%s"',
receiver["receiver"],
)
for receiver in self.results:
intent = receiver["key"]
if intent == INTENT_NEW_OUTGOING_SMS:
self.log.info(
'Found a receiver to intercept outgoing SMS messages: "%s"',
receiver["component"],
)
elif intent == INTENT_SMS_RECEIVED:
self.log.info(
'Found a receiver to intercept incoming SMS messages: "%s"',
receiver["component"],
)
elif intent == INTENT_DATA_SMS_RECEIVED:
self.log.info(
'Found a receiver to intercept incoming data SMS message: "%s"',
receiver["component"],
)
elif intent == INTENT_PHONE_STATE:
self.log.info(
'Found a receiver monitoring telephony state/incoming calls: "%s"',
receiver["component"],
)
elif intent == INTENT_NEW_OUTGOING_CALL:
self.log.info(
'Found a receiver monitoring outgoing calls: "%s"',
receiver["component"],
)
if not self.indicators:
continue
if not self.indicators:
continue
ioc_match = self.indicators.check_app_id(receiver["package_name"])
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
{intent: receiver},
matched_indicator=ioc_match.ioc,
)
continue
ioc_match = self.indicators.check_app_id(receiver["package_name"])
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
receiver,
matched_indicator=ioc_match.ioc,
)
def parse(self, output: str) -> None:
self.results: dict[str, list[dict[str, str]]] = {}
in_receiver_resolver_table = False
in_non_data_actions = False
intent = None
for line in output.splitlines():
if line.startswith("Receiver Resolver Table:"):
in_receiver_resolver_table = True
continue
if not in_receiver_resolver_table:
continue
if line.startswith(" Non-Data Actions:"):
in_non_data_actions = True
continue
if not in_non_data_actions:
continue
# If we hit an empty line, the Non-Data Actions section should be
# finished.
if line.strip() == "":
break
# We detect the action name.
if (
line.startswith(" " * 6)
and not line.startswith(" " * 8)
and ":" in line
):
intent = line.strip().replace(":", "")
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
# If we are in a block but the line does not start with 8 spaces
# it means the block ended a new one started, so we reset and
# continue.
if not line.startswith(" " * 8):
intent = None
continue
# If we got this far, we are processing receivers for the
# activities we are interested in.
receiver = parts[1]
package_name = receiver.split("/")[0]
self.results[intent].append(
{
"package_name": package_name,
"receiver": receiver,
}
)
self.results = parse_resolver_table(output, "Receiver")
+4 -4
View File
@@ -28,18 +28,18 @@ INTERESTING_PROPERTIES = [
class GetProp(AndroidArtifact):
def parse(self, entry: str) -> None:
self.results: List[Dict[str, str]] = []
rxp = re.compile(r"\[(.+?)\]: \[(.+?)\]")
rxp = re.compile(r"^\[([^]]+)\]: \[(.*)\]$")
for line in entry.splitlines():
line = line.strip()
if line == "":
continue
matches = re.findall(rxp, line)
if not matches or len(matches[0]) != 2:
match = rxp.match(line)
if not match:
continue
prop_entry = {"name": matches[0][0], "value": matches[0][1]}
prop_entry = {"name": match.group(1), "value": match.group(2)}
self.results.append(prop_entry)
def get_device_timezone(self) -> str | None:
+45
View File
@@ -117,6 +117,51 @@ class Mounts(AndroidArtifact):
# Skip lines that don't match expected format
continue
@staticmethod
def parse_mountinfo(entry: str, process_id: int) -> list[dict[str, Any]]:
"""Parse Linux /proc/PID/mountinfo records."""
results = []
for line in entry.splitlines():
fields = line.split()
if "-" not in fields:
continue
separator = fields.index("-")
if separator < 6 or len(fields) < separator + 4:
continue
try:
mount_id = int(fields[0])
parent_id = int(fields[1])
except ValueError:
continue
mount_options = fields[5].split(",")
super_options = fields[separator + 3].split(",")
options = list(dict.fromkeys(mount_options + super_options))
mount_point = fields[4].replace("\\040", " ")
device = fields[separator + 2].replace("\\040", " ")
filesystem_type = fields[separator + 1]
is_system = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
mount_point.startswith(f"{prefix}/")
for prefix in SUSPICIOUS_MOUNT_POINTS
)
results.append(
{
"mount_id": mount_id,
"parent_id": parent_id,
"major_minor": fields[2],
"root": fields[3].replace("\\040", " "),
"mount_point": mount_point,
"device": device,
"filesystem_type": filesystem_type,
"mount_options": ",".join(options),
"options_list": options,
"optional_fields": fields[6:separator],
"is_system_partition": is_system,
"is_read_write": "rw" in options,
"process_ids": [process_id],
}
)
return results
def check_indicators(self) -> None:
"""
Check for suspicious mount configurations that may indicate root access
@@ -0,0 +1,70 @@
# 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 re
RESOLVER_TYPES = {
"Full MIME Types": "full_mime_type",
"Base MIME Types": "base_mime_type",
"Wild MIME Types": "wild_mime_type",
"Schemes": "scheme",
"Non-Data Actions": "non_data_action",
"MIME Typed Actions": "mime_typed_action",
}
ENTRY_RE = re.compile(
r"^\s{8,}[0-9a-fA-F]+\s+(?P<component>\S+)"
r"(?:\s+\((?P<filters>\d+)\s+filters?\))?\s*$"
)
def parse_resolver_table(content: str, table_name: str) -> list[dict]:
"""Parse every resolver category from a dumpsys package table."""
results: list[dict] = []
in_table = False
resolver_type: str | None = None
key: str | None = None
for line in content.splitlines():
if line.startswith(f"{table_name} Resolver Table:"):
in_table = True
continue
if not in_table:
continue
if line and not line.startswith(" "):
break
section_match = re.match(r"^ {2}([^ ].*):\s*$", line)
if section_match:
resolver_type = RESOLVER_TYPES.get(section_match.group(1))
key = None
continue
if resolver_type is None:
continue
key_match = re.match(r"^ {6,}([^ ].*):\s*$", line)
if key_match:
key = key_match.group(1)
continue
if key is None:
continue
entry_match = ENTRY_RE.match(line)
if not entry_match:
continue
component = entry_match.group("component")
results.append(
{
"resolver_type": resolver_type,
"key": key,
"package_name": component.split("/", 1)[0],
"component": component,
"filter_count": int(entry_match.group("filters") or 1),
}
)
return results
+73 -54
View File
@@ -1,72 +1,91 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# 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/
from .artifact import AndroidArtifact
FIELD_NAMES = {
"LABEL": "label",
"USER": "user",
"PID": "pid",
"TID": "tid",
"PPID": "ppid",
"VSZ": "virtual_memory_size",
"RSS": "resident_set_size",
"WCHAN": "wchan",
"ADDR": "address",
"S": "state",
"PRI": "priority",
"NI": "nice",
"RTPRIO": "realtime_priority",
"SCH": "scheduler",
"PCY": "policy",
"TIME": "cpu_time",
"CMD": "command",
"NAME": "command",
}
INTEGER_FIELDS = {
"pid",
"tid",
"ppid",
"virtual_memory_size",
"resident_set_size",
"priority",
"nice",
}
class Processes(AndroidArtifact):
def parse(self, entry: str) -> None:
for line in entry.splitlines()[1:]:
proc = line.split()
self.results = []
lines = [line for line in entry.splitlines() if line.strip()]
if not lines:
return
headers = lines[0].split()
if not all(header in FIELD_NAMES for header in headers):
return
# Skip empty lines
if len(proc) == 0:
for line in lines[1:]:
values = line.split(None, len(headers) - 1)
if len(values) != len(headers):
continue
# Sometimes WCHAN is empty.
if len(proc) == 8:
proc = proc[:5] + [""] + proc[5:]
# Sometimes there is the security label.
if proc[0].startswith("u:r"):
label = proc[0]
proc = proc[1:]
else:
label = ""
# Sometimes there is no WCHAN.
if len(proc) < 9:
proc = proc[:5] + [""] + proc[5:]
self.results.append(
{
"user": proc[0],
"pid": int(proc[1]),
"ppid": int(proc[2]),
"virtual_memory_size": int(proc[3]),
"resident_set_size": int(proc[4]),
"wchan": proc[5],
"aprocress": proc[6],
"stat": proc[7],
"proc_name": proc[8].strip("[]"),
"label": label,
}
)
result = {}
valid = True
for header, raw in zip(headers, values):
key = FIELD_NAMES[header]
value: str | int = raw.strip("[]") if key == "command" else raw
if key in INTEGER_FIELDS:
try:
value = int(value)
except ValueError:
valid = False
break
result[key] = value
if valid:
self.results.append(result)
def check_indicators(self) -> None:
if not self.indicators:
return
for result in self.results:
proc_name = result.get("proc_name", "")
if not proc_name:
command = result.get("command", "")
if not isinstance(command, str):
continue
# Skipping this process because of false positives.
if result["proc_name"] == "gatekeeperd":
process_name = command.rsplit("/", 1)[-1]
if not process_name or process_name == "gatekeeperd":
continue
ioc_match = self.indicators.check_app_id(proc_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
continue
ioc_match = self.indicators.check_process(proc_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
for checker in (
self.indicators.check_app_id,
self.indicators.check_process,
):
ioc_match = checker(process_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
result,
matched_indicator=ioc_match.ioc,
)
break
+24
View File
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import re
from .artifact import AndroidArtifact
ANDROID_DANGEROUS_SETTINGS = [
@@ -60,6 +62,28 @@ ANDROID_DANGEROUS_SETTINGS = [
class Settings(AndroidArtifact):
def parse(self, content: str) -> None:
self.results: dict[str, dict[str, str]] = {}
namespace: str | None = None
for line in content.splitlines():
heading = re.match(
r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$",
line.strip(),
)
if heading:
namespace = f"{heading.group(1).lower()}:user_{heading.group(2)}"
self.results[namespace] = {}
continue
if namespace is None or not line.startswith("_id:"):
continue
setting = re.match(
r"^_id:\S+\s+name:(.*?)\s+pkg:.*?\s+value:(.*?)"
r"(?:\s+default:.*\s+defaultSystemSet:(?:true|false))?$",
line,
)
if setting:
self.results[namespace][setting.group(1)] = setting.group(2)
def check_indicators(self) -> None:
for namespace, settings in self.results.items():
for key, value in settings.items():
+28 -7
View File
@@ -34,6 +34,7 @@ TOMBSTONE_TEXT_KEY_MAPPINGS = {
"signal": "signal_info",
"code": "code",
"Cause": "cause",
"Abort message": "abort_message",
}
@@ -67,6 +68,8 @@ class TombstoneCrashResult(pydantic.BaseModel):
uid: int
signal_info: SignalInfo
cause: Optional[str] = None
causes: Optional[List[dict]] = None
abort_message: Optional[str] = None
extra: Optional[str] = None
@@ -128,6 +131,13 @@ class TombstoneCrashArtifact(AndroidArtifact):
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> None:
"""Parse Android tombstone crash files from a protobuf object."""
self.results.append(self.parse_protobuf_record(file_name, file_timestamp, data))
def parse_protobuf_record(
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> dict:
if not data:
raise ValueError("empty protobuf tombstone")
tombstone_pb = Tombstone().parse(data)
tombstone_dict = tombstone_pb.to_dict(
casing=betterproto2.Casing.SNAKE, include_default_values=True
@@ -140,20 +150,31 @@ class TombstoneCrashArtifact(AndroidArtifact):
tombstone_dict["file_name"] = file_name
tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp)
tombstone_dict["process_name"] = self._proccess_name_from_thread(tombstone_dict)
if isinstance(tombstone_dict.get("selinux_label"), str):
tombstone_dict["selinux_label"] = tombstone_dict["selinux_label"].rstrip(
"\x00"
)
# Confirm the tombstone is valid, and matches the output model
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
self.results.append(tombstone.model_dump())
return tombstone.model_dump()
def parse(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> None:
"""Parse text Android tombstone crash files."""
self.results.append(self.parse_text_record(file_name, file_timestamp, content))
def parse_text_record(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> dict:
if not content:
raise ValueError("empty plaintext tombstone")
tombstone_dict = {
"file_name": file_name,
"file_timestamp": convert_datetime_to_iso(file_timestamp),
}
lines = content.decode("utf-8").splitlines()
lines = content.decode("utf-8", errors="replace").splitlines()
for line_num, line in enumerate(lines, 1):
if not line.strip() or TOMBSTONE_DELIMITER in line:
continue
@@ -168,7 +189,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
# Validate the tombstone and add it to the results
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
self.results.append(tombstone.model_dump())
return tombstone.model_dump()
def _parse_tombstone_line(
self, line: str, key: str, destination_key: str, tombstone: dict
@@ -192,7 +213,9 @@ class TombstoneCrashArtifact(AndroidArtifact):
if line_key != key:
raise ValueError(f"Expected key {key}, got {line_key}")
value_clean = value.strip().strip("'")
value_clean = value.strip()
if len(value_clean) >= 2 and value_clean[0] == value_clean[-1] == "'":
value_clean = value_clean[1:-1]
if destination_key == "uid":
tombstone[destination_key] = int(value_clean)
elif destination_key == "process_uptime":
@@ -266,9 +289,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
@staticmethod
def _parse_timestamp_string(timestamp: str) -> str:
timestamp_parsed = parser.parse(timestamp)
# Preserve the source wall-clock time while returning the project-wide ISO format.
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
return convert_datetime_to_iso(local_timestamp)
return convert_datetime_to_iso(timestamp_parsed)
@staticmethod
def _proccess_name_from_thread(tombstone_dict: dict) -> str:
+14
View File
@@ -8,6 +8,12 @@ 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,
@@ -82,6 +88,7 @@ def _load_custom_modules(load_module):
# 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
)
@@ -505,3 +512,10 @@ def check_iocs(ctx, iocs, list_modules, module, load_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,
)
+45
View File
@@ -3,6 +3,7 @@
# 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
@@ -94,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)
@@ -220,6 +262,7 @@ class CmdAndroidCheckAndroidQF(Command):
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts)
finally:
if bugreport:
@@ -257,6 +300,7 @@ class CmdAndroidCheckAndroidQF(Command):
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts)
return True
@@ -336,6 +380,7 @@ class CmdAndroidCheckAndroidQF(Command):
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts)
return True
+1
View File
@@ -53,6 +53,7 @@ class CmdAndroidCheckBugreport(Command):
self.platform = "android"
self.name = "check-bugreport"
self.output_schema_version = 2
self.modules = BUGREPORT_MODULES
self.__format: str = ""
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .aqf_files import AQFFiles
from .aqf_getprop import AQFGetProp
from .aqf_log_timestamps import AQFLogTimestamps
@@ -12,7 +14,7 @@ from .aqf_settings import AQFSettings
from .mounts import Mounts
from .root_binaries import RootBinaries
ANDROIDQF_MODULES = [
ANDROIDQF_MODULES: list[type[MVTModule]] = [
AQFPackages,
AQFProcesses,
AQFGetProp,
+3 -1
View File
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .sms import SMS
BACKUP_MODULES = [SMS]
BACKUP_MODULES: list[type[MVTModule]] = [SMS]
+5
View File
@@ -57,6 +57,11 @@ class SMS(BackupModule):
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
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"
for file in self._get_files_by_pattern(sms_path):
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .dumpsys_accessibility import DumpsysAccessibility
from .dumpsys_activities import DumpsysActivities
from .dumpsys_appops import DumpsysAppops
@@ -16,8 +18,11 @@ from .dumpsys_receivers import DumpsysReceivers
from .dumpsys_adb_state import DumpsysADBState
from .fs_timestamps import BugReportTimestamps
from .tombstones import Tombstones
from .mounts import Mounts
from .processes import Processes
from .settings import Settings
BUGREPORT_MODULES = [
BUGREPORT_MODULES: list[type[MVTModule]] = [
DumpsysAccessibility,
DumpsysActivities,
DumpsysAppops,
@@ -31,4 +36,7 @@ BUGREPORT_MODULES = [
DumpsysADBState,
BugReportTimestamps,
Tombstones,
Processes,
Settings,
Mounts,
]
+26 -1
View File
@@ -72,7 +72,11 @@ class BugReportModule(MVTModule):
if not self.extract_path:
raise ValueError("extract_path is not set")
joined = os.path.join(self.extract_path, file_path)
if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()):
if (
not Path(joined)
.resolve()
.is_relative_to(Path(self.extract_path).resolve())
):
raise ValueError("unsafe file_path")
handle = open(joined, "rb")
@@ -100,6 +104,27 @@ class BugReportModule(MVTModule):
return None
@staticmethod
def extract_command_section(content: str, heading: str) -> str:
"""Return a bugreport command section without consuming the next one.
Bugreport separators include timing text, so looking for a line equal to
``------`` is not sufficient and can accidentally feed the remainder of
dumpstate to a parser.
"""
lines: list[str] = []
in_section = False
for line in content.splitlines():
stripped = line.strip()
if not in_section:
if stripped.startswith(heading):
in_section = True
continue
if stripped.startswith("------"):
break
lines.append(line)
return "\n".join(lines)
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
if self.zip_archive:
file_timetuple = self.zip_archive.getinfo(file_path).date_time
@@ -49,9 +49,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
self.parse(content)
for result in self.results:
self.log.info(
'Found installed accessibility service "%s"', result.get("service")
)
self.log.info('Found accessibility service "%s"', result.get("component"))
self.log.info(
"Identified a total of %d accessibility services", len(self.results)
@@ -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
@@ -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,
)
@@ -44,21 +44,8 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
)
return
lines = []
in_getprop = False
for line in content.decode(errors="ignore").splitlines():
if line.strip().startswith("------ SYSTEM PROPERTIES"):
in_getprop = True
continue
if not in_getprop:
continue
if line.strip() == "------":
break
lines.append(line)
self.parse("\n".join(lines))
section = self.extract_command_section(
content.decode(errors="ignore"), "------ SYSTEM PROPERTIES"
)
self.parse(section)
self.log.info("Extracted %d Android system properties", len(self.results))
@@ -50,7 +50,10 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
for result in self.results:
dangerous_permissions_count = 0
for perm in result["permissions"]:
permissions = list(result["permissions"])
for user in result.get("users", []):
permissions.extend(user.get("permissions", []))
for perm in permissions:
if perm["name"] in DANGEROUS_PERMISSIONS:
dangerous_permissions_count += 1
@@ -13,7 +13,7 @@ from mvt.common.module_types import ModuleResults
class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
"""This module extracts details on uninstalled apps."""
"""This module extracts raw per-package compatibility overrides."""
def __init__(
self,
@@ -48,4 +48,4 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
)
self.parse(content)
self.log.info("Found %d uninstalled apps", len(self.results))
self.log.info("Found %d package compatibility overrides", len(self.results))
@@ -33,7 +33,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
results=results,
)
self.results = results if results else {}
self.results = results if results else []
def run(self) -> None:
content = self._get_dumpstate_file()
@@ -49,4 +49,4 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
)
self.parse(dumpsys_section)
self.log.info("Extracted receivers for %d intents", len(self.results))
self.log.info("Extracted %d package receivers", len(self.results))
@@ -4,12 +4,15 @@
# https://license.mvt.re/1.1/
import logging
import datetime
from typing import Optional
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from mvt.common.utils import convert_datetime_to_iso
from .base import BugReportModule
from mvt.common.module_types import ModuleResults
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
from mvt.android.artifacts.getprop import GetProp
class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
@@ -38,15 +41,44 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
def run(self) -> None:
filesystem_files = self._get_files_by_pattern("FS/*")
timezone_name = None
dumpstate = self._get_dumpstate_file()
if dumpstate:
section = self.extract_command_section(
dumpstate.decode("utf-8", errors="replace"),
"------ SYSTEM PROPERTIES",
)
properties = GetProp()
properties.parse(section)
timezone_name = properties.get_device_timezone()
timezone = None
if timezone_name:
try:
timezone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
self.log.warning("Unknown device timezone %s", timezone_name)
self.results = []
for file in filesystem_files:
# Only the modification time is available in the zip file metadata.
# The timezone is the local timezone of the machine the phone.
modification_time = self._get_file_modification_time(file)
utc_time = None
if timezone is not None:
utc_time = convert_datetime_to_iso(
modification_time.replace(tzinfo=timezone).astimezone(
datetime.timezone.utc
)
)
self.results.append(
{
"path": file,
"modified_time": convert_datetime_to_iso(modification_time),
"modified_time_utc": utc_time,
"timezone": timezone_name,
"timestamp_source": (
"zip_metadata" if self.zip_archive else "filesystem_metadata"
),
}
)
@@ -0,0 +1,41 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
import re
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
from .base import BugReportModule
class Mounts(MountsArtifact, BugReportModule):
"""Extract and deduplicate process mount namespaces from mountinfo."""
def run(self) -> None:
mount_files = self._get_files_by_pattern("FS/proc/*/mountinfo")
if not mount_files:
mount_files = self._get_files_by_pattern("*/proc/*/mountinfo")
unique: dict[tuple, dict] = {}
for file_path in mount_files:
pid_match = re.search(r"/proc/(\d+)/mountinfo$", file_path)
if not pid_match:
continue
entries = self.parse_mountinfo(
self._get_file_content(file_path).decode("utf-8", errors="replace"),
int(pid_match.group(1)),
)
for entry in entries:
identity = (
entry["major_minor"],
entry["root"],
entry["mount_point"],
entry["device"],
entry["filesystem_type"],
entry["mount_options"],
)
if identity in unique:
unique[identity]["process_ids"].extend(entry["process_ids"])
else:
unique[identity] = entry
self.results = list(unique.values())
self.log.info("Extracted %d unique mount records", len(self.results))
@@ -0,0 +1,22 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
from mvt.android.artifacts.processes import Processes as ProcessesArtifact
from .base import BugReportModule
class Processes(ProcessesArtifact, BugReportModule):
"""Extract the process and thread table from dumpstate."""
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
self.log.error("Unable to find dumpstate file")
return
section = self.extract_command_section(
data.decode("utf-8", errors="replace"),
"------ PROCESSES AND THREADS",
)
self.parse(section)
self.log.info("Identified %d running process threads", len(self.results))
@@ -0,0 +1,22 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
from mvt.android.artifacts.settings import Settings as SettingsArtifact
from .base import BugReportModule
class Settings(SettingsArtifact, BugReportModule):
"""Extract all SettingsProvider namespaces and users."""
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
self.log.error("Unable to find dumpstate file")
return
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:"
)
self.parse(section)
count = sum(len(settings) for settings in self.results.values())
self.log.info("Identified %d Android settings", count)
+68 -13
View File
@@ -43,21 +43,76 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
)
return
for tombstone_file in sorted(tombstone_files):
tombstone_filename = tombstone_file.split("/")[-1]
modification_time = self._get_file_modification_time(tombstone_file)
tombstone_data = self._get_file_content(tombstone_file)
grouped: dict[str, dict[str, str]] = {}
for file_path in tombstone_files:
file_name = file_path.rsplit("/", 1)[-1]
source = "protobuf" if file_name.endswith(".pb") else "text"
crash_id = file_name.removesuffix(".pb")
grouped.setdefault(crash_id, {})[source] = file_path
try:
if tombstone_file.endswith(".pb"):
self.parse_protobuf(
tombstone_filename, modification_time, tombstone_data
for crash_id, paths in sorted(grouped.items()):
parsed_sources: dict[str, dict] = {}
source_records: dict[str, dict] = {}
for source in ("text", "protobuf"):
file_path = paths.get(source)
if file_path is None:
continue
file_name = file_path.rsplit("/", 1)[-1]
file_timestamp = self._get_file_modification_time(file_path)
source_info = {
"file_name": file_name,
"file_timestamp": file_timestamp.isoformat(),
"parsed": False,
"error": None,
"record": None,
}
try:
data = self._get_file_content(file_path)
if source == "protobuf":
record = self.parse_protobuf_record(
file_name, file_timestamp, data
)
else:
record = self.parse_text_record(file_name, file_timestamp, data)
source_info["parsed"] = True
source_info["record"] = record
source_records[source] = record
except Exception as exc:
source_info["error"] = str(exc)
self.log.error(
"Error parsing tombstone file %s: %s", file_path, exc
)
else:
self.parse(tombstone_filename, modification_time, tombstone_data)
except ValueError as e:
# Catch any exceptions raised during parsing or validation.
self.log.error(f"Error parsing tombstone file {tombstone_file}: {e}")
parsed_sources[source] = source_info
if not source_records:
continue
preferred = source_records.get("protobuf") or source_records["text"]
canonical = dict(preferred)
text_record = source_records.get("text")
if text_record:
for key, value in text_record.items():
if canonical.get(key) in (None, "", [], {}):
canonical[key] = value
differences = {}
protobuf_record = source_records.get("protobuf")
if text_record and protobuf_record:
for key in text_record.keys() & protobuf_record.keys():
if key in ("file_name", "file_timestamp"):
continue
if text_record[key] != protobuf_record[key]:
differences[key] = {
"text": text_record[key],
"protobuf": protobuf_record[key],
}
canonical.update(
{
"crash_id": crash_id,
"sources": parsed_sources,
"differences": differences,
}
)
self.results.append(canonical)
self.log.info(
"Extracted a total of %d tombstone files",
@@ -3,11 +3,13 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .connect_event import ConnectEvent
from .dns_event import DnsEvent
from .security_event import SecurityEvent
INTRUSION_LOGS_MODULES = [
INTRUSION_LOGS_MODULES: list[type[MVTModule]] = [
DnsEvent,
ConnectEvent,
SecurityEvent,
+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)"
),
)
+2 -1
View File
@@ -9,6 +9,7 @@ from typing import Optional
from mvt.common.command import Command
from mvt.common.module import MVTModule
from mvt.common.module_loader import get_module_logger
from mvt.common.utils import exec_or_profile
log = logging.getLogger(__name__)
@@ -76,7 +77,7 @@ class CmdCheckIOCS(Command):
)
m = iocs_module.from_json(
file_path, log=logging.getLogger(iocs_module.__module__)
file_path, log=get_module_logger(iocs_module)
)
if not m:
log.warning("No result from this module, skipping it")
+50 -5
View File
@@ -19,8 +19,13 @@ 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
from .module_loader import (
ModuleOrigin,
get_module_logger,
get_module_origin,
module_supports_command,
)
from .module_types import ModuleTimeline, URLResult
from .utils import (
CustomJSONEncoder,
convert_datetime_to_iso,
@@ -49,6 +54,7 @@ class Command:
) -> None:
self.name = ""
self.platform = ""
self.output_schema_version: Optional[int] = None
self.modules: list[type[MVTModule]] = []
self.custom_modules = custom_modules if custom_modules else []
@@ -73,6 +79,7 @@ class Command:
self.hashes = hashes
self.hash_values: list[dict[str, Any]] = []
self.timeline: ModuleTimeline = []
self.url_results: list[URLResult] = []
# Load IOCs
self._create_storage()
@@ -150,6 +157,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
@@ -172,6 +187,8 @@ class Command:
"ioc_files": [],
"hashes": [],
}
if self.output_schema_version is not None:
info["output_schema_version"] = self.output_schema_version
for coll in self.iocs.ioc_collections:
ioc_file_path = coll.get("stix2_file_path", "")
@@ -201,10 +218,34 @@ class Command:
for file in generate_hashes_from_path(self.target_path, self.log):
self.hash_values.append(file)
@staticmethod
def _modules_by_origin(
modules: list[type[MVTModule]],
) -> dict[ModuleOrigin, list[str]]:
origins: dict[ModuleOrigin, list[str]] = {}
for module in modules:
origins.setdefault(get_module_origin(module), []).append(module.__name__)
return origins
def list_modules(self) -> None:
self.log.info("Following is the list of available %s modules:", self.name)
for module in self._available_modules():
self.log.info(" - %s", module.__name__)
for origin, module_names in self._modules_by_origin(
self._available_modules()
).items():
self.log.info(
" - Modules from %s: %s", origin.label, ", ".join(module_names)
)
def _log_loaded_modules(self, modules: list[type[MVTModule]]) -> None:
"""Record the loaded modules and their origins for auditability."""
for origin, module_names in self._modules_by_origin(modules).items():
self.log.info(
"Loaded %d %s modules from %s: %s",
len(module_names),
self.name,
origin.label,
", ".join(module_names),
)
def _available_modules(self) -> list[type[MVTModule]]:
modules = list(self.modules)
@@ -351,6 +392,8 @@ class Command:
if ordered_modules is None:
return
self._log_loaded_modules(ordered_modules)
try:
self.init()
except NotImplementedError:
@@ -359,7 +402,7 @@ class Command:
executed_by_type: dict[type[MVTModule], MVTModule] = {}
for module in ordered_modules:
module_logger = logging.getLogger(module.__module__)
module_logger = get_module_logger(module)
m = module(
target_path=self.target_path,
@@ -396,6 +439,7 @@ class Command:
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:
@@ -410,4 +454,5 @@ class Command:
self._store_timeline()
self._store_alerts_timeline()
self._store_alerts()
self._store_urls()
self._store_info()
+1 -1
View File
@@ -8,7 +8,7 @@ HELP_MSG_VERSION = "Show the currently installed version of MVT"
HELP_MSG_OUTPUT = "Specify a path to a folder where you want to store JSON results"
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_LIST_MODULES = "Print list of available modules and their source, then 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 "
+14 -1
View File
@@ -49,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):
@@ -439,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
@@ -482,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.
+28
View File
@@ -18,6 +18,7 @@ from .module_types import (
ModuleResults,
ModuleSerializedResult,
ModuleTimeline,
URLResult,
)
from .utils import CustomJSONEncoder, exec_or_profile
@@ -82,6 +83,7 @@ class MVTModule:
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(
@@ -110,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
@@ -249,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:
+222 -5
View File
@@ -4,18 +4,28 @@
# https://license.mvt.re/1.1/
import hashlib
import importlib.metadata
import importlib.util
import inspect
import json
import logging
import os
import sys
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from types import ModuleType
from typing import Iterable, Optional
from .module import MVTModule
from .version import MVT_VERSION
MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES"
MODULES_ENTRY_POINT_GROUP = "mvt.modules"
EXTERNAL_LOGGER_NAMESPACE = "mvt.ext"
PLUGIN_PACKAGE_PREFIX = "mvt_plugin_"
_ORIGIN_ATTRIBUTE = "_mvt_module_origin"
_PATH_MODULE_PREFIX = "_mvt_custom_module_"
log = logging.getLogger(__name__)
@@ -23,15 +33,74 @@ class CustomModuleLoadError(Exception):
pass
@dataclass(frozen=True)
class ModuleOrigin:
"""Describes where a loaded module came from, for auditability.
``kind`` is one of ``builtin`` (shipped with MVT), ``package`` (loaded
from an installed package) or ``path`` (loaded from a file passed with
``--load-module`` or the environment variable).
"""
kind: str
name: str
version: Optional[str] = None
commit: Optional[str] = None
file_sha256: Optional[str] = None
@property
def label(self) -> str:
label = self.name
if self.version:
label += f"@{self.version}"
label = f"'{label}'"
if self.commit:
label += f" (commit {self.commit})"
if self.file_sha256:
label += f" (sha256: {self.file_sha256})"
return label
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}"
return f"{_PATH_MODULE_PREFIX}{path.stem}_{digest}"
def get_module_logger(module_class: type[MVTModule]) -> logging.Logger:
"""Return the logger a module's records should be emitted through.
Modules loaded from installed packages or file paths live outside the
"mvt" logger hierarchy, so their records would never reach the handlers
attached to the "mvt" logger and instead fall through to
logging.lastResort (which prints bare messages and drops anything below
WARNING). Their loggers are parented under the "mvt.ext" namespace,
keeping external module names from colliding with MVT's own logger
tree. File-path modules are named after their file instead of the
mangled internal import name, and packages following the recommended
"mvt_plugin_<name>" naming convention log under "mvt.ext.<name>".
"""
name = module_class.__module__
if name == "mvt" or name.startswith("mvt."):
return logging.getLogger(name)
if name.startswith(_PATH_MODULE_PREFIX):
name = Path(get_module_origin(module_class).name).stem
else:
top_level, separator, rest = name.partition(".")
if top_level.startswith(PLUGIN_PACKAGE_PREFIX) and len(top_level) > len(
PLUGIN_PACKAGE_PREFIX
):
name = top_level[len(PLUGIN_PACKAGE_PREFIX) :] + separator + rest
return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{name}")
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}")
raise CustomModuleLoadError(
f"Custom module file is not a Python file: {path}"
)
yield path
return
@@ -59,7 +128,9 @@ def _load_python_file(path: Path) -> ModuleType:
try:
spec.loader.exec_module(module)
except Exception as exc:
raise CustomModuleLoadError(f"Unable to import custom module {path}: {exc}") from exc
raise CustomModuleLoadError(
f"Unable to import custom module {path}: {exc}"
) from exc
return module
@@ -84,17 +155,156 @@ def load_custom_modules_from_path(path: str) -> list[type[MVTModule]]:
resolved_path = Path(path).expanduser().resolve()
for module_file in _iter_module_files(resolved_path):
file_sha256 = hashlib.sha256(module_file.read_bytes()).hexdigest()
loaded_module = _load_python_file(module_file)
origin = ModuleOrigin(
kind="path", name=str(module_file), file_sha256=file_sha256
)
for module_class in discover_mvt_modules(loaded_module):
key = (str(module_file), module_class.__qualname__)
if key in seen:
continue
seen.add(key)
setattr(module_class, _ORIGIN_ATTRIBUTE, origin)
custom_modules.append(module_class)
return custom_modules
def _module_key(module_class: type[MVTModule]) -> tuple[str, str]:
try:
source = str(Path(inspect.getfile(module_class)).resolve())
except (OSError, TypeError):
source = module_class.__module__
return (source, module_class.__qualname__)
def _distribution_commit(dist: importlib.metadata.Distribution) -> Optional[str]:
"""Return the VCS commit a distribution was installed from, if recorded.
Packages installed directly from a repository (``pip install git+...``)
record the commit in ``direct_url.json`` (PEP 610).
"""
try:
direct_url_text = dist.read_text("direct_url.json")
if not direct_url_text:
return None
commit = json.loads(direct_url_text).get("vcs_info", {}).get("commit_id")
return commit if isinstance(commit, str) else None
except Exception:
return None
def _entry_point_origin(entry_point: importlib.metadata.EntryPoint) -> ModuleOrigin:
name = entry_point.name
version = None
commit = None
# Manually constructed entry points have no associated distribution.
dist = getattr(entry_point, "dist", None)
if dist is not None:
try:
name = dist.name or name
version = dist.version
except Exception:
pass
commit = _distribution_commit(dist)
return ModuleOrigin(kind="package", name=name, version=version, commit=commit)
@lru_cache(maxsize=1)
def _packages_distributions() -> dict[str, list[str]]:
try:
return dict(importlib.metadata.packages_distributions())
except Exception:
return {}
def get_module_origin(module_class: type[MVTModule]) -> ModuleOrigin:
"""Return the origin of a module class for auditing purposes."""
origin = module_class.__dict__.get(_ORIGIN_ATTRIBUTE)
if isinstance(origin, ModuleOrigin):
return origin
top_level = module_class.__module__.partition(".")[0]
if top_level == "mvt":
return ModuleOrigin(kind="builtin", name="mvt", version=MVT_VERSION)
distributions = _packages_distributions().get(top_level)
if distributions:
name = distributions[0]
version = None
commit = None
try:
dist = importlib.metadata.distribution(name)
version = dist.version
commit = _distribution_commit(dist)
except Exception:
pass
return ModuleOrigin(kind="package", name=name, version=version, commit=commit)
try:
source = str(Path(inspect.getfile(module_class)).resolve())
except (OSError, TypeError):
source = module_class.__module__
return ModuleOrigin(kind="path", name=source)
def load_installed_modules() -> list[type[MVTModule]]:
"""Load MVT modules registered by installed packages.
Packages register modules in the ``mvt.modules`` entry-point group. Each
entry point must resolve to an iterable of MVTModule subclasses, or to a
callable which returns one. A broken entry point is skipped with a
warning so that a faulty plugin package cannot break MVT.
"""
try:
entry_points = importlib.metadata.entry_points(group=MODULES_ENTRY_POINT_GROUP)
except Exception as exc:
log.warning(
"Unable to discover installed module packages in entry-point group %s: %s",
MODULES_ENTRY_POINT_GROUP,
exc,
)
return []
installed_modules: list[type[MVTModule]] = []
ordered_entry_points = sorted(
entry_points, key=lambda entry_point: (entry_point.name, entry_point.value)
)
for entry_point in ordered_entry_points:
try:
loaded = entry_point.load()
if callable(loaded) and not isinstance(loaded, type):
loaded = loaded()
module_classes = list(loaded)
except (Exception, SystemExit) as exc:
log.warning(
"Unable to load modules from entry point %s (%s): %s",
entry_point.name,
entry_point.value,
exc,
)
continue
origin = _entry_point_origin(entry_point)
for module_class in module_classes:
if not (
isinstance(module_class, type) and issubclass(module_class, MVTModule)
):
log.warning(
"Entry point %s (%s) provided %r which is not an "
"MVTModule subclass",
entry_point.name,
entry_point.value,
module_class,
)
continue
setattr(module_class, _ORIGIN_ATTRIBUTE, origin)
installed_modules.append(module_class)
return installed_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)
@@ -105,10 +315,17 @@ def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTM
custom_modules: list[type[MVTModule]] = []
seen: set[tuple[str, str]] = set()
for module_class in load_installed_modules():
key = _module_key(module_class)
if key in seen:
continue
seen.add(key)
custom_modules.append(module_class)
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__)
key = _module_key(module_class)
if key in seen:
continue
seen.add(key)
+8 -1
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.
@@ -22,6 +22,13 @@ ModuleAtomicResult = Dict[str, Any]
ModuleResults = Any
class URLResult(TypedDict):
url: str
expanded_url: Optional[str]
timestamp: Optional[str]
source: str
@dataclass
class ModuleAtomicTimeline:
timestamp: str
+7
View File
@@ -239,6 +239,13 @@ def init_logging(verbose: bool = False):
"""
log = logging.getLogger("mvt")
log.setLevel(logging.DEBUG)
# Importing an MVT CLI module calls init_logging() at import time, and
# loaded module packages may import one indirectly. Keep this idempotent
# so console log lines are not duplicated by a second handler.
if any(isinstance(handler, MVTLogHandler) for handler in log.handlers):
return
consoleHandler = MVTLogHandler()
consoleHandler.setFormatter(logging.Formatter("%(message)s"))
if verbose:
+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.6.29"
MVT_VERSION = "2026.7.29"
+14
View File
@@ -8,6 +8,12 @@ import logging
import os
import click
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,
@@ -86,6 +92,7 @@ def _load_custom_modules(load_module):
# 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
)
@@ -523,3 +530,10 @@ def check_iocs(ctx, iocs, list_modules, module, load_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,
)
+66 -12
View File
@@ -6,8 +6,10 @@
import json
import logging
import os
import shutil
import tarfile
from pathlib import Path
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from typing import Any, Optional
from mvt.common.command import Command
@@ -54,6 +56,8 @@ class CmdIOSCheckSysdiagnose(Command):
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
self.sysdiagnose_files: list[str] = []
self.ips_files: list[dict[str, Any]] = []
self.temp_sysdiagnose_dir: Optional[TemporaryDirectory[str]] = None
self.extracted_sysdiagnose_path: Optional[str] = None
@staticmethod
def _parse_bugtype_header(data: bytes) -> Optional[int]:
@@ -91,21 +95,68 @@ class CmdIOSCheckSysdiagnose(Command):
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())
self._extract_sysdiagnose_archive()
def _extract_sysdiagnose_archive(self) -> None:
archive = self.sysdiagnose_archive
if archive is None:
raise RuntimeError("Sysdiagnose archive has not been initialized")
self.temp_sysdiagnose_dir = TemporaryDirectory()
extraction_root = Path(self.temp_sysdiagnose_dir.name).resolve()
archive_roots = set()
for member in archive:
member_path = PurePosixPath(member.name.replace("\\", "/"))
if member_path.is_absolute() or ".." in member_path.parts:
self.log.warning("Skipping unsafe sysdiagnose path %r", member.name)
continue
destination = extraction_root.joinpath(*member_path.parts).resolve()
if not destination.is_relative_to(extraction_root):
self.log.warning("Skipping unsafe sysdiagnose path %r", member.name)
continue
if not member_path.parts:
continue
archive_roots.add(member_path.parts[0])
if member.isdir():
destination.mkdir(parents=True, exist_ok=True)
continue
# Modules only need directories and regular files. Do not materialize
# links or device nodes from an untrusted sysdiagnose archive.
if not member.isfile():
self.log.warning("Skipping unsafe sysdiagnose member %r", member.name)
continue
normalized_name = member_path.as_posix()
self.sysdiagnose_files.append(normalized_name)
source = archive.extractfile(member)
if source is None:
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
if normalized_name.endswith(".ips"):
self._add_ips_file(str(destination), destination.read_bytes())
if len(archive_roots) != 1:
raise ValueError("Sysdiagnose archive must contain one top-level directory")
self.extracted_sysdiagnose_path = str(extraction_root / archive_roots.pop())
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
if self.extracted_sysdiagnose_path is None:
raise RuntimeError("Sysdiagnose archive has not been extracted")
module.from_sysdiagnose_folder(
self.extracted_sysdiagnose_path, self.sysdiagnose_files
)
return
if self.sysdiagnose_format == "dir" and self.target_path:
@@ -117,3 +168,6 @@ class CmdIOSCheckSysdiagnose(Command):
if self.sysdiagnose_archive is not None:
self.sysdiagnose_archive.close()
self.sysdiagnose_archive = None
if self.temp_sysdiagnose_dir is not None:
self.temp_sysdiagnose_dir.cleanup()
self.temp_sysdiagnose_dir = None
+16
View File
@@ -1216,6 +1216,14 @@
"version": "18.7.9",
"build": "22H355"
},
{
"version": "18.7.10",
"build": "22H373"
},
{
"version": "18.7.10",
"build": "22H374"
},
{
"version": "26",
"build": "23A341"
@@ -1255,5 +1263,13 @@
{
"version": "26.5",
"build": "23F77"
},
{
"version": "26.6.1",
"build": "23G82"
},
{
"version": "26.6.1",
"build": "23G83"
}
]
+8 -1
View File
@@ -3,9 +3,16 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .backup_info import BackupInfo
from .configuration_profiles import ConfigurationProfiles
from .manifest import Manifest
from .profile_events import ProfileEvents
BACKUP_MODULES = [BackupInfo, ConfigurationProfiles, Manifest, ProfileEvents]
BACKUP_MODULES: list[type[MVTModule]] = [
BackupInfo,
ConfigurationProfiles,
Manifest,
ProfileEvents,
]
+3 -1
View File
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .analytics import Analytics
from .analytics_ios_versions import AnalyticsIOSVersions
from .cache_files import CacheFiles
@@ -15,7 +17,7 @@ from .webkit_indexeddb import WebkitIndexedDB
from .webkit_localstorage import WebkitLocalStorage
from .webkit_safariviewservice import WebkitSafariViewService
FS_MODULES = [
FS_MODULES: list[type[MVTModule]] = [
CacheFiles,
Filesystem,
Netusage,
+5 -1
View File
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .applications import Applications
from .calendar import Calendar
from .calls import Calls
@@ -26,8 +28,9 @@ from .tcc import TCC
from .webkit_resource_load_statistics import WebkitResourceLoadStatistics
from .webkit_session_resource_log import WebkitSessionResourceLog
from .whatsapp import Whatsapp
from .whatsapp_contacts import WhatsappContacts
MIXED_MODULES = [
MIXED_MODULES: list[type[MVTModule]] = [
Calls,
ChromeFavicon,
ChromeHistory,
@@ -47,6 +50,7 @@ MIXED_MODULES = [
WebkitResourceLoadStatistics,
WebkitSessionResourceLog,
Whatsapp,
WhatsappContacts,
Shortcuts,
Applications,
Calendar,
+3 -1
View File
@@ -73,7 +73,9 @@ class Calls(IOSExtraction):
"duration": row[1],
"location": row[2],
"number": (
row[3].decode("utf-8") if row[3] and row[3] is bytes else row[3]
row[3].decode("utf-8")
if isinstance(row[3], bytes)
else row[3]
),
"provider": row[4],
}
+231 -9
View File
@@ -3,9 +3,11 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import datetime
import logging
import re
import sqlite3
from typing import Optional
from typing import Optional, Tuple
from mvt.common.module_types import (
ModuleAtomicResult,
@@ -15,6 +17,7 @@ from mvt.common.module_types import (
from mvt.common.utils import convert_mactime_to_iso
from ..base import IOSExtraction
from .whatsapp_contacts import WhatsappContacts
INTERACTIONC_BACKUP_IDS = [
"1f5a521220a3ad80ebfdc196978df8e7a2e49dee",
@@ -22,6 +25,49 @@ INTERACTIONC_BACKUP_IDS = [
INTERACTIONC_ROOT_PATHS = [
"private/var/mobile/Library/CoreDuet/People/interactionC.db",
]
# The interaction record's creation date normally trails its start date by
# milliseconds: emitting it as a timeline event only duplicates the start
# date event. A large divergence, however, indicates the record was
# backfilled (sync, restore, or tampering) and is worth surfacing.
CREATION_DATE_DIVERGENCE_THRESHOLD = 3600.0
# Per-contact aggregate dates from ZCONTACTS are repeated on every
# interaction row of the same contact. They are serialized with a
# contact-centric data string so that timeline de-duplication collapses
# them into one event per contact.
CONTACT_EVENT_TEMPLATES = {
"contacts_creation_date": "Contact {party} first recorded in interactionC",
"first_incoming_sender_date": "First incoming interaction from {party}",
"last_incoming_sender_date": "Last incoming interaction from {party}",
"first_incoming_recipient_date": (
"First incoming interaction where {party} was a recipient"
),
"last_incoming_recipient_date": (
"Last incoming interaction where {party} was a recipient"
),
"first_outgoing_recipient_date": (
"First outgoing interaction to {party}"
),
"last_outgoing_recipient_date": (
"Last outgoing interaction to {party}"
),
}
def _parse_iso(timestamp) -> Optional[datetime.datetime]:
try:
return datetime.datetime.strptime(
timestamp, "%Y-%m-%d %H:%M:%S.%f"
)
except (TypeError, ValueError):
return None
def _describe_delta(seconds: float) -> str:
if seconds >= 86400:
return f"{seconds / 86400:.0f} days"
return f"{seconds / 3600:.0f} hours"
# Taken from APOLLO
# https://github.com/mac4n6/APOLLO/blob/master/modules/interaction_contact_interactions.txt
QUERIES = [
@@ -34,7 +80,7 @@ QUERIES = [
CASE ZINTERACTIONS.ZDIRECTION
WHEN '0' THEN 'INCOMING'
WHEN '1' THEN 'OUTGOING'
END 'DIRECTION' AS "direction",
END AS "direction",
ZCONTACTS.ZDISPLAYNAME AS "sender_display_name",
ZCONTACTS.ZIDENTIFIER AS "sender_identifier",
ZCONTACTS.ZPERSONID AS "sender_personid",
@@ -89,7 +135,7 @@ QUERIES = [
CASE ZINTERACTIONS.ZDIRECTION
WHEN '0' THEN 'INCOMING'
WHEN '1' THEN 'OUTGOING'
END 'DIRECTION' AS "direction",
END AS "direction",
ZCONTACTS.ZDISPLAYNAME AS "sender_display_name",
ZCONTACTS.ZIDENTIFIER AS "sender_identifier",
ZCONTACTS.ZPERSONID AS "sender_personid",
@@ -117,7 +163,7 @@ QUERIES = [
CASE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE
WHEN '0' THEN '0'
ELSE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE
END 'LAST INCOMING RECIPIENT DATE' AS "last_incoming_recipient_date",
END AS "last_incoming_recipient_date",
ZCONTACTS.ZLASTOUTGOINGRECIPIENTDATE AS "last_outgoing_recipient_date",
ZCONTACTS.ZCUSTOMIDENTIFIER AS "custom_id",
ZINTERACTIONS.ZCONTENTURL AS "interaction_content_url",
@@ -218,9 +264,16 @@ QUERIES = [
]
WHATSAPP_BUNDLE_ID = "net.whatsapp.WhatsApp"
class InteractionC(IOSExtraction):
"""This module extracts data from InteractionC db."""
# WhatsApp identifies chat peers by LID in interactionC.db, which only the
# WhatsApp contacts database can map back to a phone number and name.
dependencies = [WhatsappContacts]
def __init__(
self,
file_path: Optional[str] = None,
@@ -252,10 +305,51 @@ class InteractionC(IOSExtraction):
"last_outgoing_recipient_date",
]
@staticmethod
def _describe_party(record: ModuleAtomicResult, prefix: str) -> Optional[str]:
name = record.get(f"{prefix}_display_name") or record.get(
f"{prefix}_resolved_name"
)
identifier = record.get(f"{prefix}_resolved_phone_number") or record.get(
f"{prefix}_identifier"
)
if name and identifier:
# A display name that is just a formatted copy of the phone
# number adds no information.
name_digits = re.sub(r"\D", "", name)
if name_digits and name_digits == re.sub(r"\D", "", identifier):
return identifier
return f"{name} ({identifier})"
return name or identifier or None
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
sender = self._describe_party(record, "sender")
# The chat peer from the domain identifier stands in when the
# recipient was not recorded (or the recipient join is unavailable).
recipient = self._describe_party(record, "recipient") or self._describe_party(
record, "domain"
)
direction = record.get("direction")
if not sender and direction == "OUTGOING":
sender = "local user"
if not recipient and direction == "INCOMING":
recipient = "local user"
header = f"[{record['bundle_id']}]"
if record.get("account"):
header += f" {record['account']}"
if direction:
header += f" {direction}"
data = f"{header} from {sender or 'unknown'} to {recipient or 'unknown'}"
if record.get("group_name"):
data += f" (group: {record['group_name']})"
if record.get("content"):
data += f": {record['content']}"
records = []
processed = []
for timestamp in self.timestamps:
for timestamp in ("start_date", "end_date"):
# Check if the record has the current timestamp.
if timestamp not in record or not record[timestamp]:
continue
@@ -269,16 +363,142 @@ class InteractionC(IOSExtraction):
"timestamp": record[timestamp],
"module": self.__class__.__name__,
"event": timestamp,
"data": f"[{record['bundle_id']}] {record['account']} - "
f"from {record['sender_display_name']} ({record['sender_identifier']}) "
f"to {record.get('recipient_display_name', '')} ({record.get('recipient_identifier', '')}):"
f" {record.get('content', '')}",
"data": data,
}
)
processed.append(record[timestamp])
creation_event = self._serialize_creation_date(record, data)
if creation_event:
records.append(creation_event)
# Contact-level aggregates describe the sender's contact record.
party = self._describe_party(record, "sender")
if party:
for field, template in CONTACT_EVENT_TEMPLATES.items():
if not record.get(field):
continue
records.append(
{
"timestamp": record[field],
"module": self.__class__.__name__,
"event": field,
"data": template.format(party=party),
}
)
return records
def _serialize_creation_date(
self, record: ModuleAtomicResult, data: str
) -> Optional[dict]:
"""Serialize the interaction record's creation date only when it
diverges from the start date enough to indicate the record was
backfilled."""
creation = record.get("interactions_creation_date")
if not creation:
return None
event = {
"timestamp": creation,
"module": self.__class__.__name__,
"event": "interactions_creation_date",
"data": data,
}
start = _parse_iso(record.get("start_date"))
creation_parsed = _parse_iso(creation)
if not start or not creation_parsed:
# Without a start date the creation date is the only anchor.
return event
delta = (creation_parsed - start).total_seconds()
if abs(delta) < CREATION_DATE_DIVERGENCE_THRESHOLD:
return None
direction = "after" if delta > 0 else "before"
event["data"] = (
f"Interaction record created {_describe_delta(abs(delta))} "
f"{direction} the event: {data}"
)
return event
def _whatsapp_contact_maps(self) -> Tuple[dict, dict]:
"""Build LID and phone-digit lookup maps from the WhatsappContacts
module results, when available."""
by_lid: dict = {}
by_phone: dict = {}
contacts_module = self.dependency_modules.get(WhatsappContacts)
if not contacts_module:
return by_lid, by_phone
for contact in contacts_module.results:
name = contact.get("full_name") or contact.get("given_name")
phone = contact.get("phone_number")
entry = (phone, name)
if contact.get("lid"):
by_lid[contact["lid"]] = entry
if phone:
by_phone[re.sub(r"\D", "", phone)] = entry
whatsapp_id = contact.get("whatsapp_id")
if whatsapp_id and "@" in whatsapp_id:
by_phone.setdefault(whatsapp_id.split("@")[0], entry)
return by_lid, by_phone
@staticmethod
def _resolve_whatsapp_identifier(
value, by_lid: dict, by_phone: dict
) -> Tuple[Optional[str], Optional[str]]:
"""Resolve a WhatsApp identifier (LID, JID or phone number) to a
(phone_number, contact_name) tuple."""
if not value:
return None, None
value = str(value)
if value.endswith("@lid"):
return by_lid.get(value, (None, None))
if value.endswith("@g.us"):
return None, None
if value.endswith("@s.whatsapp.net"):
digits = value.split("@")[0]
phone, name = by_phone.get(digits, (None, None))
return phone or f"+{digits}", name
if value.startswith("+"):
_, name = by_phone.get(re.sub(r"\D", "", value), (None, None))
return None, name
return None, None
def _postprocess_results(self) -> None:
by_lid, by_phone = self._whatsapp_contact_maps()
for entry in self.results:
# The fallback queries return ZDIRECTION raw instead of labelled.
if entry.get("direction") in (0, "0"):
entry["direction"] = "INCOMING"
elif entry.get("direction") in (1, "1"):
entry["direction"] = "OUTGOING"
if entry.get("bundle_id") != WHATSAPP_BUNDLE_ID:
continue
candidates = {
"sender": [entry.get("sender_identifier"), entry.get("custom_id")],
"recipient": [entry.get("recipient_identifier")],
"domain": [entry.get("domain_identifier")],
}
for prefix, values in candidates.items():
phone = name = None
for value in values:
phone, name = self._resolve_whatsapp_identifier(
value, by_lid, by_phone
)
if phone or name:
break
entry[f"{prefix}_resolved_phone_number"] = phone
entry[f"{prefix}_resolved_name"] = name
def run(self) -> None:
self._find_ios_database(
backup_ids=INTERACTIONC_BACKUP_IDS, root_paths=INTERACTIONC_ROOT_PATHS
@@ -325,4 +545,6 @@ class InteractionC(IOSExtraction):
cur.close()
conn.close()
self._postprocess_results()
self.log.info("Extracted a total of %d InteractionC events", len(self.results))
+5
View File
@@ -101,6 +101,11 @@ class SMS(IOSExtraction):
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
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:
self._find_ios_database(backup_ids=SMS_BACKUP_IDS, root_paths=SMS_ROOT_PATHS)
self.log.info("Found SMS database at path: %s", self.file_path)
+249 -3
View File
@@ -4,7 +4,9 @@
# https://license.mvt.re/1.1/
import logging
from typing import Optional
import os
import sqlite3
from typing import Dict, Optional
from mvt.common.module_types import (
ModuleAtomicResult,
@@ -22,9 +24,71 @@ WHATSAPP_ROOT_PATHS = [
"private/var/mobile/Containers/Shared/AppGroup/*/ChatStorage.sqlite",
]
WHATSAPP_LID_BACKUP_IDS = [
# SHA-1 of "AppDomainGroup-group.net.whatsapp.WhatsApp.shared-LID.sqlite"
"e794f6ffcc3c222535f47684a63d5178da3c4500",
]
WHATSAPP_LID_ROOT_PATHS = [
"private/var/mobile/Containers/Shared/AppGroup/*/LID.sqlite",
]
# WhatsApp records the mapping between a contact's LID and phone number
# identifiers in the ZWAPHONENUMBERLIDPAIR table. Depending on the WhatsApp
# version this lives in a dedicated LID.sqlite database or in
# ChatStorage.sqlite itself.
LID_PAIRS_QUERY = """
SELECT
ZLID AS "lid",
ZPHONENUMBER AS "phone_number",
ZTIMESTAMP AS "pair_timestamp"
FROM ZWAPHONENUMBERLIDPAIR;
"""
CHAT_SESSIONS_QUERY = """
SELECT
ZWACHATSESSION.Z_PK AS "session_pk",
ZWACHATSESSION.ZCONTACTJID AS "contact_jid",
ZWACHATSESSION.ZPARTNERNAME AS "partner_name",
ZWACHATSESSION.ZSESSIONTYPE AS "session_type",
ZWACHATSESSION.ZARCHIVED AS "archived",
ZWACHATSESSION.ZREMOVED AS "removed",
ZWACHATSESSION.ZMESSAGECOUNTER AS "message_counter",
ZWACHATSESSION.ZLASTMESSAGEDATE AS "last_message_date",
ZWAGROUPINFO.ZCREATIONDATE AS "group_creation_date",
MIN(ZWAMESSAGE.ZMESSAGEDATE) AS "first_stored_message_date",
MAX(ZWAMESSAGE.ZMESSAGEDATE) AS "last_stored_message_date",
COUNT(ZWAMESSAGE.Z_PK) AS "stored_message_count"
FROM ZWACHATSESSION
LEFT JOIN ZWAGROUPINFO
ON ZWACHATSESSION.ZGROUPINFO = ZWAGROUPINFO.Z_PK
LEFT JOIN ZWAMESSAGE
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
GROUP BY ZWACHATSESSION.Z_PK;
"""
CHAT_SESSION_DATE_FIELDS = [
"last_message_date",
"group_creation_date",
"first_stored_message_date",
"last_stored_message_date",
]
def _describe_chat(record: ModuleAtomicResult) -> str:
jid = record.get("contact_jid") or "unknown"
name = record.get("partner_name")
identifier = record.get("partner_resolved_phone_number") or jid
label = f"'{name}' ({identifier})" if name else identifier
is_group = jid.endswith("@g.us") or record.get("group_creation_date")
if is_group:
return f"WhatsApp group chat {label}"
return f"WhatsApp chat with {label}"
class Whatsapp(IOSExtraction):
"""This module extracts all WhatsApp messages containing links."""
"""This module extracts all WhatsApp messages containing links, as well
as per-chat records with the first and last interaction dates of each
conversation."""
def __init__(
self,
@@ -45,6 +109,21 @@ class Whatsapp(IOSExtraction):
)
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
if record.get("record_type") == "chat_session":
return self._serialize_chat_session(record)
if record.get("record_type") == "lid_phone_number_pair":
if not record.get("pair_timestamp"):
return []
return {
"timestamp": record["pair_timestamp"],
"module": self.__class__.__name__,
"event": "lid_pair_recorded",
"data": (
f"WhatsApp associated LID {record.get('lid')} with "
f"phone number {record.get('phone_number')}"
),
}
text = record.get("ZTEXT", "").replace("\n", "\\n")
links_text = ""
if record.get("links"):
@@ -57,6 +136,49 @@ class Whatsapp(IOSExtraction):
"data": f"'{text}' from {record.get('ZFROMJID', 'Unknown')}{links_text}",
}
def _serialize_chat_session(
self, record: ModuleAtomicResult
) -> ModuleSerializedResult:
records = []
chat = _describe_chat(record)
if record.get("group_creation_date"):
records.append(
{
"timestamp": record["group_creation_date"],
"module": self.__class__.__name__,
"event": "group_created",
"data": f"{chat} was created",
}
)
if record.get("first_stored_message_date"):
records.append(
{
"timestamp": record["first_stored_message_date"],
"module": self.__class__.__name__,
"event": "chat_first_message",
"data": f"First stored message in {chat}",
}
)
# The chat session's own last-message date is authoritative: it can
# postdate the newest stored message if that message was deleted.
last_message_date = record.get("last_message_date") or record.get(
"last_stored_message_date"
)
if last_message_date:
records.append(
{
"timestamp": last_message_date,
"module": self.__class__.__name__,
"event": "chat_last_message",
"data": f"Last message in {chat}",
}
)
return records
def check_indicators(self) -> None:
if not self.indicators:
return
@@ -70,6 +192,11 @@ class Whatsapp(IOSExtraction):
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
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"), "whatsapp")
def run(self) -> None:
self._find_ios_database(
backup_ids=WHATSAPP_BACKUP_IDS, root_paths=WHATSAPP_ROOT_PATHS
@@ -141,7 +268,126 @@ class Whatsapp(IOSExtraction):
message["links"] = list(set(filtered_links))
self.results.append(message)
total_messages = len(self.results)
lid_map = self._extract_lid_pairs(cur)
total_sessions = self._extract_chat_sessions(cur, lid_map)
cur.close()
conn.close()
self.log.info("Extracted a total of %d WhatsApp messages", len(self.results))
self.log.info(
"Extracted a total of %d WhatsApp messages, %d chat sessions "
"and %d LID-phone number pairs",
total_messages,
total_sessions,
len(lid_map),
)
def _find_lid_db_path(self) -> Optional[str]:
for backup_id in WHATSAPP_LID_BACKUP_IDS:
file_path = self._get_backup_file_from_id(backup_id)
if file_path and os.path.exists(file_path):
return file_path
for found_path in self._get_fs_files_from_patterns(
WHATSAPP_LID_ROOT_PATHS
):
return found_path
return None
def _extract_lid_pairs(self, chat_cur: sqlite3.Cursor) -> Dict[str, str]:
"""Extract the LID to phone number mapping from the dedicated
LID.sqlite database, falling back to the same table in
ChatStorage.sqlite. Returns a map of LID digits to phone number
digits."""
rows = []
lid_db_path = self._find_lid_db_path()
if lid_db_path:
self.log.info(
"Found WhatsApp LID database at path: %s", lid_db_path
)
lid_conn = self._open_sqlite_db(lid_db_path)
try:
lid_cur = lid_conn.cursor()
lid_cur.execute(LID_PAIRS_QUERY)
rows = lid_cur.fetchall()
lid_cur.close()
except sqlite3.DatabaseError as exc:
self.log.warning(
"Unable to extract WhatsApp LID-phone number pairs: %s",
exc,
)
finally:
lid_conn.close()
else:
try:
chat_cur.execute(LID_PAIRS_QUERY)
rows = chat_cur.fetchall()
except sqlite3.OperationalError:
self.log.info(
"No WhatsApp LID database found in this backup or "
"filesystem dump: LID chat identifiers cannot be "
"resolved to phone numbers"
)
lid_map: Dict[str, str] = {}
for lid, phone_number, pair_timestamp in rows:
record = {
"record_type": "lid_phone_number_pair",
"lid": lid,
"phone_number": phone_number,
"pair_timestamp": (
convert_mactime_to_iso(pair_timestamp) or None
)
if pair_timestamp
else None,
}
self.results.append(record)
if lid and phone_number:
lid_digits = str(lid).split("@")[0]
phone_digits = str(phone_number).split("@")[0].lstrip("+")
lid_map[lid_digits] = phone_digits
return lid_map
def _extract_chat_sessions(
self, cur: sqlite3.Cursor, lid_map: Dict[str, str]
) -> int:
"""Extract one record per chat session with the first and last
interaction dates of each conversation."""
try:
cur.execute(CHAT_SESSIONS_QUERY)
except sqlite3.OperationalError as exc:
self.log.warning(
"Unable to extract WhatsApp chat sessions: %s", exc
)
return 0
names = [description[0] for description in cur.description]
total_sessions = 0
for row in cur.fetchall():
session = dict(zip(names, row))
session["record_type"] = "chat_session"
session["partner_resolved_phone_number"] = None
jid = session.get("contact_jid") or ""
if jid.endswith("@lid"):
phone_digits = lid_map.get(jid.split("@")[0])
if phone_digits:
session["partner_resolved_phone_number"] = (
f"+{phone_digits}"
)
for field in CHAT_SESSION_DATE_FIELDS:
if session.get(field):
session[field] = (
convert_mactime_to_iso(session[field]) or None
)
else:
session[field] = None
self.results.append(session)
total_sessions += 1
return total_sessions
@@ -0,0 +1,308 @@
# 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 logging
import sqlite3
from typing import Optional
from mvt.common.module import DatabaseNotFoundError
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleResults,
ModuleSerializedResult,
)
from mvt.common.utils import convert_mactime_to_iso
from ..base import IOSExtraction
WHATSAPP_CONTACTS_BACKUP_IDS = [
# SHA-1 of "AppDomainGroup-group.net.whatsapp.WhatsApp.shared-ContactsV2.sqlite"
"b8548dc30aa1030df0ce18ef08b882cf7ab5212f",
]
WHATSAPP_CONTACTS_ROOT_PATHS = [
"private/var/mobile/Containers/Shared/AppGroup/*/ContactsV2.sqlite",
]
# WhatsApp's standard disappearing-messages timer values, in seconds.
DISAPPEARING_DURATION_LABELS = {
86400: "24 hours",
604800: "7 days",
1209600: "14 days",
2592000: "30 days",
7776000: "90 days",
}
# Output field -> candidate columns in ZWAADDRESSBOOKCONTACT, in order of
# preference. WhatsApp renames columns across versions, so the query is built
# from the columns actually present in the database.
COLUMN_CANDIDATES = {
"whatsapp_id": ["ZWHATSAPPID"],
"lid": ["ZLID"],
"phone_number": ["ZPHONENUMBER"],
"localized_phone_number": ["ZLOCALIZEDPHONENUMBER"],
"full_name": ["ZFULLNAME"],
"given_name": ["ZGIVENNAME"],
"last_name": ["ZLASTNAME"],
"user_name": ["ZUSERNAME"],
"business_name": ["ZBUSINESSNAME"],
"about_text": ["ZABOUTTEXT"],
"about_emoji": ["ZABOUTEMOJI"],
"notes": ["ZNOTES"],
"disappearing_mode_duration": ["ZDISAPPEARINGMODEDURATION"],
"disappearing_mode_timestamp": ["ZDISAPPEARINGMODETIMESTAMP"],
"about_timestamp": ["ZABOUTTIMESTAMP"],
"about_expiration_timestamp": ["ZABOUTEXPIRATIONTIMESTAMP"],
"last_updated": ["ZLASTUPDATED"],
"phone_status": ["ZPHONESTATUS", "ZPHONENUMBERSTATUS"],
"sync_policy": ["ZSYNCPOLICY"],
}
STRING_FIELDS = [
"whatsapp_id",
"lid",
"phone_number",
"localized_phone_number",
"full_name",
"given_name",
"last_name",
"user_name",
"business_name",
"about_text",
"about_emoji",
"notes",
]
DATE_FIELDS = [
"disappearing_mode_timestamp",
"about_timestamp",
"about_expiration_timestamp",
"last_updated",
]
def _decode_string(value) -> Optional[str]:
# CoreData stores string attributes as UTF-8 blobs in some WhatsApp
# versions, so values can arrive as either bytes or str.
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", "replace")
return str(value)
def _label_duration(duration) -> str:
if not duration:
return "off"
return DISAPPEARING_DURATION_LABELS.get(
int(duration), f"{int(duration)} seconds"
)
def _describe_contact(record: ModuleAtomicResult) -> str:
contact = (
record.get("whatsapp_id")
or record.get("lid")
or record.get("phone_number")
or "unknown"
)
full_name = record.get("full_name")
if full_name:
contact = f"{contact} ({full_name})"
return contact
class WhatsappContacts(IOSExtraction):
"""This module extracts WhatsApp contact records and per-contact
disappearing-messages settings from ContactsV2.sqlite.
ChatStorage.sqlite does not record the disappearing-messages state of 1:1
chats: the authoritative timer is stored on each contact record in this
database, alongside the mapping between a contact's LID and phone number
identifiers.
"""
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[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
records = []
contact = _describe_contact(record)
if record.get("disappearing_mode_timestamp"):
records.append(
{
"timestamp": record["disappearing_mode_timestamp"],
"module": self.__class__.__name__,
"event": "disappearing_mode_set",
"data": (
f"WhatsApp disappearing messages timer set to "
f"'{record.get('disappearing_mode_label')}' "
f"for {contact}"
),
}
)
if record.get("about_timestamp"):
data = f"WhatsApp about text of {contact} changed"
about_text = record.get("about_text")
if about_text:
data += f' to "{about_text}"'
records.append(
{
"timestamp": record["about_timestamp"],
"module": self.__class__.__name__,
"event": "about_changed",
"data": data,
}
)
if record.get("about_expiration_timestamp"):
records.append(
{
"timestamp": record["about_expiration_timestamp"],
"module": self.__class__.__name__,
"event": "about_expiration",
"data": (
f"WhatsApp about text of {contact} scheduled "
f"to expire"
),
}
)
if record.get("last_updated"):
records.append(
{
"timestamp": record["last_updated"],
"module": self.__class__.__name__,
"event": "contact_last_updated",
"data": f"WhatsApp contact record for {contact} updated",
}
)
return records
def run(self) -> None:
try:
self._find_ios_database(
backup_ids=WHATSAPP_CONTACTS_BACKUP_IDS,
root_paths=WHATSAPP_CONTACTS_ROOT_PATHS,
)
except DatabaseNotFoundError:
self.log.warning(
"Unable to find the WhatsApp ContactsV2.sqlite database in "
"this backup or filesystem dump. WhatsApp disappearing "
"messages settings and contact records cannot be extracted. "
"This database is often missing from incremental backups."
)
return
self.log.info(
"Found WhatsApp contacts database at path: %s", self.file_path
)
assert self.file_path is not None
conn = self._open_sqlite_db(self.file_path)
cur = conn.cursor()
try:
try:
cur.execute("PRAGMA table_info(ZWAADDRESSBOOKCONTACT)")
available_columns = {row[1] for row in cur.fetchall()}
except sqlite3.DatabaseError as exc:
self.log.error(
"Unable to read the ZWAADDRESSBOOKCONTACT table schema: %s",
exc,
)
return
if not available_columns:
self.log.warning(
"The WhatsApp contacts database does not contain a "
"ZWAADDRESSBOOKCONTACT table"
)
return
selected = {}
for field, candidates in COLUMN_CANDIDATES.items():
for candidate in candidates:
if candidate in available_columns:
selected[field] = candidate
break
# A record with no duration column is "unknown", not "off": the
# timer state cannot be determined from this database version.
has_duration = "disappearing_mode_duration" in selected
if not has_duration:
self.log.warning(
"The ZDISAPPEARINGMODEDURATION column is not present in "
"this WhatsApp contacts database: disappearing messages "
"state is unknown"
)
columns = ["Z_PK"] + list(selected.values())
cur.execute(
f"SELECT {', '.join(columns)} FROM ZWAADDRESSBOOKCONTACT;"
)
fields = ["row_pk"] + list(selected.keys())
for row in cur:
record = dict(zip(fields, row))
for field in STRING_FIELDS:
if field in record:
record[field] = _decode_string(record[field])
else:
record[field] = None
for field in DATE_FIELDS:
if record.get(field) is not None:
record[field] = (
convert_mactime_to_iso(record[field]) or None
)
else:
record[field] = None
duration = record.get("disappearing_mode_duration")
if has_duration:
record["disappearing_mode_is_on"] = bool(duration)
record["disappearing_mode_label"] = _label_duration(
duration
)
else:
record["disappearing_mode_duration"] = None
record["disappearing_mode_is_on"] = None
record["disappearing_mode_label"] = None
record.setdefault("phone_status", None)
record.setdefault("sync_policy", None)
self.results.append(record)
finally:
cur.close()
conn.close()
total_ephemeral = sum(
1 for record in self.results if record["disappearing_mode_is_on"]
)
self.log.info(
"Extracted a total of %d WhatsApp contacts (%d with disappearing "
"messages enabled)",
len(self.results),
total_ephemeral,
)
@@ -23,7 +23,7 @@ class TestDumpsysAccessibilityArtifact:
assert len(da.results) == 4
assert da.results[0]["package_name"] == "com.android.settings"
assert (
da.results[0]["service"]
da.results[0]["component"]
== "com.android.settings/com.samsung.android.settings.development.gpuwatch.GPUWatchInterceptor"
)
@@ -37,7 +37,9 @@ class TestDumpsysAccessibilityArtifact:
da.parse(data)
assert len(da.results) == 1
assert da.results[0]["package_name"] == "com.malware.accessibility"
assert da.results[0]["service"] == "com.malware.service.malwareservice"
assert da.results[0]["service_name"] == "com.malware.service.malwareservice"
assert da.results[0]["enabled"] is True
assert da.results[0]["installed"] is False
def test_accessibility_service_alert(self):
da = DumpsysAccessibilityArtifact()
@@ -52,6 +54,22 @@ class TestDumpsysAccessibilityArtifact:
assert da.alertstore.alerts[0].level == AlertLevel.MEDIUM
assert da.alertstore.alerts[0].event == da.results[0]
def test_same_component_is_kept_for_each_user(self):
da = DumpsysAccessibilityArtifact()
da.parse(
"""User state[attributes:{id=0
installed services: {
0 : com.example/.Service
}
User state[attributes:{id=10
installed services: {
0 : com.example/.Service
}
"""
)
assert [result["user_id"] for result in da.results] == [0, 10]
def test_ioc_check(self, indicator_file):
da = DumpsysAccessibilityArtifact()
file = get_artifact("android_data/dumpsys_accessibility.txt")
+131
View File
@@ -3,12 +3,27 @@
# 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
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
from mvt.android.modules.bugreport.dumpsys_adb_state import DumpsysADBState
from mvt.common.alerts import AlertLevel
from ..utils import get_artifact
class TestDumpsysADBArtifact:
def test_parsing_binary_xml_recovers_key(self):
public_key = base64.b64encode(bytes(range(256)))
keystore = DumpsysADBArtifact().parse_binary_xml(
b"ABX\x00binary-key=" + public_key + b" user@host\x00lastConnection"
)
assert len(keystore) == 1
assert keystore[0]["key"] == public_key.decode()
assert keystore[0]["user"] == "user@host"
assert keystore[0]["last_connected"] is None
def test_parsing(self):
da_adb = DumpsysADBArtifact()
file = get_artifact("android_data/dumpsys_adb.txt")
@@ -114,3 +129,119 @@ class TestDumpsysADBArtifact:
assert key_store_entry["user"] == "user@laptop"
assert key_store_entry["fingerprint"] == expected_fingerprint
assert key_store_entry["last_connected"] == "1628501829898"
class TestDumpsysADBStateAlerts:
def test_no_androidqf_context_preserves_existing_behavior(self):
module = DumpsysADBState(
results=[
{
"user_keys": [
{
"key": b"QUJDRA==",
"user": "host@example",
"fingerprint": "fingerprint",
}
]
}
]
)
module.check_indicators()
assert module.alertstore.alerts == []
def test_androidqf_trusted_keys_create_expected_alerts(self):
module = DumpsysADBState(
module_options={
"androidqf_acquisition": {
"started": "2025-06-20T18:00:00Z",
"adb_host_public_key": "QUJDRA== acquisition@host",
}
},
results=[
{
"user_keys": [
{
"key": b"QUJDRA==",
"user": "acquisition@host",
"fingerprint": "acquisition-fingerprint",
},
{
"key": b"RUZHSA==",
"user": "other@host",
"fingerprint": "other-fingerprint",
},
{
"key": b"not-base64",
"user": "invalid@host",
"fingerprint": "",
},
],
"keystore": [
{
"key": b"QUJDRA==",
"user": "acquisition@host",
"fingerprint": "acquisition-fingerprint",
"last_connected": "1750266000000",
}
],
}
],
)
module.check_indicators()
assert [alert.level for alert in module.alertstore.alerts] == [
AlertLevel.INFORMATIONAL,
AlertLevel.LOW,
AlertLevel.LOW,
]
informational, different, invalid = module.alertstore.alerts
assert "at least one day before" in informational.message
assert informational.event_time == "2025-06-18 17:00:00.000000"
assert "different from the AndroidQF acquisition host" in different.message
assert "invalid trusted ADB host key" in invalid.message
def test_missing_androidqf_host_key_creates_low_alert(self):
trusted_key = {
"key": b"QUJDRA==",
"user": "host@example",
"fingerprint": "fingerprint",
}
module = DumpsysADBState(
module_options={"androidqf_acquisition": {}},
results=[{"user_keys": [trusted_key]}],
)
module.check_indicators()
assert len(module.alertstore.alerts) == 1
assert module.alertstore.alerts[0].level == AlertLevel.LOW
assert "does not include its host key" in module.alertstore.alerts[0].message
def test_recent_acquisition_host_key_does_not_create_alert(self):
module = DumpsysADBState(
module_options={
"androidqf_acquisition": {
"started": "2025-06-20T18:00:00Z",
"adb_host_public_key": "QUJDRA== acquisition@host",
}
},
results=[
{
"keystore": [
{
"key": b"QUJDRA==",
"user": "acquisition@host",
"fingerprint": "fingerprint",
"last_connected": "1750438800000",
}
]
}
],
)
module.check_indicators()
assert module.alertstore.alerts == []
+28 -1
View File
@@ -25,10 +25,37 @@ class TestDumpsysAppopsArtifact:
assert da.results[0]["uid"] == "0"
assert len(da.results[0]["permissions"]) == 1
assert da.results[0]["permissions"][0]["name"] == "MANAGE_IPSEC_TUNNELS"
assert da.results[0]["permissions"][0]["access"] == "allow"
assert da.results[0]["permissions"][0]["mode"] == "allow"
assert da.results[6]["package_name"] == "com.sec.factory.camera"
assert len(da.results[6]["permissions"][1]["entries"]) == 1
assert len(da.results[11]["permissions"]) == 4
wake_lock = next(
permission
for permission in da.results[11]["permissions"]
if permission["name"] == "WAKE_LOCK"
)
assert wake_lock["entries"][0]["duration"] == "+126ms"
def test_running_and_attribution_are_retained(self):
da = DumpsysAppopsArtifact()
da.parse(
""" Uid 0:
state=cch
Package com.example:
CAMERA (allow):
camera=[
Access: [fg-s] 2025-01-01 00:00:00.000 (-1s) duration=+2ms
]
RECORD_AUDIO (allow):
Running start at: +3s
"""
)
camera = da.results[0]["permissions"][0]["entries"][0]
running = da.results[0]["permissions"][1]["entries"][0]
assert camera["attribution"] == "camera"
assert running["event"] == "running"
assert running["relative_time"] == "+3s"
def test_ioc_check(self, indicator_file):
da = DumpsysAppopsArtifact()
@@ -57,18 +57,19 @@ class TestDumpsysBatteryDailyArtifact:
assert uninstall_alert.message == (
"Detected uninstall of package com.example.removed (vers 0)"
)
assert uninstall_alert.event_time == "2022-08-16"
assert uninstall_alert.event_time == "2022-08-16 15:56:39"
assert uninstall_alert.event["package_name"] == "com.example.removed"
assert uninstall_alert.event["vers"] == "0"
assert uninstall_alert.event["version_code"] == 0
assert uninstall_alert.event["action"] == "uninstall"
assert downgrade_alert.level == AlertLevel.MEDIUM
assert downgrade_alert.message == (
"Detected downgrade of package com.example.app from vers 10 to vers 9"
)
assert downgrade_alert.event_time == "2022-08-17"
assert downgrade_alert.event_time == "2022-08-17 15:56:39"
assert downgrade_alert.event["package_name"] == "com.example.app"
assert downgrade_alert.event["action"] == "downgrade"
assert downgrade_alert.event["previous_vers"] == "10"
assert downgrade_alert.event["previous_version_code"] == 10
def test_newest_first_update_is_not_reported_as_downgrade(self):
dba = DumpsysBatteryDailyArtifact()
@@ -107,7 +108,19 @@ class TestDumpsysBatteryDailyArtifact:
assert downgrade_alert.event_time == "2026-01-10"
assert downgrade_alert.event["package_name"] == "com.example.app"
assert downgrade_alert.event["action"] == "downgrade"
assert downgrade_alert.event["previous_vers"] == "102"
assert downgrade_alert.event["previous_version_code"] == 102
def test_duplicate_updates_retain_occurrence_count(self):
dba = DumpsysBatteryDailyArtifact()
dba.parse(
""" Daily from 2026-01-10-01-02-03 to 2026-01-11-04-05-06:
Update com.example.app vers=12
Update com.example.app vers=12
"""
)
assert dba.results[0]["occurrences"] == 2
assert dba.results[0]["period_start"] == "2026-01-10 01:02:03"
def test_reinstall_after_uninstall_is_not_reported_as_downgrade(self):
dba = DumpsysBatteryDailyArtifact()
@@ -54,6 +54,7 @@ class TestDumpsysBatteryHistoryArtifact:
assert len(dba.results) == 2
assert dba.results[0] == {
"time_elapsed": "07-15 20:27:39.431",
"timestamp": "1900-07-15 20:27:39.431000",
"event": "start_job",
"uid": "u0a123",
"package_name": "com.example",
@@ -61,3 +62,23 @@ class TestDumpsysBatteryHistoryArtifact:
}
assert dba.results[1]["event"] == "end_job"
assert dba.results[1]["uid"] == "u0a123"
def test_wake_lock_without_component_is_retained(self):
dba = DumpsysBatteryHistoryArtifact()
dba.parse(
"Battery History:\n"
" 0 (2) 100 RESET:TIME: 2025-09-05-01-04-52-139\n"
' +1s (2) 100 +running +wake_lock=1000:"*alarm*:TIME_TICK"\n'
' +2s (2) 100 +running +wake_lock=u0a1:"*walarm*:com.whatsapp.MessageHandler.LOGOUT_ACTION"\n'
"\n"
)
assert [record["event"] for record in dba.results] == ["wake", "wake"]
assert dba.results[0]["package_name"] is None
assert dba.results[1]["package_name"] == "com.whatsapp"
def test_decorated_sync_job_uses_component_package(self):
dba = DumpsysBatteryHistoryArtifact()
dba.parse('+1s (2) 100 +job=u0a1:"@SyncManager@gmail-ls/com.google:android"\n')
assert dba.results[0]["package_name"] == "com.google"
+24 -1
View File
@@ -53,9 +53,32 @@ Connection pool for /data/user/0/com.example/databases/current.db:
assert dbi.results == [
{
"isodate": "07-15 20:27:39.431",
"timestamp": "07-15 20:27:39.431",
"pid": None,
"action": "executeForCursorWindow",
"duration_ms": 1,
"status": "succeeded",
"sql": "SELECT 1",
"path": "/data/user/0/com.example/databases/current.db",
"pool_path": "/data/user/0/com.example/databases/current.db",
"connection_number": None,
"is_primary": None,
}
]
def test_parses_operations_from_multiple_connections(self):
dbi = DumpsysDBInfoArtifact()
dbi.parse(
"""Connection pool for /data/example.db:
Connection #0:
isPrimaryConnection: true
Most recently executed operations:
0: [2025-01-01 00:00:00.000] execute took 1ms - succeeded, sql="SELECT 1", path=/data/example.db
Connection #1:
isPrimaryConnection: false
Most recently executed operations:
0: [2025-01-01 00:00:01.000] execute took 2ms - succeeded, sql="SELECT 2", path=/data/example.db
"""
)
assert [record["connection_number"] for record in dbi.results] == [0, 1]
@@ -21,12 +21,16 @@ class TestDumpsysPackageActivitiesArtifact:
assert len(dpa.results) == 0
dpa.parse(data)
assert len(dpa.results) == 4
assert dpa.results[0]["package_name"] == "com.samsung.android.app.social"
assert len(dpa.results) == 10
assert dpa.results[0]["package_name"] == "com.samsung.android.messaging"
assert (
dpa.results[0]["activity"]
== "com.samsung.android.app.social/.feed.FeedsActivity"
dpa.results[0]["component"]
== "com.samsung.android.messaging/.ui.RcsTransferContent"
)
assert {result["resolver_type"] for result in dpa.results} == {
"full_mime_type",
"non_data_action",
}
def test_ioc_check(self, indicator_file):
dpa = DumpsysPackageActivitiesArtifact()
@@ -41,4 +45,4 @@ class TestDumpsysPackageActivitiesArtifact:
dpa.indicators = ind
assert len(dpa.alertstore.alerts) == 0
dpa.check_indicators()
assert len(dpa.alertstore.alerts) == 1
assert len(dpa.alertstore.alerts) == 2
@@ -25,6 +25,29 @@ class TestDumpsysPackagesArtifact:
== "com.samsung.android.provider.filterprovider"
)
assert dpa.results[0]["version_name"] == "5.0.07"
assert dpa.results[0]["version_code"] == 500700000
assert dpa.results[0]["min_sdk"] == 28
assert dpa.results[0]["target_sdk"] == 28
assert dpa.results[0]["package_type"] == "active"
assert dpa.results[0]["users"][0]["user_id"] == 0
assert dpa.results[0]["system"] is True
def test_parsing_system_flag(self):
system_details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
" pkgFlags=[ SYSTEM HAS_CODE ALLOW_CLEAR_USER_DATA ]"
)
third_party_details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
" pkgFlags=[ HAS_CODE ALLOW_BACKUP ]"
)
missing_flag_details = (
DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
" versionName=1.0"
)
)
assert system_details["system"] is True
assert third_party_details["system"] is False
assert missing_flag_details["system"] is False
def test_ioc_check(self, indicator_file):
dpa = DumpsysPackagesArtifact()
@@ -40,3 +63,28 @@ class TestDumpsysPackagesArtifact:
assert len(dpa.alertstore.alerts) == 0
dpa.check_indicators()
assert len(dpa.alertstore.alerts) == 1
def test_hidden_packages_and_per_user_state(self):
dpa = DumpsysPackagesArtifact()
dpa.parse(
"""Packages:
Package [com.example.active]:
appId=10001
versionCode=12 minSdk=29 targetSdk=35
User 0: installed=true hidden=false
firstInstallTime=2025-01-01 01:02:03
User 10: installed=false hidden=true
firstInstallTime=2025-01-02 01:02:03
Hidden system packages:
Package [com.example.hidden]:
appId=10002
installerPackageName=null
"""
)
assert [record["package_type"] for record in dpa.results] == [
"active",
"hidden_system",
]
assert len(dpa.results[0]["users"]) == 2
assert dpa.results[1]["installer"] is None
@@ -22,6 +22,9 @@ class TestDumpsysPlatformCompatArtifact:
assert len(dbi.results) == 2
assert dbi.results[0]["package_name"] == "org.torproject.torbrowser"
assert dbi.results[1]["package_name"] == "org.article19.circulo.next"
assert dbi.results[0]["change_id"] == 168419799
assert dbi.results[0]["change_name"] == "DOWNSCALED"
assert dbi.results[0]["override_value"] is False
def test_ioc_check(self, indicator_file):
dbi = DumpsysPlatformCompatArtifact()
@@ -19,17 +19,14 @@ class TestDumpsysReceiversArtifact:
assert len(dr.results) == 0
dr.parse(data)
assert len(dr.results) == 4
assert (
list(dr.results.keys())[0]
== "com.android.storagemanager.automatic.SHOW_NOTIFICATION"
)
assert (
dr.results["com.android.storagemanager.automatic.SHOW_NOTIFICATION"][0][
"package_name"
]
== "com.android.storagemanager"
assert len(dr.results) == 9
assert dr.results[0]["resolver_type"] == "full_mime_type"
storage_manager = next(
result
for result in dr.results
if result["key"] == "com.android.storagemanager.automatic.SHOW_NOTIFICATION"
)
assert storage_manager["package_name"] == "com.android.storagemanager"
def test_parsing_misindented_action(self):
dr = DumpsysReceiversArtifact()
@@ -44,12 +41,8 @@ Receiver Resolver Table:
dr.parse(data)
assert (
dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][
"package_name"
]
== "com.psycatgames.nhiegame"
)
assert dr.results[1]["key"] == "android.intent.action.MY_PACKAGE_REPLACED"
assert dr.results[1]["package_name"] == "com.psycatgames.nhiegame"
def test_parsing_misindented_first_action(self):
dr = DumpsysReceiversArtifact()
@@ -62,12 +55,8 @@ Receiver Resolver Table:
dr.parse(data)
assert (
dr.results["android.intent.action.MY_PACKAGE_REPLACED"][0][
"package_name"
]
== "com.psycatgames.nhiegame"
)
assert dr.results[0]["key"] == "android.intent.action.MY_PACKAGE_REPLACED"
assert dr.results[0]["package_name"] == "com.psycatgames.nhiegame"
def test_ioc_check(self, indicator_file):
dr = DumpsysReceiversArtifact()
+9
View File
@@ -39,3 +39,12 @@ class TestGetPropArtifact:
assert len(gp.alertstore.alerts) == 0
gp.check_indicators()
assert len(gp.alertstore.alerts) == 1
def test_empty_values_and_invalid_lines(self):
gp = GetProp()
gp.parse("[empty]: []\n[valid]: [value]\n0\n[broken]: [value")
assert gp.results == [
{"name": "empty", "value": ""},
{"name": "valid", "value": "value"},
]
+12 -1
View File
@@ -20,7 +20,7 @@ class TestProcessesArtifact:
assert len(p.results) == 0
p.parse(data)
assert len(p.results) == 17
assert p.results[0]["proc_name"] == "init"
assert p.results[0]["command"] == "init"
def test_ioc_check(self, indicator_file):
p = Processes()
@@ -36,3 +36,14 @@ class TestProcessesArtifact:
assert len(p.alertstore.alerts) == 0
p.check_indicators()
assert len(p.alertstore.alerts) == 1
def test_bugreport_thread_columns(self):
p = Processes()
p.parse(
"LABEL USER PID TID PPID VSZ RSS WCHAN ADDR S PRI NI RTPRIO SCH PCY TIME CMD\n"
"u:r:init:s0 root 1 2 0 100 20 0 0 S 19 0 - 0 fg 00:00:01 init\n"
)
assert p.results[0]["label"] == "u:r:init:s0"
assert p.results[0]["tid"] == 2
assert p.results[0]["command"] == "init"
+44 -5
View File
@@ -8,6 +8,7 @@ import datetime
import pytest
from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact
from mvt.android.parsers.proto.tombstone import Tombstone
from ..utils import get_artifact
@@ -42,6 +43,47 @@ class TestTombstoneCrashArtifact:
assert len(tombstone_artifact.results) == 1
self.validate_tombstone_result(tombstone_artifact.results[0])
def test_text_tombstone_preserves_abort_message(self):
tombstone_artifact = TombstoneCrashArtifact()
artifact_path = "android_data/bugreport/FS/data/tombstones/tombstone_00"
file = get_artifact(artifact_path)
with open(file, "rb") as f:
data = f.read()
tombstone_artifact.parse(
os.path.basename(artifact_path),
datetime.datetime(2021, 9, 29, 17, 43, 49),
data,
)
assert tombstone_artifact.results[0]["abort_message"] == (
"Check failed: payload.size() <= bytes_left "
"(payload.size()=99, bytes_left=51) "
)
def test_protobuf_tombstone_preserves_abort_message_and_causes(self):
tombstone_artifact = TombstoneCrashArtifact()
artifact_path = "android_data/tombstone_process.pb"
file = get_artifact(artifact_path)
with open(file, "rb") as f:
tombstone = Tombstone().parse(f.read())
tombstone.abort_message = "synthetic abort reason"
tombstone_artifact.parse_protobuf(
os.path.basename(artifact_path),
datetime.datetime(2023, 4, 12, 12, 32, 40, 518290),
bytes(tombstone),
)
result = tombstone_artifact.results[0]
assert result["abort_message"] == "synthetic abort reason"
assert result["causes"] == [
{
"human_readable": "null pointer dereference",
"memory_error": None,
}
]
def test_text_tombstone_keeps_crashing_thread(self):
tombstone_artifact = TombstoneCrashArtifact()
artifact_path = "android_data/tombstone_process.txt"
@@ -86,8 +128,5 @@ class TestTombstoneCrashArtifact:
assert tombstone_result.get("pid") == 25541
assert tombstone_result.get("process_name") == "mtk.ape.decoder"
# With Android logs we want to keep timestamps as device local time for consistency.
# We often don't know the time offset for a log entry and so can't convert everything to UTC.
# MVT should output the local time only:
# So original 2023-04-12 12:32:40.518290770+0200 -> 2023-04-12 12:32:40.000000
assert tombstone_result.get("timestamp") == "2023-04-12 12:32:40.518290"
# Tombstones include an explicit offset, so normalize them to UTC.
assert tombstone_result.get("timestamp") == "2023-04-12 10:32:40.518290"
+13
View File
@@ -13,6 +13,19 @@ from ..utils import get_android_androidqf, list_files
class TestAndroidqfMountsArtifact:
def test_parse_proc_mountinfo(self):
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
results = MountsArtifact.parse_mountinfo(
"41 40 254:13 / / ro,relatime shared:1 - erofs /dev/block/dm-13 ro,seclabel\n",
123,
)
assert results[0]["mount_id"] == 41
assert results[0]["mount_point"] == "/"
assert results[0]["filesystem_type"] == "erofs"
assert results[0]["process_ids"] == [123]
def test_parse_mounts_token_checks(self):
"""
Test the artifact-level `parse` method using tolerant token checks.
+15
View File
@@ -6,12 +6,27 @@
from pathlib import Path
from mvt.android.modules.androidqf.aqf_settings import AQFSettings
from mvt.android.artifacts.settings import Settings
from mvt.common.module import run_module
from ..utils import get_android_androidqf, list_files
class TestSettingsModule:
def test_bugreport_settings_format(self):
settings = Settings()
settings.parse(
"GLOBAL SETTINGS (user 0)\n"
"_id:1 name:adb_wifi_enabled pkg:android value:0 default:0 defaultSystemSet:true\n"
"SECURE SETTINGS (user 10)\n"
"_id:2 name:accessibility_enabled pkg:android value:1\n"
)
assert settings.results == {
"global:user_0": {"adb_wifi_enabled": "0"},
"secure:user_10": {"accessibility_enabled": "1"},
}
def test_parsing(self):
data_path = get_android_androidqf()
m = AQFSettings(target_path=data_path)
+15 -7
View File
@@ -54,8 +54,11 @@ class TestBugreportAnalysis:
== "com.samsung.android.provider.filterprovider"
)
assert m.results[1]["package_name"] == "com.instagram.android"
assert m.results[0]["installer"] is None
assert m.results[1]["installer"] == "com.android.vending"
assert len(m.results[0]["permissions"]) == 4
assert len(m.results[1]["permissions"]) == 32
assert len(m.results[1]["permissions"]) == 20
assert len(m.results[1]["users"][0]["permissions"]) == 19
def test_getprop_module(self):
m = self.launch_bug_report_module(DumpsysGetProp)
@@ -64,29 +67,34 @@ class TestBugreportAnalysis:
def test_receivers_match_exact_package_name(self, indicators_factory):
intent = "android.intent.action.PHONE_STATE"
false_positive = {
"resolver_type": "non_data_action",
"key": intent,
"package_name": "com.android.phone",
"receiver": (
"component": (
"com.android.phone/"
"com.android.services.telephony.sip.SipIncomingCallReceiver"
),
"filter_count": 1,
}
malicious_receiver = {
"resolver_type": "non_data_action",
"key": intent,
"package_name": "com.android.services",
"receiver": "com.android.services/com.example.SomeReceiver",
"component": "com.android.services/com.example.SomeReceiver",
"filter_count": 1,
}
module = DumpsysReceivers(
results={intent: [false_positive, malicious_receiver]}
)
module = DumpsysReceivers(results=[false_positive, malicious_receiver])
module.indicators = indicators_factory(app_ids=["com.android.services"])
module.check_indicators()
assert len(module.alertstore.alerts) == 1
alert = module.alertstore.alerts[0]
assert alert.event == {intent: malicious_receiver}
assert alert.event == malicious_receiver
assert alert.matched_indicator.value == "com.android.services"
def test_tombstones_modules(self):
m = self.launch_bug_report_module(Tombstones)
assert len(m.results) == 2
assert m.results[1]["pid"] == 3559
assert m.results[0]["sources"]["text"]["parsed"] is True
+371
View File
@@ -0,0 +1,371 @@
from types import SimpleNamespace
import click
from click.testing import CliRunner
from mvt.common.cli_plugins import (
ANDROID_CLI_PLUGIN_GROUP,
IOS_CLI_PLUGIN_GROUP,
BrokenPluginCommand,
load_cli_commands_option,
register_cli_commands_from_path,
register_cli_plugins,
register_installed_cli_commands,
)
COMMAND_TEMPLATE = """
import click
@click.command({name!r})
@click.pass_context
def cli(ctx):
click.echo({message!r})
if ctx.obj:
click.echo(ctx.obj.get("marker", ""))
"""
def _write_command(path, name, message="command ran"):
path.write_text(
COMMAND_TEMPLATE.format(name=name, message=message),
encoding="utf-8",
)
return path
def _make_group():
@click.group()
@load_cli_commands_option
@click.pass_context
def group(ctx):
ctx.ensure_object(dict)
ctx.obj["marker"] = "parent context"
return group
def _entry_point(name, value, command=None, exception=None, distribution="plugin"):
def load():
if exception is not None:
raise exception
return command
dist = SimpleNamespace(metadata={"Name": distribution}, version="1.0")
return SimpleNamespace(name=name, value=value, load=load, dist=dist)
def test_load_command_option_registers_command_before_resolution(tmp_path):
command_path = _write_command(tmp_path / "hello.py", "hello")
group = _make_group()
result = CliRunner().invoke(
group,
["--load-command", str(command_path), "hello"],
)
assert result.exit_code == 0
assert "command ran" in result.output
assert "parent context" in result.output
def test_load_command_option_supports_folders_and_repeated_paths(tmp_path):
folder = tmp_path / "commands"
folder.mkdir()
_write_command(folder / "b.py", "second")
_write_command(folder / "a.py", "first")
_write_command(folder / ".hidden.py", "hidden")
_write_command(folder / "__init__.py", "init")
other = _write_command(tmp_path / "third.py", "third")
group = _make_group()
result = CliRunner().invoke(
group,
[
"--load-command",
str(folder),
"--load-command",
str(other),
"--load-command",
str(other),
"--help",
],
)
assert result.exit_code == 0
assert "first" in result.output
assert "second" in result.output
assert "third" in result.output
assert "hidden" not in result.output
assert "init" not in result.output
def test_loaded_command_participates_in_shell_completion(tmp_path):
command_path = _write_command(tmp_path / "hello.py", "hello")
group = _make_group()
words = f"group --load-command {command_path} he"
result = CliRunner().invoke(
group,
[],
env={
"_GROUP_COMPLETE": "bash_complete",
"COMP_WORDS": words,
"COMP_CWORD": "3",
},
)
assert result.exit_code == 0
assert "plain,hello" in result.output
def test_explicit_command_import_and_contract_failures_are_usage_errors(tmp_path):
broken_path = tmp_path / "broken.py"
broken_path.write_text("raise RuntimeError('broken import')", encoding="utf-8")
missing_cli_path = tmp_path / "missing_cli.py"
missing_cli_path.write_text("value = 1", encoding="utf-8")
broken_result = CliRunner().invoke(
_make_group(),
["--load-command", str(broken_path), "broken"],
)
missing_cli_result = CliRunner().invoke(
_make_group(),
["--load-command", str(missing_cli_path), "missing-cli"],
)
assert broken_result.exit_code == 2
assert "broken import" in broken_result.output
assert missing_cli_result.exit_code == 2
assert "must export a Click command or group named 'cli'" in (
missing_cli_result.output
)
def test_environment_command_failure_gets_broken_placeholder(tmp_path):
command_path = tmp_path / "broken_command.py"
command_path.write_text("raise RuntimeError('broken import')", encoding="utf-8")
group = click.Group()
registered = register_cli_commands_from_path(group, command_path)
assert registered == ["broken-command"]
assert isinstance(group.commands["broken-command"], BrokenPluginCommand)
result = CliRunner().invoke(group, ["broken-command"])
assert result.exit_code == 1
assert "broken import" in result.output
assert str(command_path) in result.output
def test_environment_command_system_exit_gets_broken_placeholder(tmp_path):
command_path = tmp_path / "exiting_command.py"
command_path.write_text("raise SystemExit(7)", encoding="utf-8")
group = click.Group()
registered = register_cli_commands_from_path(group, command_path)
assert registered == ["exiting-command"]
assert isinstance(group.commands["exiting-command"], BrokenPluginCommand)
result = CliRunner().invoke(group, ["exiting-command"])
assert result.exit_code == 1
assert "Unable to import custom command" in result.output
assert result.output.rstrip().endswith(": 7")
def test_installed_entry_point_name_is_the_command_name(monkeypatch):
@click.command("internal-name")
def command():
click.echo("installed command ran")
entry_point = _entry_point(
"external-name",
"example_plugin:cli",
command=command,
)
monkeypatch.setattr(
"mvt.common.cli_plugins.importlib.metadata.entry_points",
lambda **kwargs: [entry_point],
)
group = click.Group()
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
assert registered == ["external-name"]
assert "internal-name" not in group.commands
result = CliRunner().invoke(group, ["external-name"])
assert result.exit_code == 0
assert result.output == "installed command ran\n"
def test_broken_installed_plugin_does_not_break_cli(monkeypatch):
broken = _entry_point(
"broken",
"broken_plugin:cli",
exception=RuntimeError("missing dependency"),
distribution="broken-plugin",
)
monkeypatch.setattr(
"mvt.common.cli_plugins.importlib.metadata.entry_points",
lambda **kwargs: [broken],
)
group = click.Group()
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
help_result = CliRunner().invoke(group, ["--help"])
assert help_result.exit_code == 0
assert "Warning: external command could not be loaded." in help_result.output
result = CliRunner().invoke(group, ["broken"])
assert result.exit_code == 1
assert "broken-plugin 1.0 (broken_plugin:cli)" in result.output
assert "RuntimeError: missing dependency" in result.output
def test_installed_plugin_system_exit_does_not_break_cli(monkeypatch):
exiting = _entry_point(
"exiting",
"exiting_plugin:cli",
exception=SystemExit(7),
distribution="exiting-plugin",
)
monkeypatch.setattr(
"mvt.common.cli_plugins.importlib.metadata.entry_points",
lambda **kwargs: [exiting],
)
group = click.Group()
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
help_result = CliRunner().invoke(group, ["--help"])
assert help_result.exit_code == 0
result = CliRunner().invoke(group, ["exiting"])
assert result.exit_code == 1
assert "SystemExit: 7" in result.output
def test_non_click_entry_point_gets_broken_placeholder(monkeypatch):
invalid = _entry_point("invalid", "plugin:value", command=object())
monkeypatch.setattr(
"mvt.common.cli_plugins.importlib.metadata.entry_points",
lambda **kwargs: [invalid],
)
group = click.Group()
register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
assert isinstance(group.commands["invalid"], BrokenPluginCommand)
result = CliRunner().invoke(group, ["invalid"])
assert result.exit_code == 1
assert "must resolve to a Click command or group" in result.output
def test_entry_point_discovery_failure_does_not_break_group(monkeypatch, caplog):
def fail_discovery(**kwargs):
raise RuntimeError("invalid package metadata")
monkeypatch.setattr(
"mvt.common.cli_plugins.importlib.metadata.entry_points",
fail_discovery,
)
group = click.Group()
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
assert registered == []
assert not group.commands
assert "Unable to discover external commands" in caplog.text
assert "invalid package metadata" in caplog.text
def test_builtin_and_first_external_command_win_collisions(monkeypatch, caplog):
@click.command("version")
def core_version():
pass
@click.command()
def first():
pass
@click.command()
def second():
pass
entry_points = [
_entry_point("duplicate", "z_plugin:cli", command=second, distribution="z"),
_entry_point("version", "plugin:version", command=first),
_entry_point("duplicate", "a_plugin:cli", command=first, distribution="a"),
]
monkeypatch.setattr(
"mvt.common.cli_plugins.importlib.metadata.entry_points",
lambda **kwargs: entry_points,
)
group = click.Group(commands={"version": core_version})
registered = register_installed_cli_commands(group, IOS_CLI_PLUGIN_GROUP)
assert registered == ["duplicate"]
assert group.commands["version"] is core_version
assert group.commands["duplicate"] is first
assert "the command name is already registered" in caplog.text
def test_explicit_command_cannot_replace_existing_command(tmp_path):
command_path = _write_command(tmp_path / "version.py", "version")
group = _make_group()
@group.command("version")
def core_version():
pass
result = CliRunner().invoke(
group,
["--load-command", str(command_path), "version"],
)
assert result.exit_code == 2
assert "the command name is already registered" in result.output
def test_platform_entry_point_groups_and_environment_paths_are_separate(
tmp_path, monkeypatch
):
ios_path = _write_command(tmp_path / "ios.py", "ios-file")
android_path = _write_command(tmp_path / "android.py", "android-file")
@click.command()
def ios_package():
pass
@click.command()
def android_package():
pass
def entry_points(*, group):
if group == IOS_CLI_PLUGIN_GROUP:
return [_entry_point("ios-package", "ios_plugin:cli", ios_package)]
return [_entry_point("android-package", "android_plugin:cli", android_package)]
monkeypatch.setattr(
"mvt.common.cli_plugins.importlib.metadata.entry_points",
entry_points,
)
monkeypatch.setenv("TEST_IOS_COMMANDS", str(ios_path))
monkeypatch.setenv("TEST_ANDROID_COMMANDS", str(android_path))
ios_group = click.Group()
android_group = click.Group()
register_cli_plugins(
ios_group,
entry_point_group=IOS_CLI_PLUGIN_GROUP,
environment_variable="TEST_IOS_COMMANDS",
)
register_cli_plugins(
android_group,
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
environment_variable="TEST_ANDROID_COMMANDS",
)
assert set(ios_group.commands) == {"ios-file", "ios-package"}
assert set(android_group.commands) == {"android-file", "android-package"}
+24
View File
@@ -42,6 +42,15 @@ class IndependentModule(RecordingModule):
pass
class URLRecordingModule(RecordingModule):
def collect_url_results(self):
self.add_url_result(
"https://example.org/message",
"2026-07-29 12:00:00.000000",
"test-chat",
)
class CustomIOSBackupModule(RecordingModule):
supported_commands = (("ios", "check-backup"),)
@@ -87,6 +96,21 @@ class TestCommand:
alerts = json.loads((tmp_path / "alerts.json").read_text())
assert alerts[0]["event"]["payload"] == "\\xa8\\xa9"
def test_stores_collected_urls(self, tmp_path):
cmd = RecordingCommand(results_path=str(tmp_path))
cmd.modules = [URLRecordingModule]
cmd.run()
assert json.loads((tmp_path / "urls.json").read_text()) == [
{
"url": "https://example.org/message",
"expanded_url": None,
"timestamp": "2026-07-29 12:00:00.000000",
"source": "test-chat",
}
]
def test_modules_run_in_stable_topological_order(self):
cmd = RecordingCommand()
cmd.modules = [ThirdModule, IndependentModule, SecondModule, FirstModule]
+9
View File
@@ -197,6 +197,15 @@ class TestIndicators:
assert matches[0] is None
assert matches[1]
assert matches[1].ioc.value == "example.org"
assert (
ind.get_expanded_url("https://tinyurl.com/nested")
== "https://www.example.org/landing"
)
assert (
ind.get_expanded_url("https://t.co/nested")
== "https://www.example.org/landing"
)
assert ind.get_expanded_url("https://bit.ly/failure") is None
assert {call.args[0] for call in head.call_args_list} == {
"https://bit.ly/failure",
"https://tinyurl.com/nested",
+43
View File
@@ -3,10 +3,12 @@ import pytest
from mvt.common.module import MVTModule
from mvt.common.module_loader import (
CustomModuleLoadError,
get_module_logger,
load_custom_modules,
load_custom_modules_from_path,
module_supports_command,
)
from mvt.ios.modules.mixed.whatsapp import Whatsapp
MODULE_TEMPLATE = """
@@ -144,3 +146,44 @@ def test_module_supports_command_honors_supported_commands(tmp_path):
assert module_supports_command(module, "ios", "check-backup")
assert not module_supports_command(module, "ios", "check-fs")
def test_get_module_logger_keeps_builtin_names():
assert get_module_logger(Whatsapp).name == "mvt.ios.modules.mixed.whatsapp"
def test_get_module_logger_parents_package_modules_under_mvt_ext():
class PackageModule(MVTModule):
pass
PackageModule.__module__ = "some_plugin_package.ios.custom"
assert (
get_module_logger(PackageModule).name
== "mvt.ext.some_plugin_package.ios.custom"
)
def test_get_module_logger_strips_the_plugin_package_prefix():
class PluginModule(MVTModule):
pass
PluginModule.__module__ = "mvt_plugin_amnesty_custom.ios.custom"
assert get_module_logger(PluginModule).name == "mvt.ext.amnesty_custom.ios.custom"
def test_get_module_logger_only_strips_the_prefix_from_the_top_level():
class NestedModule(MVTModule):
pass
NestedModule.__module__ = "other_package.mvt_plugin_sub"
assert get_module_logger(NestedModule).name == "mvt.ext.other_package.mvt_plugin_sub"
def test_get_module_logger_names_path_modules_after_their_file(tmp_path):
module_path = _write_module(tmp_path / "my_custom_module.py", "PathModule")
module = load_custom_modules_from_path(str(module_path))[0]
assert get_module_logger(module).name == "mvt.ext.my_custom_module"
+19
View File
@@ -8,6 +8,7 @@ import logging
import os
from datetime import datetime
from mvt.common.log import MVTLogHandler
from mvt.common.utils import (
CustomJSONEncoder,
convert_datetime_to_iso,
@@ -16,6 +17,7 @@ from mvt.common.utils import (
convert_unix_to_utc_datetime,
generate_hashes_from_path,
get_sha256_from_file_path,
init_logging,
)
from ..utils import get_artifact_folder
@@ -103,3 +105,20 @@ class TestCustomJSONEncoder:
json.dumps({"name": "家".encode()}, cls=CustomJSONEncoder)
== '{"name": "\\u5bb6"}'
)
class TestInitLogging:
def test__init_logging_is_idempotent(self):
# Loaded module packages may import an MVT CLI module, which calls
# init_logging() again at import time. A second call must not add
# a duplicate console handler.
log = logging.getLogger("mvt")
init_logging()
handler_count = sum(
isinstance(handler, MVTLogHandler) for handler in log.handlers
)
init_logging()
assert (
sum(isinstance(handler, MVTLogHandler) for handler in log.handlers)
== handler_count
)
+105
View File
@@ -0,0 +1,105 @@
# 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/
from mvt.common.module import run_module
from mvt.ios.modules.mixed.interactionc import InteractionC
from mvt.ios.modules.mixed.whatsapp_contacts import WhatsappContacts
from ..utils import get_ios_backup_folder
class TestInteractionCModule:
def test_extraction_with_whatsapp_contacts(self):
contacts = WhatsappContacts(target_path=get_ios_backup_folder())
run_module(contacts)
m = InteractionC(target_path=get_ios_backup_folder())
m.dependency_modules = {WhatsappContacts: contacts}
run_module(m)
assert len(m.results) == 3
incoming = next(
r for r in m.results if r["sender_identifier"] == "100000000000001@lid"
)
assert incoming["direction"] == "INCOMING"
assert incoming["sender_resolved_phone_number"] == "+14155550100"
assert incoming["sender_resolved_name"] == "Alice Example"
outgoing = next(
r for r in m.results if r["direction"] == "OUTGOING"
)
assert outgoing["recipient_identifier"] == "+14155550100"
assert outgoing["recipient_resolved_name"] == "Alice Example"
assert outgoing["domain_resolved_phone_number"] == "+14155550100"
assert outgoing["domain_resolved_name"] == "Alice Example"
sms = next(
r for r in m.results if r["bundle_id"] == "com.apple.MobileSMS"
)
assert sms.get("sender_resolved_name") is None
assert sms["sender_display_name"] == "Bob Example"
events = [entry["data"] for entry in m.timeline]
assert (
"[net.whatsapp.WhatsApp] INCOMING from "
"Alice Example (+14155550100) to local user" in events
)
assert (
"[net.whatsapp.WhatsApp] OUTGOING from local user to "
"Alice Example (+14155550100)" in events
)
assert (
"[com.apple.MobileSMS] INCOMING from "
"Bob Example (+14155550101) to local user" in events
)
# The creation date is only serialized when it diverges from the
# start date; the SMS record was created 90 days after the event.
creation_events = [
entry
for entry in m.timeline
if entry["event"] == "interactions_creation_date"
]
assert len(creation_events) == 1
assert creation_events[0]["timestamp"] == "2025-12-09 12:26:40.000000"
assert creation_events[0]["data"] == (
"Interaction record created 90 days after the event: "
"[com.apple.MobileSMS] INCOMING from "
"Bob Example (+14155550101) to local user"
)
# Per-contact aggregate dates use contact-centric data strings.
first_seen = [
entry
for entry in m.timeline
if entry["event"] == "first_incoming_sender_date"
]
assert len(first_seen) == 1
assert first_seen[0]["timestamp"] == "2025-09-03 13:46:40.000000"
assert first_seen[0]["data"] == (
"First incoming interaction from Bob Example (+14155550101)"
)
assert (
"Last incoming interaction from Bob Example (+14155550101)"
in events
)
def test_extraction_without_whatsapp_contacts(self):
# Without the WhatsappContacts dependency the module still runs, and
# unresolvable LIDs are shown as-is.
m = InteractionC(target_path=get_ios_backup_folder())
run_module(m)
assert len(m.results) == 3
events = [entry["data"] for entry in m.timeline]
assert (
"[net.whatsapp.WhatsApp] INCOMING from "
"100000000000001@lid to local user" in events
)
assert (
"[net.whatsapp.WhatsApp] OUTGOING from local user to "
"+14155550100" in events
)
+8
View File
@@ -18,6 +18,14 @@ class TestSMSModule:
run_module(m)
assert len(m.results) == 1
assert len(m.timeline) == 2
assert m.url_results == [
{
"url": "https://badbadbad.example.org/",
"expanded_url": None,
"timestamp": "2019-08-29 23:13:30.000000",
"source": "sms",
}
]
assert len(m.alertstore.alerts) == 0
def test_detection(self, indicator_file):
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more