Compare commits

...
Author SHA1 Message Date
Janik Besendorf 3f83d9ba6f Update GitHub Actions 2026-08-12 15:37:18 +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
Rory Flynn 8140e350f7 Update version.py (#854) 2026-07-29 13:57:34 +02:00
besendorf f483223e23 Add iOS sysdiagnose checking (#832)
* Add iOS sysdiagnose checking

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

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

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

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

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

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

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

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

---------

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

* fix dumpsys ADB multiline boundaries

---------

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

* Fix iOS backup path mypy typing

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

* Fix missing delay argument

* Fix VirusTotal delay handling

* Fix mypy type for VirusTotal package map

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* intrusion_logs: gate certificate authority install alert on success

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

---------

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

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

* Remove dead commented-out code in webkit_session_resource_log

* Remove stale FIXME comment in command.py

* Narrow bare except to specific exception types in convert_mactime_to_datetime

* Fix typo in aqf_files.py comment

* Refactor b64 encoding in configuration_profiles into helper methods

* Pass branch parameter to GitHub commits API in update checker

* Replace bare KeyError catch with explicit key check in net_base

* Remove confirmed Chrome database path TODOs

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

* Extract additional timestamps from WebKit ObservedDomains table

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

* Clarify command_line list format matches protobuf schema in tombstone parser

* Support SHA1 and MD5 hash matching in AQF files module

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

* Clean up code TODOs and type checks

* Fix WebKit timestamp schema handling
2026-06-05 20:15:56 +02:00
besendorf 3b2f923bd9 Fix accessibility service alerts (#807) 2026-06-05 19:59:28 +02:00
tes 11d06a3a16 fix: open timeline.csv and alerts_timeline.csv with write mode to prevent cross run contamination (#798) 2026-05-15 18:37:17 +02:00
164 changed files with 6686 additions and 1578 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
name: Add issue to project name: Add issue to project
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/add-to-project@v0.5.0 - uses: actions/add-to-project@v2
with: with:
# You can target a project in a different organization # You can target a project in a different organization
# to the issue # to the issue
+7 -4
View File
@@ -1,5 +1,8 @@
name: Mypy name: Mypy
on: workflow_dispatch on:
pull_request:
branches: [main]
workflow_dispatch:
jobs: jobs:
mypy_py3: mypy_py3:
@@ -8,13 +11,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: "3.10" python-version: "3.10"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v10.0.0
with: with:
enable-cache: true enable-cache: true
- name: Install Dependencies - name: Install Dependencies
+5 -6
View File
@@ -36,10 +36,10 @@ jobs:
tag-suffix: "-android" tag-suffix: "-android"
steps: steps:
- name: Checkout repository - 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. # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} 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. # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: | flavor: |
@@ -61,13 +61,13 @@ jobs:
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }} type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
# This step sets up some additional capabilities to generate the provenance and sbom attestations # This step sets up some additional capabilities to generate the provenance and sbom attestations
- name: Set up Docker Buildx - 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. # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image - name: Build and push Docker image
id: push id: push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
file: ${{ matrix.platform.dockerfile }} file: ${{ matrix.platform.dockerfile }}
context: . context: .
@@ -76,4 +76,3 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max provenance: mode=max
sbom: true sbom: true
+3 -3
View File
@@ -12,13 +12,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: "3.10" python-version: "3.10"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v10.0.0
with: with:
enable-cache: true enable-cache: true
- name: Install Dependencies - name: Install Dependencies
+3 -3
View File
@@ -15,13 +15,13 @@ jobs:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v7 uses: astral-sh/setup-uv@v10.0.0
with: with:
enable-cache: true enable-cache: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Python dependencies - name: Install Python dependencies
+5 -3
View File
@@ -11,10 +11,12 @@ jobs:
update-ios-version: update-ios-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v7
- uses: actions/setup-python@v4 - 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. - 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 - name: Create Pull Request
uses: peter-evans/create-pull-request@v8 uses: peter-evans/create-pull-request@v8
with: with:
+27
View File
@@ -60,6 +60,33 @@ For alternative installation options and known issues, please refer to the [docu
MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/) MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
### Shell completion
MVT can generate shell completion scripts for Bash, Zsh, and Fish:
```bash
mvt-ios completion
mvt-android completion
```
The commands print setup instructions by default. To generate a completion script directly, pass the shell name:
```bash
mvt-ios completion bash
mvt-android completion zsh
```
MVT only writes completion files or shell configuration when `--install` is passed. See the [command completion documentation](https://docs.mvt.re/en/latest/command_completion/) for details.
Module-running `check-*` commands can load custom Python modules with
`--load-module PATH` or from a folder set in `MVT_CUSTOM_MODULES`. See the
[development documentation](https://docs.mvt.re/en/latest/development/) for
details.
Users can also add top-level commands to `mvt-ios` and `mvt-android` from
installed Python packages or local files and folders. See the
[custom CLI command documentation](https://docs.mvt.re/en/latest/custom_commands/)
for the plugin entry points and `--load-command` interface.
## License ## 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. 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). 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.
+16
View File
@@ -38,6 +38,22 @@ By separating artifact collection from forensic analysis, this approach ensures
For more information, refer to the [AndroidQF project documentation](https://github.com/mvt-project/androidqf). For more information, refer to the [AndroidQF project documentation](https://github.com/mvt-project/androidqf).
### VirusTotal package lookups
AndroidQF records APK file hashes in `packages.json`. MVT can optionally look up non-system APK hashes on VirusTotal while checking an AndroidQF acquisition:
```bash
MVT_VT_API_KEY=<key> mvt-android check-androidqf --virustotal /path/to/androidqf-output
```
The `--virustotal` option is disabled by default because it sends APK hashes to VirusTotal and requires network access. It uses the `VT_API_KEY` MVT configuration value, which can also be provided through the `MVT_VT_API_KEY` environment variable.
To avoid exhausting free VirusTotal API quotas, MVT waits 16 seconds between package hash requests by default. Use `--delay` to change the delay, or `--delay 0` to disable throttling:
```bash
mvt-android check-androidqf --virustotal --delay 30 /path/to/androidqf-output
```
## Android Intrusion Logs ## Android Intrusion Logs
On devices where the user has opted into Android's [**Advanced Protection Mode**](https://support.google.com/android/answer/16339980) and turned on the optional Intrusion Logging featrue, Android can create and archive structured *Intrusion Logs* in an encrypted format. These logs record DNS queries, outbound network connections, process starts, ADB activity and other security-relevant events, and are a high-fidelity complement to the rest of an AndroidQF acquisition. The logs are generated on-device and encrypted before being stored in the Google account associated with the device. The encryption key is protected by the user device PIN. The intrusion log data is not accessible to Google. On devices where the user has opted into Android's [**Advanced Protection Mode**](https://support.google.com/android/answer/16339980) and turned on the optional Intrusion Logging featrue, Android can create and archive structured *Intrusion Logs* in an encrypted format. These logs record DNS queries, outbound network connections, process starts, ADB activity and other security-relevant events, and are a high-fidelity complement to the rest of an AndroidQF acquisition. The logs are generated on-device and encrypted before being stored in the Google account associated with the device. The encryption key is protected by the user device PIN. The intrusion log data is not accessible to Google.
+36 -13
View File
@@ -1,43 +1,66 @@
# Command Completion # Command Completion
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface. MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fish. Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fish.
To enable it, you need to manually register a special function with your shell, which varies depending on the shell you are using. To enable it, you need to register a completion script with your shell, which varies depending on the shell you are using.
The following describes how to generate the command completion scripts and add them to your shell configuration. The following describes how to generate the command completion scripts and add them to your shell configuration.
> **Note: You will need to start a new shell for the changes to take effect.** > **Note: You will need to start a new shell for the changes to take effect.**
### For Bash ### For Bash
```bash ```bash
# Generates bash completion scripts # Generate bash completion scripts
echo "$(_MVT_IOS_COMPLETE=bash_source mvt-ios)" > ~/.mvt-ios-complete.bash && mvt-ios completion bash > ~/.mvt-ios-complete.bash
echo "$(_MVT_ANDROID_COMPLETE=bash_source mvt-android)" > ~/.mvt-android-complete.bash mvt-android completion bash > ~/.mvt-android-complete.bash
``` ```
Add the following to `~/.bashrc`: Add the following to `~/.bashrc`:
```bash ```bash
# source mvt completion scripts # source mvt completion scripts
. ~/.mvt-ios-complete.bash && . ~/.mvt-android-complete.bash [ -f ~/.mvt-ios-complete.bash ] && . ~/.mvt-ios-complete.bash
[ -f ~/.mvt-android-complete.bash ] && . ~/.mvt-android-complete.bash
``` ```
### For Zsh ### For Zsh
```bash ```bash
# Generates zsh completion scripts # Generate zsh completion scripts
echo "$(_MVT_IOS_COMPLETE=zsh_source mvt-ios)" > ~/.mvt-ios-complete.zsh && mvt-ios completion zsh > ~/.mvt-ios-complete.zsh
echo "$(_MVT_ANDROID_COMPLETE=zsh_source mvt-android)" > ~/.mvt-android-complete.zsh mvt-android completion zsh > ~/.mvt-android-complete.zsh
``` ```
Add the following to `~/.zshrc`: Add the following to `~/.zshrc`:
```bash ```bash
# source mvt completion scripts # source mvt completion scripts
. ~/.mvt-ios-complete.zsh && . ~/.mvt-android-complete.zsh [ -f ~/.mvt-ios-complete.zsh ] && . ~/.mvt-ios-complete.zsh
[ -f ~/.mvt-android-complete.zsh ] && . ~/.mvt-android-complete.zsh
``` ```
### For Fish
```bash
# Generate fish completion scripts
mkdir -p ~/.config/fish/completions
mvt-ios completion fish > ~/.config/fish/completions/mvt-ios.fish
mvt-android completion fish > ~/.config/fish/completions/mvt-android.fish
```
Fish loads completion files from `~/.config/fish/completions` automatically.
### Automatic Installation
MVT can write the completion file and update the relevant shell configuration for Bash and Zsh when you pass `--install`:
```bash
mvt-ios completion bash --install
mvt-android completion bash --install
```
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/completions`.
For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion). For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion).
+107
View File
@@ -0,0 +1,107 @@
# Custom CLI Commands
MVT can load additional top-level commands into `mvt-ios` and `mvt-android`.
Custom commands are different from [custom forensic modules](development.md#custom-modules):
commands add new CLI operations, while modules add analysis steps to existing
`check-*` commands.
!!! warning
Custom commands run as trusted Python code inside the MVT process. Install
or load commands only from sources you trust. MVT does not sandbox
third-party commands, and the MVT maintainers do not maintain them.
## Install a Command Package
Python packages can register a Click command or group for either MVT CLI. A
minimal package can expose this command from `my_mvt_plugin.py`:
```python
import click
@click.command()
@click.argument("path", type=click.Path(exists=True))
def summarize(path):
"""Summarize an acquisition."""
click.echo(f"Summarizing {path}")
```
Register the object in the package's `pyproject.toml`. The entry-point name is
the command users invoke:
```toml
[project.entry-points."mvt.ios.cli_plugins"]
summarize = "my_mvt_plugin:summarize"
[project.entry-points."mvt.android.cli_plugins"]
summarize = "my_mvt_plugin:summarize"
```
Use only the iOS or Android group if the command is platform-specific. After
installing the package in the same environment as MVT, it appears directly in
the appropriate CLI:
```bash
mvt-ios summarize ./ios-backup
mvt-android summarize ./androidqf-output
```
For a `pipx` installation of MVT, inject the plugin into MVT's environment:
```bash
pipx inject mvt my-mvt-plugin
```
When MVT is installed in an active virtual environment, install the plugin with
`pip` in that environment.
## Load a Command File
For local commands that are not packaged, create a Python file that exports one
Click command or group named `cli`:
```python
import click
@click.command("case-summary")
@click.argument("path", type=click.Path(exists=True))
def cli(path):
"""Summarize a case directory."""
click.echo(f"Summarizing {path}")
```
Pass the file before the custom command name:
```bash
mvt-ios --load-command ./case_summary.py case-summary ./ios-backup
```
`--load-command` can be repeated and also accepts a folder. MVT loads
non-hidden top-level `*.py` files in sorted order and skips `__init__.py`.
Every loaded file must export one `cli` object.
To load a file or folder on every invocation, set the platform-specific
environment variable:
```bash
export MVT_IOS_CUSTOM_COMMANDS=./ios_commands
export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands
```
## Naming and Errors
Built-in MVT commands cannot be replaced. External command names must also be
unique; when installed packages or environment paths collide, MVT keeps the
first command and logs a warning. A collision from an explicit
`--load-command` is a usage error.
A package entry point or environment command that cannot be imported appears
as a marked broken command without preventing other MVT commands from working.
Invoke that command to see its package or file source and the underlying error.
An invalid command supplied explicitly with `--load-command` fails immediately
with a usage error.
Installed command packages use the entry-point name as the CLI command name.
The entry point must resolve to a `click.Command` or `click.Group`.
+96
View File
@@ -21,6 +21,102 @@ make check
Run these tests before making new commits or opening pull requests. Run these tests before making new commits or opening pull requests.
## Module dependencies
Modules can require other modules to run first by declaring their classes in
`dependencies`. The command runner uses a stable topological ordering, so the
existing module list order is preserved wherever dependency constraints allow.
```python
class DependentModule(MVTModule):
dependencies = (PrerequisiteModule,)
def run(self):
prerequisite_results = self.get_dependency_results(PrerequisiteModule)
```
Selecting a single module also runs its transitive dependencies. If a dependency
is unavailable or the dependency graph contains a cycle, the command logs a
warning and does not run any modules.
## Custom modules
Module-running `check-*` commands can load custom modules from Python files that
are not installed as part of MVT. Load one file with:
```bash
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
```
You can also load a folder. MVT loads non-hidden top-level `*.py` files in
sorted order and skips `__init__.py`:
```bash
mvt-ios check-fs --load-module ./custom_modules ./filesystem-dump
```
Set `MVT_CUSTOM_MODULES` to load a folder for every module-running command. This
folder is loaded before any `--load-module` path:
```bash
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
```
Custom modules are normal `MVTModule` subclasses:
```python
from mvt.common.module import MVTModule
class ExampleCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"), ("ios", "check-fs"))
slug = "example_custom_module"
def run(self):
self.results = [{"message": "custom module ran"}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
Use `supported_commands` to declare the platform/command pairs a module
supports. Empty `supported_commands` means the module will not run and MVT logs
a warning. This explicit declaration is required for every command. Supported
pairs are:
```python
("ios", "check-backup")
("ios", "check-fs")
("ios", "check-iocs")
("ios", "check-sysdiagnose")
("android", "check-backup")
("android", "check-bugreport")
("android", "check-androidqf")
("android", "check-intrusion-logs")
("android", "check-iocs")
```
Custom modules can depend on existing MVT module classes. Dependencies are
resolved with the same ordering logic as built-in modules, and custom modules
are appended after built-ins before ordering:
```python
from mvt.common.module import MVTModule
from mvt.ios.modules.backup.manifest import Manifest
class DependentCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"),)
dependencies = (Manifest,)
def run(self):
manifest_results = self.get_dependency_results(Manifest)
self.results = [{"manifest_entries": len(manifest_results)}]
```
## Profiling ## Profiling
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
+21 -2
View File
@@ -34,6 +34,27 @@ It is also possible to load STIX2 files automatically from the environment varia
export MVT_STIX2="/home/user/IOC1.stix2:/home/user/IOC2.stix2" export MVT_STIX2="/home/user/IOC1.stix2:/home/user/IOC2.stix2"
``` ```
## Network Access
When checking URL indicators, MVT follows recognized shortened URLs with an
HTTP `HEAD` request. URL checks are deduplicated and run concurrently, with at
most 20 requests in progress at a time. Redirects within an individual URL
chain are still followed sequentially. The following environment variables
control these requests:
- `MVT_NETWORK_ACCESS_ALLOWED` enables or disables network requests. It defaults
to `true`. Set it to `false` to prevent MVT from attempting to resolve
shortened URLs.
- `MVT_NETWORK_TIMEOUT` sets the request timeout in seconds. It defaults to
`15`.
For example, to run IOC checks without resolving shortened URLs:
```bash
MVT_NETWORK_ACCESS_ALLOWED=false mvt-ios check-iocs \
--iocs ~/iocs/malware.stix2 /path/to/iphone/output/
```
## STIX2 Support ## STIX2 Support
So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis-open.org/cti/stix/v2.1/csprd01/stix-v2.1-csprd01.html): So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis-open.org/cti/stix/v2.1/csprd01/stix-v2.1-csprd01.html):
@@ -54,5 +75,3 @@ You can automaticallly download the latest public indicator files with the comma
Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs. Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs.
+9
View File
@@ -312,6 +312,15 @@ If indicators are provided through the command-line, they are checked against th
--- ---
### `urls.json`
This JSON file collects URLs extracted from SMS, iMessage, and WhatsApp
messages. Each entry contains the original URL, its expanded destination when
MVT resolved a shortened URL during indicator checking, the message timestamp,
and its `sms` or `whatsapp` source.
---
### `sms_attachments.json` ### `sms_attachments.json`
!!! info "Availability" !!! info "Availability"
+50
View File
@@ -0,0 +1,50 @@
# Check an iOS Sysdiagnose
`mvt-ios check-sysdiagnose` prepares an iOS sysdiagnose archive for analysis by
custom MVT modules. MVT does not include built-in sysdiagnose modules. You must
load at least one custom module that explicitly supports this command.
The command accepts either an extracted sysdiagnose directory or the original
gzip-compressed tar archive.
```bash
mvt-ios check-sysdiagnose \
--load-module ./sysdiagnose_modules.py \
--output ./results \
./sysdiagnose_2024.01.02_03-04-05+0200.tar.gz
```
Use `--hashes` to include hashes for analyzed files in `info.json`, and
`--list-modules` to display the eligible custom modules without running them.
## Writing a custom module
Extend `SysdiagnoseExtraction` to access the archive contents consistently for
both directory and tar inputs. Each module must declare the command explicitly
in `supported_commands`.
```python
from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
supported_commands = (("ios", "check-sysdiagnose"),)
slug = "example_sysdiagnose"
def run(self):
paths = self._get_files_by_pattern("*/example.log")
if paths:
content = self._get_file_content(paths[0]).decode("utf-8", "replace")
self.results = [{"content": content}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
The base class provides `from_sysdiagnose_folder()` and
`from_sysdiagnose_tar()` setup hooks, as well as protected file lookup, file
reading, and timezone extraction helpers. IPS crash-report metadata is exposed
on `ips_files`.
+5
View File
@@ -3,6 +3,8 @@ repo_url: https://github.com/mvt-project/mvt
edit_uri: edit/main/docs/ edit_uri: edit/main/docs/
copyright: Copyright &copy; 2021-2023 MVT Project Developers copyright: Copyright &copy; 2021-2023 MVT Project Developers
site_description: Mobile Verification Toolkit Documentation site_description: Mobile Verification Toolkit Documentation
not_in_nav: |
android/adb.md
markdown_extensions: markdown_extensions:
- attr_list - attr_list
- admonition - admonition
@@ -28,6 +30,8 @@ nav:
- Welcome: "index.md" - Welcome: "index.md"
- Introduction: "introduction.md" - Introduction: "introduction.md"
- Installation: "install.md" - Installation: "install.md"
- Command Completion: "command_completion.md"
- Custom CLI Commands: "custom_commands.md"
- Using Docker: "docker.md" - Using Docker: "docker.md"
- MVT for iOS: - MVT for iOS:
- iOS Forensic Methodology: "ios/methodology.md" - iOS Forensic Methodology: "ios/methodology.md"
@@ -39,6 +43,7 @@ nav:
- Check a Filesystem Dump: - Check a Filesystem Dump:
- Dumping the filesystem: "ios/filesystem/dump.md" - Dumping the filesystem: "ios/filesystem/dump.md"
- Check a Filesystem Dump with mvt-ios: "ios/filesystem/check.md" - Check a Filesystem Dump with mvt-ios: "ios/filesystem/check.md"
- Check a Sysdiagnose: "ios/sysdiagnose.md"
- Records extracted by mvt-ios: "ios/records.md" - Records extracted by mvt-ios: "ios/records.md"
- MVT for Android: - MVT for Android:
- Android Forensic Methodology: "android/methodology.md" - Android Forensic Methodology: "android/methodology.md"
+15 -15
View File
@@ -17,25 +17,25 @@ classifiers = [
"Programming Language :: Python", "Programming Language :: Python",
] ]
dependencies = [ dependencies = [
"click==8.3.2", "click==8.4.2",
"rich==14.3.3", "rich==15.0.0",
"tld==0.13.1", "tld==0.13.2",
"requests==2.33.1", "requests==2.34.2",
"simplejson==3.20.2", "simplejson==4.1.1",
"packaging==26.0", "packaging==26.3",
"appdirs==1.4.4", "appdirs==1.4.4",
"iOSbackup==0.9.925", "iOSbackup==0.9.925",
"adb-shell[usb]==0.4.4", "adb-shell[usb]==0.4.4",
"libusb1==3.3.1", "libusb1==3.4.0",
"cryptography==47.0.0", "cryptography==50.0.0",
"PyYAML>=6.0.2", "PyYAML>=6.0.2",
"pyahocorasick==2.2.0", "pyahocorasick==2.3.1",
"betterproto2==0.9.1", "betterproto2==0.10.0",
"pydantic==2.13.3", "pydantic==2.13.4",
"pydantic-settings==2.13.1", "pydantic-settings==2.15.0",
"NSKeyedUnArchiver==1.5.2", "NSKeyedUnArchiver==1.5.2",
"python-dateutil==2.9.0.post0", "python-dateutil==2.9.0.post0",
"tzdata==2026.2", "tzdata==2026.3",
] ]
requires-python = ">= 3.10" requires-python = ">= 3.10"
@@ -65,9 +65,9 @@ dev = [
docs = [ docs = [
"mkdocs==1.6.1", "mkdocs==1.6.1",
"mkdocs-autorefs==1.4.4", "mkdocs-autorefs==1.4.4",
"mkdocs-material==9.7.6", "mkdocs-material==9.7.7",
"mkdocs-material-extensions==1.3.1", "mkdocs-material-extensions==1.3.1",
"mkdocstrings==1.0.4", "mkdocstrings==1.0.6",
] ]
[build-system] [build-system]
@@ -10,16 +10,20 @@ from .artifact import AndroidArtifact
class DumpsysAccessibilityArtifact(AndroidArtifact): class DumpsysAccessibilityArtifact(AndroidArtifact):
def check_indicators(self) -> None: def check_indicators(self) -> None:
if not self.indicators:
return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"]) if self.indicators:
if ioc_match: ioc_match = self.indicators.check_app_id(result["package_name"])
self.alertstore.critical( if ioc_match:
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
continue
self.alertstore.medium(
f'Found accessibility service: "{result["service"]}"',
"",
result,
) )
continue
def parse(self, content: str) -> None: def parse(self, content: str) -> None:
""" """
+29 -7
View File
@@ -13,6 +13,13 @@ from .artifact import AndroidArtifact
class DumpsysADBArtifact(AndroidArtifact): class DumpsysADBArtifact(AndroidArtifact):
multiline_fields = ["user_keys", "keystore"] multiline_fields = ["user_keys", "keystore"]
@staticmethod
def is_structural_line(key: str, vals: list) -> bool:
if key == "}":
return True
# XML keystore continuations also split on "=", but never into an identifier.
return len(vals) == 2 and key.isidentifier()
def indented_dump_parser(self, dump_data): def indented_dump_parser(self, dump_data):
""" """
Parse the indented dumpsys output, generated by DualDumpOutputStream in Android. Parse the indented dumpsys output, generated by DualDumpOutputStream in Android.
@@ -41,10 +48,18 @@ class DumpsysADBArtifact(AndroidArtifact):
if key == "": if key == "":
# If the line is empty, it's the terminator for the multiline value # If the line is empty, it's the terminator for the multiline value
in_multiline = False in_multiline = False
stack.pop() if isinstance(stack[-1], list):
else: stack.pop()
continue
if not self.is_structural_line(key, vals):
current_dict.append(line.lstrip()) current_dict.append(line.lstrip())
continue continue
in_multiline = False
if isinstance(stack[-1], list):
stack.pop()
current_dict = stack[-1]
if key == "}": if key == "}":
stack.pop() stack.pop()
@@ -131,10 +146,17 @@ class DumpsysADBArtifact(AndroidArtifact):
) )
return return
# TODO: Parse AdbDebuggingManager line in output. start_of_json = content.find(b"\n{")
start_of_json = content.find(b"\n{") + 2 if start_of_json == -1:
end_of_json = content.rfind(b"}\n") - 2 self.log.error("Unable to find ADB manager state in dumpsys output")
json_content = content[start_of_json:end_of_json].rstrip() return
end_of_json = content.rfind(b"}\n")
if end_of_json == -1 or end_of_json <= start_of_json:
self.log.error("Unable to find complete ADB manager state in dumpsys output")
return
json_content = content[start_of_json + 2 : end_of_json - 2].rstrip()
parsed = self.indented_dump_parser(json_content) parsed = self.indented_dump_parser(json_content)
if parsed.get("debugging_manager") is None: if parsed.get("debugging_manager") is None:
@@ -50,13 +50,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
daily = None daily = None
daily_updates: list[dict[str, Any]] = [] daily_updates: list[dict[str, Any]] = []
package_versions: dict[ records: list[dict[str, Any]] = []
str, str
] = {} # Track package versions to detect downgrades
for line in output.splitlines(): for line in output.splitlines():
if line.startswith(" Daily from "): if line.startswith(" Daily from "):
if len(daily_updates) > 0: if len(daily_updates) > 0:
self.results.extend(daily_updates) records.extend(daily_updates)
daily_updates = [] daily_updates = []
timeframe = line[13:].strip() timeframe = line[13:].strip()
@@ -89,35 +87,53 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
"vers": vers_nr, "vers": vers_nr,
} }
# Check for uninstall (version 0)
if vers_nr == "0":
self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)",
daily["from"],
update_record,
)
# Check for downgrade
elif package_name in package_versions:
try:
current_vers = int(vers_nr)
previous_vers = int(package_versions[package_name])
if current_vers < previous_vers:
update_record["action"] = "downgrade"
update_record["previous_vers"] = str(previous_vers)
self.alertstore.medium(
f"Detected downgrade of package {package_name} "
f"from vers {previous_vers} to vers {current_vers}",
daily["from"],
update_record,
)
except ValueError:
# If version numbers aren't integers, skip comparison
pass
# Update tracking dictionary
package_versions[package_name] = vers_nr
daily_updates.append(update_record) daily_updates.append(update_record)
if len(daily_updates) > 0: if len(daily_updates) > 0:
self.results.extend(daily_updates) records.extend(daily_updates)
self._detect_uninstalls_and_downgrades(records)
self.results.extend(records)
def _detect_uninstalls_and_downgrades(
self, records: list[dict[str, Any]]
) -> None:
package_versions: dict[str, int] = {}
for record in sorted(
records,
key=lambda record: (
record["from"],
record["to"],
record["package_name"],
),
):
package_name = record["package_name"]
vers_nr = record["vers"]
if vers_nr == "0":
self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)",
record["from"],
record,
)
package_versions.pop(package_name, None)
continue
try:
current_vers = int(vers_nr)
except ValueError:
continue
previous_vers = package_versions.get(package_name)
if previous_vers is not None and current_vers < previous_vers:
record["action"] = "downgrade"
record["previous_vers"] = str(previous_vers)
self.alertstore.medium(
f"Detected downgrade of package {package_name} "
f"from vers {previous_vers} to vers {current_vers}",
record["from"],
record,
)
package_versions[package_name] = current_vers
@@ -31,21 +31,38 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact):
if line.strip() == "": if line.strip() == "":
break break
time_elapsed = line.strip().split(" ", 1)[0] time_parts = line.strip().split()
time_elapsed = time_parts[0]
if (
len(time_parts) > 1
and len(time_parts[0]) == 5
and time_parts[0][2] == "-"
and ":" in time_parts[1]
):
time_elapsed = " ".join(time_parts[:2])
event = "" event = ""
if line.find("+job") > 0: if line.find("+job") > 0:
event = "start_job" event = "start_job"
uid = line[line.find("+job") + 5 : line.find(":")] payload = line.split("+job=", 1)[1]
service = line[line.find(":") + 1 :].strip('"') uid, separator, service = payload.partition(":")
if not separator:
continue
service = service.strip().strip('"')
package_name = service.split("/")[0] package_name = service.split("/")[0]
elif line.find("-job") > 0: elif line.find("-job") > 0:
event = "end_job" event = "end_job"
uid = line[line.find("-job") + 5 : line.find(":")] payload = line.split("-job=", 1)[1]
service = line[line.find(":") + 1 :].strip('"') uid, separator, service = payload.partition(":")
if not separator:
continue
service = service.strip().strip('"')
package_name = service.split("/")[0] package_name = service.split("/")[0]
elif line.find("+running +wake_lock=") > 0: elif line.find("+running +wake_lock=") > 0:
uid = line[line.find("+running +wake_lock=") + 21 : line.find(":")] payload = line.split("+running +wake_lock=", 1)[1]
uid, separator, _ = payload.partition(":")
if not separator:
continue
event = "wake" event = "wake"
service = ( service = (
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip() line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
+15 -29
View File
@@ -29,10 +29,9 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
rxp = re.compile( rxp = re.compile(
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\].*\[Pid:\((\d+)\)\](\w+).*sql\=\"(.+?)\"" r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} "
) # pylint: disable=line-too-long r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*"
rxp_no_pid = re.compile( r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\""
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\][ ]{1}(\w+).*sql\=\"(.+?)\""
) # pylint: disable=line-too-long ) # pylint: disable=line-too-long
pool = None pool = None
@@ -56,29 +55,16 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
pool = None pool = None
continue continue
matches = rxp.findall(line) match = rxp.match(line)
if not matches: if not match:
matches = rxp_no_pid.findall(line) continue
if not matches:
continue
match = matches[0] result = {
self.results.append( "isodate": match.group(1),
{ "action": match.group(3),
"isodate": match[0], "sql": match.group(4),
"action": match[1], "path": pool,
"sql": match[2], }
"path": pool, if match.group(2):
} result["pid"] = match.group(2)
) self.results.append(result)
else:
match = matches[0]
self.results.append(
{
"isodate": match[0],
"pid": match[1],
"action": match[2],
"sql": match[3],
"path": pool,
}
)
@@ -14,9 +14,12 @@ from .artifact import AndroidArtifact
class DumpsysPackagesArtifact(AndroidArtifact): class DumpsysPackagesArtifact(AndroidArtifact):
def check_indicators(self) -> None: def check_indicators(self) -> None:
alerted_root_packages = set()
for result in self.results: for result in self.results:
# XXX: De-duplication Package detections
if result["package_name"] in ROOT_PACKAGES: if result["package_name"] in ROOT_PACKAGES:
if result["package_name"] in alerted_root_packages:
continue
alerted_root_packages.add(result["package_name"])
self.alertstore.medium( self.alertstore.medium(
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"', f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
"", "",
@@ -188,7 +191,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
package = [] package = []
in_package_list = False in_package_list = False
for line in content.split("\n"): for line in content.splitlines():
if line.startswith("Packages:"): if line.startswith("Packages:"):
in_package_list = True in_package_list = True
continue continue
+13 -1
View File
@@ -96,6 +96,18 @@ class DumpsysReceiversArtifact(AndroidArtifact):
self.results[intent] = [] self.results[intent] = []
continue 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 we are not in an intent block yet, skip.
if not intent: if not intent:
continue continue
@@ -109,7 +121,7 @@ class DumpsysReceiversArtifact(AndroidArtifact):
# If we got this far, we are processing receivers for the # If we got this far, we are processing receivers for the
# activities we are interested in. # activities we are interested in.
receiver = line.strip().split(" ")[1] receiver = parts[1]
package_name = receiver.split("/")[0] package_name = receiver.split("/")[0]
self.results[intent].append( self.results[intent].append(
+1 -1
View File
@@ -8,7 +8,7 @@ from .artifact import AndroidArtifact
class Processes(AndroidArtifact): class Processes(AndroidArtifact):
def parse(self, entry: str) -> None: def parse(self, entry: str) -> None:
for line in entry.split("\n")[1:]: for line in entry.splitlines()[1:]:
proc = line.split() proc = line.split()
# Skip empty lines # Skip empty lines
+9 -6
View File
@@ -67,11 +67,14 @@ class Settings(AndroidArtifact):
# Check if one of the dangerous settings is using an unsafe # Check if one of the dangerous settings is using an unsafe
# value (different than the one specified). # value (different than the one specified).
if danger["key"] == key and danger["safe_value"] != value: if danger["key"] == key and danger["safe_value"] != value:
self.log.warning( self.alertstore.medium(
'Found suspicious "%s" setting "%s = %s" (%s)', f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})',
namespace, "",
key, {
value, "namespace": namespace,
danger["description"], "key": key,
"value": value,
"description": danger["description"],
},
) )
break break
+11 -4
View File
@@ -34,6 +34,7 @@ TOMBSTONE_TEXT_KEY_MAPPINGS = {
"signal": "signal_info", "signal": "signal_info",
"code": "code", "code": "code",
"Cause": "cause", "Cause": "cause",
"Abort message": "abort_message",
} }
@@ -67,6 +68,8 @@ class TombstoneCrashResult(pydantic.BaseModel):
uid: int uid: int
signal_info: SignalInfo signal_info: SignalInfo
cause: Optional[str] = None cause: Optional[str] = None
causes: Optional[List[dict]] = None
abort_message: Optional[str] = None
extra: Optional[str] = None extra: Optional[str] = None
@@ -101,8 +104,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
continue continue
if result.get("command_line", []): if result.get("command_line", []):
command_name = result.get("command_line")[0].split("/")[-1] command_name = result["command_line"][0].split("/")[-1]
command_name = result["command_line"][0]
ioc_match = self.indicators.check_process(command_name) ioc_match = self.indicators.check_process(command_name)
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
@@ -200,13 +202,18 @@ class TombstoneCrashArtifact(AndroidArtifact):
# eg. "Process uptime: 40s" # eg. "Process uptime: 40s"
tombstone[destination_key] = int(value_clean.rstrip("s")) tombstone[destination_key] = int(value_clean.rstrip("s"))
elif destination_key == "command_line": elif destination_key == "command_line":
# XXX: Check if command line should be a single string in a list, or a list of strings. # Wrap in list for consistency with protobuf format (repeated string).
tombstone[destination_key] = [value_clean] tombstone[destination_key] = [value_clean]
else: else:
tombstone[destination_key] = value_clean tombstone[destination_key] = value_clean
return True return True
def _load_pid_line(self, line: str, tombstone: dict) -> bool: def _load_pid_line(self, line: str, tombstone: dict) -> bool:
# The first pid line identifies the crashing thread. Full text tombstones
# contain additional pid lines for the other threads in the process.
if "pid" in tombstone:
return True
try: try:
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>") parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
process_info = parts[0] process_info = parts[0]
@@ -262,7 +269,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
@staticmethod @staticmethod
def _parse_timestamp_string(timestamp: str) -> str: def _parse_timestamp_string(timestamp: str) -> str:
timestamp_parsed = parser.parse(timestamp) timestamp_parsed = parser.parse(timestamp)
# HACK: Swap the local timestamp to UTC, so keep the original time and avoid timezone conversion. # Preserve the source wall-clock time while returning the project-wide ISO format.
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc) local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
return convert_datetime_to_iso(local_timestamp) return convert_datetime_to_iso(local_timestamp)
+151 -8
View File
@@ -4,10 +4,23 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging import logging
from zipfile import BadZipFile
import click 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.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.help import ( from mvt.common.help import (
HELP_MSG_ANDROID_BACKUP_PASSWORD, HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_ADB_REMOVED, HELP_MSG_CHECK_ADB_REMOVED,
@@ -17,19 +30,24 @@ from mvt.common.help import (
HELP_MSG_CHECK_BUGREPORT, HELP_MSG_CHECK_BUGREPORT,
HELP_MSG_CHECK_IOCS, HELP_MSG_CHECK_IOCS,
HELP_MSG_CHECK_INTRUSION_LOGS, HELP_MSG_CHECK_INTRUSION_LOGS,
HELP_MSG_DELAY_CHECKS,
HELP_MSG_COMPLETION,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK, HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_HASHES, HELP_MSG_HASHES,
HELP_MSG_IOC, HELP_MSG_IOC,
HELP_MSG_LIST_MODULES, HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE, HELP_MSG_MODULE,
HELP_MSG_NONINTERACTIVE, HELP_MSG_NONINTERACTIVE,
HELP_MSG_OUTPUT, HELP_MSG_OUTPUT,
HELP_MSG_STIX2, HELP_MSG_STIX2,
HELP_MSG_VERBOSE, HELP_MSG_VERBOSE,
HELP_MSG_VERSION, HELP_MSG_VERSION,
HELP_MSG_VIRUS_TOTAL,
) )
from mvt.common.logo import logo from mvt.common.logo import logo
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.updates import IndicatorsUpdates from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import init_logging, set_verbose_logging from mvt.common.utils import init_logging, set_verbose_logging
@@ -59,10 +77,18 @@ def _get_disable_flags(ctx):
) )
def _load_custom_modules(load_module):
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
raise click.ClickException(str(exc)) from exc
# ============================================================================== # ==============================================================================
# Main # Main
# ============================================================================== # ==============================================================================
@click.group(invoke_without_command=False) @click.group(invoke_without_command=False)
@load_cli_commands_option
@click.option( @click.option(
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK "--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
) )
@@ -76,10 +102,11 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check ctx.obj["disable_indicator_check"] = disable_indicator_update_check
logo( if ctx.invoked_subcommand != "completion":
disable_version_check=disable_update_check, logo(
disable_indicator_check=disable_indicator_update_check, disable_version_check=disable_update_check,
) disable_indicator_check=disable_indicator_update_check,
)
# ============================================================================== # ==============================================================================
@@ -90,6 +117,40 @@ def version():
return return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-android"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ============================================================================== # ==============================================================================
# Command: check-adb (removed) # Command: check-adb (removed)
# ============================================================================== # ==============================================================================
@@ -119,11 +180,28 @@ def check_adb(ctx):
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT) @click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True)) @click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_path): def check_bugreport(
ctx,
iocs,
output,
list_modules,
module,
load_module,
verbose,
bugreport_path,
):
set_verbose_logging(verbose) set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as bug reports are small. # Always generate hashes as bug reports are small.
cmd = CmdAndroidCheckBugreport( cmd = CmdAndroidCheckBugreport(
target_path=bugreport_path, target_path=bugreport_path,
@@ -133,6 +211,7 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
hashes=True, hashes=True,
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
@@ -141,7 +220,10 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
log.info("Checking Android bug report at path: %s", bugreport_path) log.info("Checking Android bug report at path: %s", bugreport_path)
cmd.run() try:
cmd.run()
except BadZipFile as exc:
raise click.ClickException(f"Invalid bugreport archive: {exc}") from exc
cmd.show_alerts_brief() cmd.show_alerts_brief()
cmd.show_support_message() cmd.show_support_message()
@@ -164,6 +246,13 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
) )
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT) @click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE) @click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD) @click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@@ -174,12 +263,14 @@ def check_backup(
iocs, iocs,
output, output,
list_modules, list_modules,
load_module,
non_interactive, non_interactive,
backup_password, backup_password,
verbose, verbose,
backup_path, backup_path,
): ):
set_verbose_logging(verbose) set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as backups are generally small. # Always generate hashes as backups are generally small.
cmd = CmdAndroidCheckBackup( cmd = CmdAndroidCheckBackup(
@@ -193,6 +284,7 @@ def check_backup(
}, },
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
@@ -223,7 +315,18 @@ def check_backup(
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT) @click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--virustotal", "-V", is_flag=True, help=HELP_MSG_VIRUS_TOTAL)
@click.option(
"--delay", "-d", type=click.IntRange(min=0), default=16, help=HELP_MSG_DELAY_CHECKS
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE) @click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD) @click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@@ -235,13 +338,17 @@ def check_androidqf(
output, output,
list_modules, list_modules,
module, module,
load_module,
hashes, hashes,
virustotal,
delay,
non_interactive, non_interactive,
backup_password, backup_password,
verbose, verbose,
androidqf_path, androidqf_path,
): ):
set_verbose_logging(verbose) set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
cmd = CmdAndroidCheckAndroidQF( cmd = CmdAndroidCheckAndroidQF(
target_path=androidqf_path, target_path=androidqf_path,
@@ -252,9 +359,12 @@ def check_androidqf(
module_options={ module_options={
"interactive": not non_interactive, "interactive": not non_interactive,
"backup_password": cli_load_android_backup_password(log, backup_password), "backup_password": cli_load_android_backup_password(log, backup_password),
"virustotal": virustotal,
"virustotal_delay": delay,
}, },
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
@@ -288,6 +398,13 @@ def check_androidqf(
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT) @click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option( @click.option(
"--timezone", "--timezone",
"-t", "-t",
@@ -307,11 +424,13 @@ def check_intrusion_logs(
output, output,
list_modules, list_modules,
module, module,
load_module,
timezone, timezone,
verbose, verbose,
logs_path, logs_path,
): ):
set_verbose_logging(verbose) set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
module_options = {} module_options = {}
if timezone: if timezone:
@@ -325,6 +444,7 @@ def check_intrusion_logs(
module_options=module_options if module_options else None, module_options=module_options if module_options else None,
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
@@ -352,10 +472,26 @@ def check_intrusion_logs(
) )
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.argument("FOLDER", type=click.Path(exists=True)) @click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_iocs(ctx, iocs, list_modules, module, folder): def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
cmd = CmdCheckIOCS(target_path=folder, ioc_files=iocs, module_name=module) custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS(
target_path=folder,
ioc_files=iocs,
module_name=module,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
platform="android",
)
cmd.modules = ( cmd.modules = (
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
) )
@@ -376,3 +512,10 @@ def check_iocs(ctx, iocs, list_modules, module, folder):
def download_indicators(): def download_indicators():
ioc_updates = IndicatorsUpdates() ioc_updates = IndicatorsUpdates()
ioc_updates.update() ioc_updates.update()
register_cli_plugins(
cli,
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
)
+71 -5
View File
@@ -3,6 +3,7 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import json
import logging import logging
import os import os
import shutil import shutil
@@ -13,10 +14,11 @@ from typing import List, Optional
from mvt.android.artifacts.getprop import GetProp from mvt.android.artifacts.getprop import GetProp
from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs from mvt.android.cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup from mvt.android.cmd_check_backup import CmdAndroidCheckBackup, InvalidAndroidBackup
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.androidqf import ANDROIDQF_MODULES from .modules.androidqf import ANDROIDQF_MODULES
from .modules.androidqf.base import AndroidQFModule from .modules.androidqf.base import AndroidQFModule
@@ -50,6 +52,7 @@ class CmdAndroidCheckAndroidQF(Command):
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -64,8 +67,10 @@ class CmdAndroidCheckAndroidQF(Command):
log=log, log=log,
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "android"
self.name = "check-androidqf" self.name = "check-androidqf"
self.modules = ANDROIDQF_MODULES self.modules = ANDROIDQF_MODULES
@@ -90,6 +95,47 @@ class CmdAndroidCheckAndroidQF(Command):
self.__zip = zipfile.ZipFile(self.target_path) self.__zip = zipfile.ZipFile(self.target_path)
self.__files = self.__zip.namelist() 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] def module_init(self, module: AndroidQFModule) -> None: # type: ignore[override]
if self.__format == "zip" and self.__zip: if self.__format == "zip" and self.__zip:
module.from_zip(self.__zip, self.__files) module.from_zip(self.__zip, self.__files)
@@ -210,11 +256,13 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=self.module_options, module_options=self.module_options,
hashes=self.hashes, hashes=self.hashes,
sub_command=True, sub_command=True,
custom_modules=self.custom_modules,
) )
cmd.from_zip(bugreport) cmd.from_zip(bugreport)
cmd.run() cmd.run()
self.timeline.extend(cmd.timeline) self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts) self.alertstore.extend(cmd.alertstore.alerts)
finally: finally:
if bugreport: if bugreport:
@@ -239,11 +287,20 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=self.module_options, module_options=self.module_options,
hashes=self.hashes, hashes=self.hashes,
sub_command=True, sub_command=True,
custom_modules=self.custom_modules,
) )
cmd.from_ab(backup) try:
cmd.from_ab(backup)
except InvalidAndroidBackup as exc:
self.log.warning(
"Skipping backup modules as backup.ab is malformed: %s", exc
)
return False
cmd.run() cmd.run()
self.timeline.extend(cmd.timeline) self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts) self.alertstore.extend(cmd.alertstore.alerts)
return True return True
@@ -283,6 +340,7 @@ class CmdAndroidCheckAndroidQF(Command):
elif self.__format == "zip" and self.__zip: elif self.__format == "zip" and self.__zip:
temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_") temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_")
temp_root = Path(temp_dir).resolve()
for entry in intrusion_log_files: for entry in intrusion_log_files:
normalized = entry.replace("\\", "/") normalized = entry.replace("\\", "/")
idx = normalized.find("intrusion_logs/") idx = normalized.find("intrusion_logs/")
@@ -290,9 +348,15 @@ class CmdAndroidCheckAndroidQF(Command):
if not relative or relative.endswith("/"): if not relative or relative.endswith("/"):
continue continue
target = os.path.join(temp_dir, relative) target = (temp_root / relative).resolve()
os.makedirs(os.path.dirname(target), exist_ok=True) if not target.is_relative_to(temp_root):
with self.__zip.open(entry) as src, open(target, "wb") as dst: self.log.warning(
"Skipping unsafe intrusion log archive entry: %s", entry
)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with self.__zip.open(entry) as src, target.open("wb") as dst:
dst.write(src.read()) dst.write(src.read())
intrusion_logs_path = temp_dir intrusion_logs_path = temp_dir
@@ -311,10 +375,12 @@ class CmdAndroidCheckAndroidQF(Command):
module_options=adv_module_options, module_options=adv_module_options,
hashes=self.hashes, hashes=self.hashes,
sub_command=True, sub_command=True,
custom_modules=self.custom_modules,
) )
cmd.run() cmd.run()
self.timeline.extend(cmd.timeline) self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts) self.alertstore.extend(cmd.alertstore.alerts)
return True return True
+26 -1
View File
@@ -21,12 +21,17 @@ from mvt.android.parsers.backup import (
) )
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class InvalidAndroidBackup(Exception):
pass
class CmdAndroidCheckBackup(Command): class CmdAndroidCheckBackup(Command):
def __init__( def __init__(
self, self,
@@ -41,6 +46,7 @@ class CmdAndroidCheckBackup(Command):
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -55,8 +61,10 @@ class CmdAndroidCheckBackup(Command):
log=log, log=log,
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "android"
self.name = "check-backup" self.name = "check-backup"
self.modules = BACKUP_MODULES self.modules = BACKUP_MODULES
@@ -68,6 +76,10 @@ class CmdAndroidCheckBackup(Command):
self.__type = "ab" self.__type = "ab"
header = parse_ab_header(ab_file_bytes) header = parse_ab_header(ab_file_bytes)
if not header["backup"]: if not header["backup"]:
if self.sub_command:
raise InvalidAndroidBackup(
"Invalid backup format, file should be in .ab format"
)
log.critical("Invalid backup format, file should be in .ab format") log.critical("Invalid backup format, file should be in .ab format")
sys.exit(1) sys.exit(1)
@@ -83,12 +95,25 @@ class CmdAndroidCheckBackup(Command):
log.critical("Invalid backup password") log.critical("Invalid backup password")
sys.exit(1) sys.exit(1)
except AndroidBackupParsingError as exc: except AndroidBackupParsingError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc) log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead") log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1) sys.exit(1)
dbytes = io.BytesIO(tardata) dbytes = io.BytesIO(tardata)
self.__tar = tarfile.open(fileobj=dbytes) try:
self.__tar = tarfile.open(fileobj=dbytes)
except tarfile.TarError as exc:
if self.sub_command:
raise InvalidAndroidBackup(
f"Impossible to parse this backup file: {exc}"
) from exc
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1)
for member in self.__tar: for member in self.__tar:
self.__files.append(member.name) self.__files.append(member.name)
+4
View File
@@ -12,6 +12,7 @@ from zipfile import ZipFile
from mvt.android.modules.bugreport.base import BugReportModule from mvt.android.modules.bugreport.base import BugReportModule
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.bugreport import BUGREPORT_MODULES from .modules.bugreport import BUGREPORT_MODULES
@@ -32,6 +33,7 @@ class CmdAndroidCheckBugreport(Command):
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -46,8 +48,10 @@ class CmdAndroidCheckBugreport(Command):
log=log, log=log,
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "android"
self.name = "check-bugreport" self.name = "check-bugreport"
self.modules = BUGREPORT_MODULES self.modules = BUGREPORT_MODULES
@@ -9,6 +9,7 @@ from typing import Optional
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.intrusion_logs import ( from .modules.intrusion_logs import (
INTRUSION_LOGS_MODULES, INTRUSION_LOGS_MODULES,
@@ -35,6 +36,7 @@ class CmdAndroidCheckIntrusionLogs(Command):
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -49,8 +51,10 @@ class CmdAndroidCheckIntrusionLogs(Command):
log=log, log=log,
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "android"
self.name = "check-intrusion-logs" self.name = "check-intrusion-logs"
self.modules = INTRUSION_LOGS_MODULES self.modules = INTRUSION_LOGS_MODULES
self._all_events: dict[str, list[dict]] = {} self._all_events: dict[str, list[dict]] = {}
@@ -5,12 +5,12 @@
from .aqf_files import AQFFiles from .aqf_files import AQFFiles
from .aqf_getprop import AQFGetProp from .aqf_getprop import AQFGetProp
from .aqf_log_timestamps import AQFLogTimestamps
from .aqf_packages import AQFPackages from .aqf_packages import AQFPackages
from .aqf_processes import AQFProcesses from .aqf_processes import AQFProcesses
from .aqf_settings import AQFSettings from .aqf_settings import AQFSettings
from .mounts import Mounts from .mounts import Mounts
from .root_binaries import RootBinaries from .root_binaries import RootBinaries
from .sms import SMS
ANDROIDQF_MODULES = [ ANDROIDQF_MODULES = [
AQFPackages, AQFPackages,
@@ -18,7 +18,7 @@ ANDROIDQF_MODULES = [
AQFGetProp, AQFGetProp,
AQFSettings, AQFSettings,
AQFFiles, AQFFiles,
SMS, AQFLogTimestamps,
RootBinaries, RootBinaries,
Mounts, Mounts,
] ]
+15 -15
View File
@@ -41,7 +41,7 @@ class AQFFiles(AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -107,16 +107,16 @@ class AQFFiles(AndroidQFModule):
msg = f'Found {file_type}file at suspicious path "{result["path"]}"' msg = f'Found {file_type}file at suspicious path "{result["path"]}"'
self.alertstore.high(msg, "", result) self.alertstore.high(msg, "", result)
if result.get("sha256", "") == "": for hash_key in ("sha256", "sha1", "md5"):
continue file_hash = result.get(hash_key, "")
if not file_hash:
ioc_match = self.indicators.check_file_hash(result.get("sha256") or "") continue
if ioc_match: ioc_match = self.indicators.check_file_hash(file_hash)
self.alertstore.critical( if ioc_match:
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.alertstore.critical(
) ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
# TODO: adds SHA1 and MD5 when available in MVT break
def run(self) -> None: def run(self) -> None:
if timezone := self._get_device_timezone(): if timezone := self._get_device_timezone():
@@ -131,7 +131,7 @@ class AQFFiles(AndroidQFModule):
data = json.loads(rawdata) data = json.loads(rawdata)
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
data = [] data = []
for line in rawdata.split("\n"): for line in rawdata.splitlines():
if line.strip() == "": if line.strip() == "":
continue continue
data.append(json.loads(line)) data.append(json.loads(line))
@@ -142,11 +142,11 @@ class AQFFiles(AndroidQFModule):
utc_timestamp = datetime.datetime.fromtimestamp( utc_timestamp = datetime.datetime.fromtimestamp(
file_data[ts], tz=datetime.timezone.utc file_data[ts], tz=datetime.timezone.utc
) )
# Convert the UTC timestamp to local tiem on Android device's local timezone # Convert the UTC timestamp to local time on Android device's local timezone
local_timestamp = utc_timestamp.astimezone(device_timezone) local_timestamp = utc_timestamp.astimezone(device_timezone)
# HACK: We only output the UTC timestamp in convert_datetime_to_iso, we # Preserve the device-local wall-clock time while using
# set the timestamp timezone to UTC, to avoid the timezone conversion again. # the project-wide ISO conversion helper.
local_timestamp = local_timestamp.replace( local_timestamp = local_timestamp.replace(
tzinfo=datetime.timezone.utc tzinfo=datetime.timezone.utc
) )
@@ -22,7 +22,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,7 +32,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule):
log=log, log=log,
results=results, results=results,
) )
self.results: list = [] self.results: list = [] if results is None else results
def run(self) -> None: def run(self) -> None:
getprop_files = self._get_files_by_pattern("*/getprop.txt") getprop_files = self._get_files_by_pattern("*/getprop.txt")
@@ -27,7 +27,7 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -5,8 +5,11 @@
import json import json
import logging import logging
import time
from typing import Optional from typing import Optional
from rich.progress import track
from mvt.android.utils import ( from mvt.android.utils import (
BROWSER_INSTALLERS, BROWSER_INSTALLERS,
PLAY_STORE_INSTALLERS, PLAY_STORE_INSTALLERS,
@@ -15,7 +18,8 @@ from mvt.android.utils import (
SYSTEM_UPDATE_PACKAGES, SYSTEM_UPDATE_PACKAGES,
THIRD_PARTY_STORE_INSTALLERS, THIRD_PARTY_STORE_INSTALLERS,
) )
from mvt.common.module_types import ModuleResults from mvt.common.module_types import ModuleAtomicResult, ModuleResults
from mvt.common.virustotal import VTNoKey, VTQuotaExceeded, virustotal_lookup
from .base import AndroidQFModule from .base import AndroidQFModule
@@ -30,7 +34,7 @@ class AQFPackages(AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -125,6 +129,65 @@ class AQFPackages(AndroidQFModule):
) )
break break
if self.module_options.get("virustotal", False):
self.check_virustotal(
delay=self.module_options.get("virustotal_delay", 0)
)
def check_virustotal(self, delay: int = 0) -> None:
files_by_hash: dict[
str, list[tuple[ModuleAtomicResult, ModuleAtomicResult]]
] = {}
for package in self.results:
if package.get("system", False):
continue
for package_file in package.get("files", []):
file_hash = package_file.get("sha256")
if not file_hash:
continue
files_by_hash.setdefault(file_hash, []).append((package, package_file))
total_hashes = len(files_by_hash)
if total_hashes == 0:
return
progress_desc = f"Looking up {total_hashes} package files on VirusTotal..."
for index, file_hash in enumerate(
track(files_by_hash, description=progress_desc)
):
try:
results = virustotal_lookup(file_hash)
except VTNoKey as exc:
self.log.warning("%s", exc)
return
except VTQuotaExceeded as exc:
self.log.warning("Unable to continue VirusTotal lookups: %s", exc)
break
if index < total_hashes - 1 and delay > 0:
time.sleep(delay)
if not results:
continue
attributes = results.get("attributes", {})
stats = attributes.get("last_analysis_stats", {})
positives = stats.get("malicious", 0)
total = len(attributes.get("last_analysis_results", {}))
detection = f"{positives}/{total}"
for package, package_file in files_by_hash[file_hash]:
package_file["virustotal"] = detection
if positives > 0:
self.alertstore.high(
f'VirusTotal flagged package "{package["name"]}" file '
f'"{package_file["path"]}" with {detection} detections',
"",
package,
)
def run(self) -> None: def run(self) -> None:
packages = self._get_files_by_pattern("*/packages.json") packages = self._get_files_by_pattern("*/packages.json")
if not packages: if not packages:
@@ -22,7 +22,7 @@ class AQFProcesses(ProcessesArtifact, AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,7 +32,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
log=log, log=log,
results=results, results=results,
) )
self.results: dict = {} self.results: dict = results if results is not None else {}
def run(self) -> None: def run(self) -> None:
for setting_file in self._get_files_by_pattern("*/settings_*.txt"): for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
@@ -40,7 +40,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
self.results[namespace] = {} self.results[namespace] = {}
data = self._get_file_content(setting_file) data = self._get_file_content(setting_file)
for line in data.decode("utf-8").split("\n"): for line in data.decode("utf-8").splitlines():
line = line.strip() line = line.strip()
try: try:
key, value = line.split("=", 1) key, value = line.split("=", 1)
+1 -1
View File
@@ -23,7 +23,7 @@ class AndroidQFModule(MVTModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
+4 -1
View File
@@ -32,7 +32,7 @@ class Mounts(MountsArtifact, AndroidQFModule):
log=log, log=log,
results=results, results=results,
) )
self.results: list = [] self.results: list = [] if results is None else results
def run(self) -> None: def run(self) -> None:
""" """
@@ -66,6 +66,9 @@ class Mounts(MountsArtifact, AndroidQFModule):
# AndroidQF format: array of strings like # AndroidQF format: array of strings like
# "/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)" # "/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)"
mount_content = "\n".join(json_data) mount_content = "\n".join(json_data)
else:
self.log.error("Expected mounts.json to contain a list of mount lines")
return
self.parse(mount_content) self.parse(mount_content)
except Exception as exc: except Exception as exc:
self.log.error("Failed to parse mount information: %s", exc) self.log.error("Failed to parse mount information: %s", exc)
-109
View File
@@ -1,109 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1
import logging
from typing import Optional
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
from mvt.android.parsers.backup import (
AndroidBackupParsingError,
InvalidBackupPassword,
parse_ab_header,
parse_backup_file,
parse_tar_for_sms,
)
from .base import AndroidQFModule
class SMS(AndroidQFModule):
"""
This module analyse SMS file in backup
XXX: We should also de-duplicate this AQF module, but first we
need to add tests for loading encrypted SMS backups through the backup
sub-module.
"""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
def check_indicators(self) -> None:
if not self.indicators:
return
for message in self.results:
if "body" not in message:
continue
ioc_match = self.indicators.check_domains(message.get("links", []))
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
def parse_backup(self, data):
header = parse_ab_header(data)
if not header["backup"]:
self.log.critical("Invalid backup format, backup.ab was not analysed")
return
password = None
if header["encryption"] != "none":
password = prompt_or_load_android_backup_password(
self.log, self.module_options
)
if not password:
self.log.critical("No backup password provided.")
return
try:
tardata = parse_backup_file(data, password=password)
except InvalidBackupPassword:
self.log.critical("Invalid backup password")
return
except AndroidBackupParsingError:
self.log.critical(
"Impossible to parse this backup file, please use"
" Android Backup Extractor instead"
)
return
if not tardata:
return
try:
self.results = parse_tar_for_sms(tardata)
except AndroidBackupParsingError:
self.log.info(
"Impossible to read SMS from the Android Backup, "
"please extract the SMS and try extracting it with "
"Android Backup Extractor"
)
return
def run(self) -> None:
files = self._get_files_by_pattern("*/backup.ab")
if not files:
self.log.info("No backup data found")
return
self.parse_backup(self._get_file_content(files[0]))
self.log.info("Identified %d SMS in backup data", len(self.results))
+1 -1
View File
@@ -22,7 +22,7 @@ class BackupModule(MVTModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
+2 -3
View File
@@ -4,9 +4,8 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from rich.prompt import Prompt
from mvt.common.config import settings from mvt.common.config import settings
from mvt.common.password import prompt_password
MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD" MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD"
@@ -49,7 +48,7 @@ def prompt_or_load_android_backup_password(log, module_options):
# The default is to allow interactivity # The default is to allow interactivity
elif module_options.get("interactive", True): elif module_options.get("interactive", True):
backup_password = Prompt.ask(prompt="Enter backup password", password=True) backup_password = prompt_password("Enter backup password: ")
else: else:
log.critical( log.critical(
"Cannot decrypt backup because interactivity" "Cannot decrypt backup because interactivity"
+14 -3
View File
@@ -20,7 +20,7 @@ class SMS(BackupModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -36,6 +36,8 @@ class SMS(BackupModule):
if not self.indicators: if not self.indicators:
return return
messages = []
url_batches = []
for message in self.results: for message in self.results:
if "body" not in message: if "body" not in message:
continue continue
@@ -44,12 +46,21 @@ class SMS(BackupModule):
if message_links == []: if message_links == []:
message_links = check_for_links(message.get("text", "")) message_links = check_for_links(message.get("text", ""))
ioc_match = self.indicators.check_urls(message_links) messages.append(message)
url_batches.append(message_links)
for message, ioc_match in zip(
messages, self.indicators.check_url_batches(url_batches)
):
if ioc_match: if ioc_match:
self.alertstore.critical( self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc ioc_match.message, "", message, matched_indicator=ioc_match.ioc
) )
continue
def collect_url_results(self) -> None:
for message in self.results:
for url in message.get("links", []):
self.add_url_result(url, message.get("isodate"), "sms")
def run(self) -> None: def run(self) -> None:
sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup" sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup"
+1 -1
View File
@@ -23,7 +23,7 @@ class BugReportModule(MVTModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -24,7 +24,7 @@ class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -3,11 +3,15 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import base64
import binascii
import datetime
import logging import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
from mvt.common.module_types import ModuleResults from mvt.common.module_types import ModuleResults
from mvt.common.utils import convert_datetime_to_iso
from .base import BugReportModule from .base import BugReportModule
@@ -22,7 +26,7 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -53,3 +57,122 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
"Identified a total of %d trusted ADB keys", "Identified a total of %d trusted ADB keys",
len(self.results[0].get("user_keys", [])), len(self.results[0].get("user_keys", [])),
) )
@staticmethod
def _key_material(public_key: object) -> str:
if isinstance(public_key, bytes):
public_key = public_key.decode("utf-8", errors="replace")
if not isinstance(public_key, str):
return ""
return public_key.strip().split(" ", 1)[0]
@staticmethod
def _is_valid_key(public_key: str) -> bool:
if not public_key:
return False
try:
return bool(base64.b64decode(public_key, validate=True))
except (binascii.Error, ValueError):
return False
@staticmethod
def _parse_acquisition_time(value: object) -> Optional[datetime.datetime]:
if not isinstance(value, str):
return None
try:
timestamp = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if not timestamp.tzinfo:
timestamp = timestamp.replace(tzinfo=datetime.timezone.utc)
return timestamp.astimezone(datetime.timezone.utc)
@staticmethod
def _parse_last_connected(value: object) -> Optional[datetime.datetime]:
try:
return datetime.datetime.fromtimestamp(
int(str(value)) / 1000,
tz=datetime.timezone.utc,
)
except (OSError, OverflowError, TypeError, ValueError):
return None
def _trusted_keys(self) -> list[dict]:
"""Return unique trusted keys, preferring keystore connection metadata."""
trusted_keys = []
seen = set()
for result in self.results:
keystore = result.get("keystore", [])
candidates = keystore if isinstance(keystore, list) else []
candidates = [*candidates, *result.get("user_keys", [])]
for candidate in candidates:
if not isinstance(candidate, dict):
continue
key = self._key_material(candidate.get("key"))
identity = key or repr(candidate)
if identity in seen:
continue
seen.add(identity)
trusted_keys.append(candidate)
return trusted_keys
def check_indicators(self) -> None:
if "androidqf_acquisition" not in self.module_options:
return super().check_indicators()
context = self.module_options.get("androidqf_acquisition")
if not isinstance(context, dict):
context = {}
acquisition_key = self._key_material(context.get("adb_host_public_key"))
if acquisition_key and not self._is_valid_key(acquisition_key):
acquisition_key = ""
acquisition_time = self._parse_acquisition_time(context.get("started"))
cutoff = (
acquisition_time - datetime.timedelta(days=1) if acquisition_time else None
)
for trusted_key in self._trusted_keys():
key = self._key_material(trusted_key.get("key"))
fingerprint = trusted_key.get("fingerprint") or "<unknown key>"
user = trusted_key.get("user") or "unknown user"
description = f"{fingerprint} ({user})"
last_connected = self._parse_last_connected(
trusted_key.get("last_connected")
)
event_time = (
convert_datetime_to_iso(last_connected) if last_connected else ""
)
if not self._is_valid_key(key):
self.alertstore.low(
f"Found an invalid trusted ADB host key: {description}",
event_time,
trusted_key,
)
continue
if not acquisition_key:
self.alertstore.low(
"Found a trusted ADB host key, but the AndroidQF acquisition "
f"does not include its host key: {description}",
event_time,
trusted_key,
)
continue
if key != acquisition_key:
self.alertstore.low(
"Found a trusted ADB host key different from the AndroidQF "
f"acquisition host: {description}",
event_time,
trusted_key,
)
continue
if cutoff and last_connected and last_connected <= cutoff:
self.alertstore.info(
"Found a trusted ADB host key last connected at least one day "
f"before the AndroidQF acquisition: {description}",
event_time,
trusted_key,
)
@@ -22,7 +22,7 @@ class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -24,7 +24,7 @@ class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -23,7 +23,7 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -22,7 +22,7 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -42,8 +42,10 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
) )
return return
data = data.decode("utf-8", errors="replace") decoded_data = data.decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(data, "DUMP OF SERVICE platform_compat:") content = self.extract_dumpsys_section(
decoded_data, "DUMP OF SERVICE platform_compat:"
)
self.parse(content) self.parse(content)
self.log.info("Found %d uninstalled apps", len(self.results)) self.log.info("Found %d uninstalled apps", len(self.results))
@@ -22,7 +22,7 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -35,24 +35,6 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
self.results = results if results else {} self.results = results if results else {}
def check_indicators(self) -> None:
for result in self.results:
if self.indicators:
receiver_name = self.results[result][0]["receiver"]
# return IoC if the stix2 process name a substring of the receiver name
ioc_match = self.indicators.check_receiver_prefix(receiver_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
self.results[result][0],
matched_indicator=ioc_match.ioc,
)
continue
def run(self) -> None: def run(self) -> None:
content = self._get_dumpstate_file() content = self._get_dumpstate_file()
if not content: if not content:
@@ -24,7 +24,7 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -23,7 +23,7 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -264,11 +264,17 @@ SECURITY_EVENT_TAGS = {
} }
SECURITY_EVENT_METADATA_KEYS = { SECURITY_EVENT_METADATA_KEYS = {
"event_id",
"event_time", "event_time",
"event_type", "event_type",
"timestamp", "timestamp",
} }
# Known platform failures that remain in the timeline but do not need a warning.
KNOWN_BENIGN_KEY_GENERATION_FAILURES = {
("PinStorage_crossReboot_key", 1001),
}
class SecurityEvent(IntrusionLogsModule): class SecurityEvent(IntrusionLogsModule):
"""This module analyzes security events from intrusion logs.""" """This module analyzes security events from intrusion logs."""
@@ -302,10 +308,15 @@ class SecurityEvent(IntrusionLogsModule):
def check_indicators(self) -> None: def check_indicators(self) -> None:
"""Check security events against indicators of compromise.""" """Check security events against indicators of compromise."""
if not self.indicators:
return
for result in self.results: for result in self.results:
# Heuristic alerts are intrinsic to the event, so they run even
# when no indicator set is loaded.
self._check_security_heuristics(result)
# The remaining checks match events against loaded indicators.
if not self.indicators:
continue
# Check app process start events for suspicious package names # Check app process start events for suspicious package names
if "app_process_start" in result: if "app_process_start" in result:
process_info = result["app_process_start"] process_info = result["app_process_start"]
@@ -389,60 +400,80 @@ class SecurityEvent(IntrusionLogsModule):
matched_indicator=ioc.ioc, matched_indicator=ioc.ioc,
) )
# Flag failed cryptographic operations as potentially suspicious def _check_security_heuristics(self, result: dict) -> None:
if "key_generated" in result: """Raise alerts for events that are intrinsically suspicious,
if not result["key_generated"].get("success", True): independent of any loaded indicators."""
self.log.warning( # Flag failed cryptographic operations as potentially suspicious
"Failed key generation detected for key_id: %s", if "key_generated" in result:
result["key_generated"].get("key_id", "unknown"), key_info = result["key_generated"]
) key_id = key_info.get("key_id", "unknown")
uid = key_info.get("uid")
# Flag certificate validation failures is_known_benign_failure = (
if "cert_validation_failure" in result: key_id,
uid,
) in KNOWN_BENIGN_KEY_GENERATION_FAILURES
if not key_info.get("success", True) and not is_known_benign_failure:
self.log.warning( self.log.warning(
"Certificate validation failure detected: %s", "Failed key generation detected for key_id: %s",
result.get("cert_validation_failure"), key_id,
) )
# Flag key integrity violations # Flag certificate validation failures
if "key_integrity_violation" in result: if "cert_validation_failure" in result:
self.alertstore.medium(
"Certificate validation failure detected: "
f"{result.get('cert_validation_failure')}",
result.get("timestamp") or "",
result,
)
# Flag key integrity violations
if "key_integrity_violation" in result:
self.alertstore.medium(
f"Key integrity violation detected: {result.get('key_integrity_violation')}",
result.get("timestamp") or "",
result,
)
# Flag successful certificate authority installations (potential
# MITM); a missing success field is treated as installed
if "cert_authority_installed" in result:
cert_info = result["cert_authority_installed"]
if cert_info.get("success", True):
self.alertstore.medium( self.alertstore.medium(
f"Key integrity violation detected: {result.get('key_integrity_violation')}", "Certificate authority installed: "
f"{cert_info.get('subject', 'unknown')}",
result.get("timestamp") or "", result.get("timestamp") or "",
result, result,
) )
else:
# Flag certificate authority installations (potential MITM)
if "cert_authority_installed" in result:
cert_info = result["cert_authority_installed"]
self.log.warning( self.log.warning(
"Certificate authority installed: %s (success: %s)", "Failed certificate authority install attempt: %s",
cert_info.get("subject", "unknown"), cert_info.get("subject", "unknown"),
cert_info.get("success", "unknown"),
) )
# Flag wipe failures # Flag wipe failures
if "wipe_failure" in result: if "wipe_failure" in result:
self.alertstore.medium(
"Device wipe failure detected",
result.get("timestamp") or "",
result,
)
# Flag crypto self test failures
if "crypto_self_test_completed" in result:
test_result = result["crypto_self_test_completed"]
if isinstance(test_result, dict):
success = test_result.get("success", True)
else:
success = test_result == 1
if not success:
self.alertstore.medium( self.alertstore.medium(
"Device wipe failure detected", "Cryptographic self test failed",
result.get("timestamp") or "", result.get("timestamp") or "",
result, result,
) )
# Flag crypto self test failures
if "crypto_self_test_completed" in result:
test_result = result["crypto_self_test_completed"]
if isinstance(test_result, dict):
success = test_result.get("success", True)
else:
success = test_result == 1
if not success:
self.alertstore.medium(
"Cryptographic self test failed",
result.get("timestamp") or "",
result,
)
def serialize(self, record: dict) -> Union[dict, list]: def serialize(self, record: dict) -> Union[dict, list]:
"""Serialize a security event record for timeline output.""" """Serialize a security event record for timeline output."""
# Determine the event sub-type # Determine the event sub-type
+36 -29
View File
@@ -29,9 +29,6 @@ class InvalidBackupPassword(AndroidBackupParsingError):
pass pass
# TODO: Need to clean all the following code and conform it to the coding style.
def to_utf8_bytes(input_bytes): def to_utf8_bytes(input_bytes):
output = [] output = []
for byte in input_bytes: for byte in input_bytes:
@@ -51,13 +48,16 @@ def parse_ab_header(data):
'encryption': "none", 'version': 4} 'encryption': "none", 'version': 4}
""" """
if data.startswith(b"ANDROID BACKUP"): if data.startswith(b"ANDROID BACKUP"):
[_, version, is_compressed, encryption, _] = data.split(b"\n", 4) try:
return { [_, version, is_compressed, encryption, _] = data.split(b"\n", 4)
"backup": True, return {
"compression": (is_compressed == b"1"), "backup": True,
"version": int(version), "compression": (is_compressed == b"1"),
"encryption": encryption.decode("utf-8"), "version": int(version),
} "encryption": encryption.decode("utf-8"),
}
except (UnicodeDecodeError, ValueError):
pass
return {"backup": False, "compression": None, "version": None, "encryption": None} return {"backup": False, "compression": None, "version": None, "encryption": None}
@@ -131,20 +131,23 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
if password is None: if password is None:
raise InvalidBackupPassword() raise InvalidBackupPassword()
[ try:
user_salt, [
checksum_salt, user_salt,
pbkdf2_rounds, checksum_salt,
user_iv, pbkdf2_rounds,
master_key_blob, user_iv,
encrypted_data, master_key_blob,
] = encrypted_backup.split(b"\n", 5) encrypted_data,
] = encrypted_backup.split(b"\n", 5)
user_salt = bytes.fromhex(user_salt.decode("utf-8")) user_salt = bytes.fromhex(user_salt.decode("utf-8"))
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8")) checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
pbkdf2_rounds = int(pbkdf2_rounds) pbkdf2_rounds = int(pbkdf2_rounds)
user_iv = bytes.fromhex(user_iv.decode("utf-8")) user_iv = bytes.fromhex(user_iv.decode("utf-8"))
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8")) master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
except (UnicodeDecodeError, ValueError) as exc:
raise AndroidBackupParsingError("Invalid encrypted backup header") from exc
# Derive decryption master key from password. # Derive decryption master key from password.
master_key, master_iv = decrypt_master_key( master_key, master_iv = decrypt_master_key(
@@ -157,13 +160,13 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
checksum_salt=checksum_salt, checksum_salt=checksum_salt,
) )
# Decrypt and unpad backup data using derivied key. # Decrypt and unpad backup data using derived key.
cipher = Cipher(algorithms.AES(master_key), modes.CBC(master_iv)) cipher = Cipher(algorithms.AES(master_key), modes.CBC(master_iv))
decryptor = cipher.decryptor() decryptor = cipher.decryptor()
decrypted_tar = decryptor.update(encrypted_data) + decryptor.finalize() decrypted_tar = decryptor.update(encrypted_data) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder() unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(decrypted_tar) return unpadder.update(decrypted_tar) + unpadder.finalize()
def parse_backup_file(data, password=None): def parse_backup_file(data, password=None):
@@ -174,10 +177,12 @@ def parse_backup_file(data, password=None):
if not data.startswith(b"ANDROID BACKUP"): if not data.startswith(b"ANDROID BACKUP"):
raise AndroidBackupParsingError("Invalid file header") raise AndroidBackupParsingError("Invalid file header")
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4) try:
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
version = int(version) version = int(version)
is_compressed = int(is_compressed) is_compressed = int(is_compressed)
except ValueError as exc:
raise AndroidBackupParsingError("Invalid file header") from exc
if encryption_algo != b"none": if encryption_algo != b"none":
tar_data = decrypt_backup_data( tar_data = decrypt_backup_data(
@@ -210,6 +215,8 @@ def parse_tar_for_sms(data):
or member.name.endswith("_mms_backup") or member.name.endswith("_mms_backup")
): ):
dhandler = tar.extractfile(member) dhandler = tar.extractfile(member)
if not dhandler:
continue
res.extend(parse_sms_file(dhandler.read())) res.extend(parse_sms_file(dhandler.read()))
return res return res
+1 -1
View File
@@ -219,7 +219,7 @@ class AlertStore:
return alerts return alerts
def save_timeline(self, timeline_path: str) -> None: def save_timeline(self, timeline_path: str) -> None:
with open(timeline_path, "a+", encoding="utf-8") as handle: with open(timeline_path, "w", encoding="utf-8") as handle:
csvoutput = csv.writer( csvoutput = csv.writer(
handle, handle,
delimiter=",", delimiter=",",
+2 -1
View File
@@ -8,5 +8,6 @@ from .module import MVTModule
class Artifact(MVTModule): class Artifact(MVTModule):
"""Base class for artifacts. """Base class for artifacts.
XXX: Inheriting from MVTModule to have the same signature as other modules. Not sure if this is a good idea. Artifacts share the MVTModule lifecycle so commands can run artifacts and
extraction modules through the same interface.
""" """
+297
View File
@@ -0,0 +1,297 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import hashlib
import importlib.metadata
import importlib.util
import logging
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Iterable
import click
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
log = logging.getLogger(__name__)
class CustomCommandLoadError(Exception):
pass
class BrokenPluginCommand(click.Command):
"""A placeholder for an installed or configured command that failed to load."""
def __init__(self, name: str, source: str, exception: BaseException):
super().__init__(
name,
help=(
f"Unable to load external command from {source}.\n\n"
f"{type(exception).__name__}: {exception}"
),
short_help="Warning: external command could not be loaded.",
)
self.source = source
self.exception = exception
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
return args
def invoke(self, ctx: click.Context) -> None:
raise click.ClickException(
f"Unable to load external command '{self.name}' from {self.source}: "
f"{type(self.exception).__name__}: {self.exception}"
)
def _module_name_for_path(path: Path) -> str:
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
return f"_mvt_custom_command_{path.stem}_{digest}"
def _iter_command_files(path: Path) -> Iterable[Path]:
if path.is_file():
if path.suffix != ".py":
raise CustomCommandLoadError(
f"Custom command file is not a Python file: {path}"
)
yield path
return
if path.is_dir():
for child in sorted(path.iterdir()):
if child.name.startswith(".") or child.name == "__init__.py":
continue
if child.is_file() and child.suffix == ".py":
yield child
return
raise CustomCommandLoadError(f"Custom command path does not exist: {path}")
def _load_python_file(path: Path) -> ModuleType:
module_name = _module_name_for_path(path)
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise CustomCommandLoadError(f"Unable to load custom command file: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except (Exception, SystemExit) as exc:
raise CustomCommandLoadError(
f"Unable to import custom command {path}: {exc}"
) from exc
return module
def load_cli_command_file(path: Path) -> click.Command:
module = _load_python_file(path)
command = getattr(module, "cli", None)
if not isinstance(command, click.Command):
raise CustomCommandLoadError(
f"Custom command {path} must export a Click command or group named 'cli'"
)
if command.name is None:
raise CustomCommandLoadError(f"Custom command {path} has no command name")
return command
def _register_command(
group: click.Group,
command: click.Command,
*,
name: str,
source: str,
collision_is_error: bool,
) -> bool:
if name in group.commands:
registered_sources = getattr(group, "_mvt_external_command_sources", {})
if registered_sources.get(name) == source:
return False
message = (
f"Unable to register external command '{name}' from {source}: "
"the command name is already registered"
)
if collision_is_error:
raise CustomCommandLoadError(message)
log.warning(message)
return False
group.add_command(command, name=name)
registered_sources = getattr(group, "_mvt_external_command_sources", {})
registered_sources[name] = source
setattr(group, "_mvt_external_command_sources", registered_sources)
return True
def register_cli_commands_from_path(
group: click.Group,
path: str | Path,
*,
collision_is_error: bool = False,
failures_are_errors: bool = False,
) -> list[str]:
resolved_path = Path(path).expanduser().resolve()
registered: list[str] = []
try:
command_files = list(_iter_command_files(resolved_path))
except CustomCommandLoadError as exc:
if failures_are_errors:
raise
name = resolved_path.stem.replace("_", "-") or "custom-command"
if _register_command(
group,
BrokenPluginCommand(name, str(resolved_path), exc),
name=name,
source=str(resolved_path),
collision_is_error=collision_is_error,
):
registered.append(name)
return registered
for command_file in command_files:
try:
command = load_cli_command_file(command_file)
except CustomCommandLoadError as exc:
if failures_are_errors:
raise
name = command_file.stem.replace("_", "-")
command = BrokenPluginCommand(name, str(command_file), exc)
command_name = command.name
if command_name is None:
raise CustomCommandLoadError(
f"Custom command {command_file} has no command name"
)
if _register_command(
group,
command,
name=command_name,
source=str(command_file),
collision_is_error=collision_is_error,
):
registered.append(command_name)
return registered
def _entry_point_source(entry_point: importlib.metadata.EntryPoint) -> str:
try:
distribution = getattr(entry_point, "dist", None)
if distribution is None:
return f"entry point {entry_point.value}"
name = distribution.metadata.get("Name", "unknown distribution")
version = getattr(distribution, "version", None)
if version:
return f"{name} {version} ({entry_point.value})"
return f"{name} ({entry_point.value})"
except Exception:
return f"entry point {entry_point.value}"
def register_installed_cli_commands(
group: click.Group,
entry_point_group: str,
) -> list[str]:
try:
entry_points = importlib.metadata.entry_points(group=entry_point_group)
except Exception as exc:
log.warning(
"Unable to discover external commands in entry-point group %s: %s",
entry_point_group,
exc,
)
return []
ordered_entry_points = sorted(
entry_points,
key=lambda entry_point: (
entry_point.name,
_entry_point_source(entry_point),
entry_point.value,
),
)
registered: list[str] = []
for entry_point in ordered_entry_points:
source = _entry_point_source(entry_point)
try:
command = entry_point.load()
if not isinstance(command, click.Command):
raise TypeError(
f"entry point must resolve to a Click command or group, "
f"not {type(command).__name__}"
)
except (Exception, SystemExit) as exc:
command = BrokenPluginCommand(entry_point.name, source, exc)
if _register_command(
group,
command,
name=entry_point.name,
source=source,
collision_is_error=False,
):
registered.append(entry_point.name)
return registered
def register_cli_plugins(
group: click.Group,
*,
entry_point_group: str,
environment_variable: str,
) -> None:
environment_path = os.environ.get(environment_variable)
if environment_path:
register_cli_commands_from_path(group, environment_path)
register_installed_cli_commands(group, entry_point_group)
def _load_command_option_callback(
ctx: click.Context,
param: click.Parameter,
paths: tuple[str, ...],
) -> tuple[str, ...]:
if not isinstance(ctx.command, click.Group):
raise click.ClickException("--load-command requires a Click command group")
for path in paths:
try:
register_cli_commands_from_path(
ctx.command,
path,
collision_is_error=True,
failures_are_errors=True,
)
except CustomCommandLoadError as exc:
raise click.BadParameter(str(exc), ctx=ctx, param=param) from exc
return paths
load_cli_commands_option = click.option(
"--load-command",
"load_commands",
type=click.Path(exists=True),
multiple=True,
expose_value=False,
is_eager=True,
callback=_load_command_option_callback,
help=(
"Load a custom CLI command from a Python file or folder "
"(can be invoked multiple times)"
),
)
+6 -1
View File
@@ -8,6 +8,7 @@ import os
from typing import Optional from typing import Optional
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.module import MVTModule
from mvt.common.utils import exec_or_profile from mvt.common.utils import exec_or_profile
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -26,6 +27,8 @@ class CmdCheckIOCS(Command):
sub_command: Optional[bool] = False, sub_command: Optional[bool] = False,
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
platform: str = "",
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -39,14 +42,16 @@ class CmdCheckIOCS(Command):
log=log, log=log,
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = platform
self.name = "check-iocs" self.name = "check-iocs"
def run(self) -> None: def run(self) -> None:
assert self.target_path is not None assert self.target_path is not None
all_modules = [] all_modules = []
for entry in self.modules: for entry in self._available_modules():
if entry not in all_modules: if entry not in all_modules:
all_modules.append(entry) all_modules.append(entry)
+114 -13
View File
@@ -8,6 +8,7 @@ import logging
import os import os
import sys import sys
from datetime import datetime from datetime import datetime
from heapq import heappop, heappush
from typing import Any, Optional from typing import Any, Optional
from rich.console import Console from rich.console import Console
@@ -18,6 +19,8 @@ from .alerts import AlertLevel, AlertStore
from .config import settings from .config import settings
from .indicators import Indicators from .indicators import Indicators
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
from .module_loader import module_supports_command
from .module_types import ModuleTimeline, URLResult
from .utils import ( from .utils import (
CustomJSONEncoder, CustomJSONEncoder,
convert_datetime_to_iso, convert_datetime_to_iso,
@@ -42,9 +45,12 @@ class Command:
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
self.name = "" self.name = ""
self.modules: list[Any] = [] self.platform = ""
self.modules: list[type[MVTModule]] = []
self.custom_modules = custom_modules if custom_modules else []
self.target_path = target_path self.target_path = target_path
self.results_path = results_path self.results_path = results_path
@@ -63,10 +69,11 @@ class Command:
# This list will contain all executed modules. # This list will contain all executed modules.
# We can use this to reference e.g. self.executed[0].results. # We can use this to reference e.g. self.executed[0].results.
self.executed: list[Any] = [] self.executed: list[MVTModule] = []
self.hashes = hashes self.hashes = hashes
self.hash_values: list[dict[str, Any]] = [] self.hash_values: list[dict[str, Any]] = []
self.timeline: list[dict[str, Any]] = [] self.timeline: ModuleTimeline = []
self.url_results: list[URLResult] = []
# Load IOCs # Load IOCs
self._create_storage() self._create_storage()
@@ -144,6 +151,14 @@ class Command:
with open(alerts_path, "w+", encoding="utf-8") as handle: with open(alerts_path, "w+", encoding="utf-8") as handle:
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder) json.dump(alerts, handle, indent=4, 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: def _store_alerts_timeline(self) -> None:
if not self.results_path: if not self.results_path:
return return
@@ -197,9 +212,24 @@ class Command:
def list_modules(self) -> None: def list_modules(self) -> None:
self.log.info("Following is the list of available %s modules:", self.name) self.log.info("Following is the list of available %s modules:", self.name)
for module in self.modules: for module in self._available_modules():
self.log.info(" - %s", module.__name__) self.log.info(" - %s", module.__name__)
def _available_modules(self) -> list[type[MVTModule]]:
modules = list(self.modules)
modules.extend(
module
for module in self.custom_modules
if module_supports_command(module, self.platform, self.name)
)
deduplicated = []
for module in modules:
if module not in deduplicated:
deduplicated.append(module)
return deduplicated
def init(self) -> None: def init(self) -> None:
raise NotImplementedError raise NotImplementedError
@@ -258,22 +288,86 @@ class Command:
console.print("") console.print("")
console.print(panel) console.print(panel)
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
"""Return enabled modules in stable topological order."""
modules = self._available_modules()
module_indexes = {module: index for index, module in enumerate(modules)}
if self.module_name:
selected = [
module for module in modules if module.__name__ == self.module_name
]
else:
selected = [module for module in modules if module.enabled]
required = set(selected)
pending = list(selected)
while pending:
module = pending.pop()
for dependency in module.dependencies:
if dependency not in module_indexes:
self.log.warning(
"Module %s depends on unavailable module %s. "
"No modules will be run.",
module.__name__,
dependency.__name__,
)
return None
if dependency not in required:
required.add(dependency)
pending.append(dependency)
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
module: [] for module in required
}
indegree = {module: 0 for module in required}
for module in required:
for dependency in module.dependencies:
if dependency not in required:
continue
dependents[dependency].append(module)
indegree[module] += 1
ready: list[tuple[int, type[MVTModule]]] = []
for module, count in indegree.items():
if count == 0:
heappush(ready, (module_indexes[module], module))
ordered = []
while ready:
_, module = heappop(ready)
ordered.append(module)
for dependent in dependents[module]:
indegree[dependent] -= 1
if indegree[dependent] == 0:
heappush(ready, (module_indexes[dependent], dependent))
if len(ordered) != len(required):
cyclic_modules = sorted(
(module.__name__ for module, count in indegree.items() if count > 0)
)
self.log.warning(
"Circular module dependency detected involving: %s. "
"No modules will be run.",
", ".join(cyclic_modules),
)
return None
return ordered
def run(self) -> None: def run(self) -> None:
ordered_modules = self._ordered_modules()
if ordered_modules is None:
return
try: try:
self.init() self.init()
except NotImplementedError: except NotImplementedError:
pass pass
for module in self.modules: executed_by_type: dict[type[MVTModule], MVTModule] = {}
if self.module_name and module.__name__ != self.module_name: for module in ordered_modules:
continue
if not module.enabled and not (
self.module_name and module.__name__ == self.module_name
):
continue
# FIXME: do we need the logger here
module_logger = logging.getLogger(module.__module__) module_logger = logging.getLogger(module.__module__)
m = module( m = module(
@@ -282,6 +376,10 @@ class Command:
module_options=self.module_options, module_options=self.module_options,
log=module_logger, log=module_logger,
) )
m.dependency_modules = {
dependency: executed_by_type[dependency]
for dependency in module.dependencies
}
if self.iocs.total_ioc_count: if self.iocs.total_ioc_count:
m.indicators = self.iocs m.indicators = self.iocs
@@ -305,7 +403,9 @@ class Command:
return return
self.executed.append(m) self.executed.append(m)
executed_by_type[module] = m
self.timeline.extend(m.timeline) self.timeline.extend(m.timeline)
self.url_results.extend(m.url_results)
self.alertstore.extend(m.alertstore.alerts) self.alertstore.extend(m.alertstore.alerts)
try: try:
@@ -320,4 +420,5 @@ class Command:
self._store_timeline() self._store_timeline()
self._store_alerts_timeline() self._store_alerts_timeline()
self._store_alerts() self._store_alerts()
self._store_urls()
self._store_info() self._store_info()
+94
View File
@@ -0,0 +1,94 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from pathlib import Path
import shlex
import click
from click.shell_completion import get_completion_class
SUPPORTED_SHELLS = ("bash", "zsh", "fish")
def completion_instructions(program_name: str) -> str:
return f"""Shell completion for {program_name}
Print a completion script:
{program_name} completion bash > ~/.{program_name}-complete.bash
{program_name} completion zsh > ~/.{program_name}-complete.zsh
mkdir -p ~/.config/fish/completions
{program_name} completion fish > ~/.config/fish/completions/{program_name}.fish
Load the generated Bash script from ~/.bashrc:
[ -f ~/.{program_name}-complete.bash ] && . ~/.{program_name}-complete.bash
Load the generated Zsh script from ~/.zshrc:
[ -f ~/.{program_name}-complete.zsh ] && . ~/.{program_name}-complete.zsh
Fish loads completion files from ~/.config/fish/completions automatically.
To write these files and update Bash/Zsh shell configuration automatically:
{program_name} completion bash --install
{program_name} completion zsh --install
{program_name} completion fish --install
"""
def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str:
completion_class = get_completion_class(shell)
if completion_class is None:
raise click.ClickException(f"Unsupported shell: {shell}")
complete_var = f"_{program_name.upper().replace('-', '_')}_COMPLETE"
return completion_class(cli, {}, program_name, complete_var).source()
def install_completion_script(
cli: click.Command,
program_name: str,
shell: str,
) -> Path:
script = generate_completion_script(cli, program_name, shell)
script_path = _completion_script_path(program_name, shell)
script_path.parent.mkdir(parents=True, exist_ok=True)
script_path.write_text(script, encoding="utf-8")
if shell in ("bash", "zsh"):
_install_shell_source_line(program_name, shell, script_path)
return script_path
def _completion_script_path(program_name: str, shell: str) -> Path:
home = Path.home()
if shell == "fish":
return home / ".config" / "fish" / "completions" / f"{program_name}.fish"
return home / f".{program_name}-complete.{shell}"
def _install_shell_source_line(program_name: str, shell: str, script_path: Path) -> None:
shell_config_path = Path.home() / f".{shell}rc"
source_line = (
f"[ -f {shlex.quote(str(script_path))} ] && "
f". {shlex.quote(str(script_path))}"
)
block = (
f"# MVT shell completion for {program_name}\n"
f"{source_line}\n"
)
if shell_config_path.exists():
shell_config = shell_config_path.read_text(encoding="utf-8")
if source_line in shell_config:
return
else:
shell_config = ""
separator = "" if not shell_config or shell_config.endswith("\n") else "\n"
with shell_config_path.open("a", encoding="utf-8") as handle:
handle.write(f"{separator}{block}")
+8
View File
@@ -10,6 +10,10 @@ HELP_MSG_IOC = "Path to indicators file (can be invoked multiple time)"
HELP_MSG_FAST = "Avoid running time/resource consuming features" HELP_MSG_FAST = "Avoid running time/resource consuming features"
HELP_MSG_LIST_MODULES = "Print list of available modules and exit" HELP_MSG_LIST_MODULES = "Print list of available modules and exit"
HELP_MSG_MODULE = "Name of a single module you would like to run instead of all" HELP_MSG_MODULE = "Name of a single module you would like to run instead of all"
HELP_MSG_LOAD_MODULE = (
"Load custom MVT module(s) from a Python file or folder "
"(can be invoked multiple times)"
)
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing" HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
HELP_MSG_HASHES = "Generate hashes of all the files analyzed" HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
HELP_MSG_VERBOSE = "Verbose mode" HELP_MSG_VERBOSE = "Verbose mode"
@@ -17,6 +21,7 @@ HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators"
HELP_MSG_STIX2 = "Download public STIX2 indicators" HELP_MSG_STIX2 = "Download public STIX2 indicators"
HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check" HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check"
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check" HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check"
HELP_MSG_COMPLETION = "Generate or install shell completion"
# IOS Specific # IOS Specific
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup" HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
@@ -33,6 +38,7 @@ HELP_MSG_BACKUP_KEYFILE = (
HELP_MSG_EXTRACT_KEY = "Extract decryption key from an iTunes backup" HELP_MSG_EXTRACT_KEY = "Extract decryption key from an iTunes backup"
HELP_MSG_CHECK_IOS_BACKUP = "Extract artifacts from an iTunes backup" HELP_MSG_CHECK_IOS_BACKUP = "Extract artifacts from an iTunes backup"
HELP_MSG_CHECK_FS = "Extract artifacts from a full filesystem dump" HELP_MSG_CHECK_FS = "Extract artifacts from a full filesystem dump"
HELP_MSG_CHECK_SYSDIAGNOSE = "Extract artifacts from an iOS sysdiagnose archive"
# Android Specific # Android Specific
HELP_MSG_ANDROID_BACKUP_PASSWORD = "The backup password to use for an Android backup" HELP_MSG_ANDROID_BACKUP_PASSWORD = "The backup password to use for an Android backup"
@@ -48,3 +54,5 @@ HELP_MSG_CHECK_BUGREPORT = "Check an Android Bug Report"
HELP_MSG_CHECK_ANDROID_BACKUP = "Check an Android Backup" HELP_MSG_CHECK_ANDROID_BACKUP = "Check an Android Backup"
HELP_MSG_CHECK_ANDROIDQF = "Check data collected with AndroidQF" HELP_MSG_CHECK_ANDROIDQF = "Check data collected with AndroidQF"
HELP_MSG_CHECK_INTRUSION_LOGS = "Check Android Intrusion Logging files" HELP_MSG_CHECK_INTRUSION_LOGS = "Check Android Intrusion Logging files"
HELP_MSG_VIRUS_TOTAL = "Check package APK hashes on VirusTotal"
HELP_MSG_DELAY_CHECKS = "Delay in seconds between VirusTotal requests"
+52 -30
View File
@@ -7,9 +7,10 @@ import glob
import json import json
import logging import logging
import os import os
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Any, Dict, Iterator, List, Optional from typing import Any, Dict, Iterator, List, Optional, Sequence
import ahocorasick import ahocorasick
from appdirs import user_data_dir from appdirs import user_data_dir
@@ -22,6 +23,8 @@ MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
URL_CHECK_MAX_WORKERS = 20
@dataclass @dataclass
class Indicator: class Indicator:
@@ -46,6 +49,7 @@ class Indicators:
self.log = log self.log = log
self.ioc_collections: List[Dict[str, Any]] = [] self.ioc_collections: List[Dict[str, Any]] = []
self.total_ioc_count = 0 self.total_ioc_count = 0
self.resolved_urls: Dict[str, str] = {}
def _load_downloaded_indicators(self) -> None: def _load_downloaded_indicators(self) -> None:
if not os.path.isdir(MVT_INDICATORS_FOLDER): if not os.path.isdir(MVT_INDICATORS_FOLDER):
@@ -436,9 +440,14 @@ class Indicators:
orig_url.url, orig_url.url,
dest_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 final_url = dest_url
self.resolved_urls[url] = final_url.url
else: else:
# If it's not shortened, we just use the original URL object. # If it's not shortened, we just use the original URL object.
final_url = orig_url final_url = orig_url
@@ -479,6 +488,13 @@ class Indicators:
return None 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]: def check_urls(self, urls: list) -> Optional[IndicatorMatch]:
"""Check a list of URLs against the provided list of domain indicators. """Check a list of URLs against the provided list of domain indicators.
@@ -490,12 +506,41 @@ class Indicators:
if not urls: if not urls:
return None return None
for url in urls: return self.check_url_batches([urls])[0]
check = self.check_url(url)
if check:
return check
return None def check_url_batches(
self, url_batches: Sequence[Optional[Sequence[str]]]
) -> List[Optional[IndicatorMatch]]:
"""Check batches of URLs concurrently while preserving batch order.
URLs are deduplicated across batches before checking. Each returned item
is the first indicator match from the corresponding input batch, using
the original URL order.
"""
batches = [list(urls) if urls else [] for urls in url_batches]
unique_urls = list(
dict.fromkeys(url for urls in batches for url in urls)
)
if not unique_urls:
return [None] * len(batches)
if settings.NETWORK_ACCESS_ALLOWED and len(unique_urls) > 1:
worker_count = min(URL_CHECK_MAX_WORKERS, len(unique_urls))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
url_matches = dict(
zip(unique_urls, executor.map(self.check_url, unique_urls))
)
else:
url_matches = {url: self.check_url(url) for url in unique_urls}
return [
next(
(url_matches[url] for url in urls if url_matches[url] is not None),
None,
)
for urls in batches
]
def check_process(self, process: str) -> Optional[IndicatorMatch]: def check_process(self, process: str) -> Optional[IndicatorMatch]:
"""Check the provided process name against the list of process """Check the provided process name against the list of process
@@ -727,29 +772,6 @@ class Indicators:
return None return None
def check_receiver_prefix(
self, receiver_name: str
) -> Optional[IndicatorMatch]:
"""Check the provided receiver name against the list of indicators.
An IoC match is detected when a substring of the receiver matches the indicator.
:param receiver_name: Receiver name to check against app ID indicators
:type receiver_name: str
:returns: IndicatorMatch if matched, otherwise None
"""
if not receiver_name:
return None
for ioc in self.get_iocs("app_ids"):
if ioc.value.lower() in receiver_name.lower():
return IndicatorMatch(
ioc=ioc,
message=f'Found a known suspicious receiver with name "{receiver_name}" matching indicators from "{ioc.name}"',
)
return None
def check_android_property_name( def check_android_property_name(
self, property_name: str self, property_name: str
) -> Optional[IndicatorMatch]: ) -> Optional[IndicatorMatch]:
+47 -8
View File
@@ -9,7 +9,7 @@ import logging
import os import os
import re import re
from dataclasses import asdict, is_dataclass from dataclasses import asdict, is_dataclass
from typing import Any, Dict, Optional from typing import Any, Dict, Optional, Sequence
from .alerts import AlertStore from .alerts import AlertStore
from .indicators import Indicators from .indicators import Indicators
@@ -18,6 +18,7 @@ from .module_types import (
ModuleResults, ModuleResults,
ModuleSerializedResult, ModuleSerializedResult,
ModuleTimeline, ModuleTimeline,
URLResult,
) )
from .utils import CustomJSONEncoder, exec_or_profile from .utils import CustomJSONEncoder, exec_or_profile
@@ -43,6 +44,8 @@ class MVTModule:
enabled: bool = True enabled: bool = True
slug: Optional[str] = None slug: Optional[str] = None
dependencies: Sequence[type["MVTModule"]] = ()
supported_commands: Sequence[tuple[str, str]] = ()
def __init__( def __init__(
self, self,
@@ -51,7 +54,7 @@ class MVTModule:
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[Dict[str, Any]] = None, module_options: Optional[Dict[str, Any]] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
"""Initialize module. """Initialize module.
@@ -71,16 +74,23 @@ class MVTModule:
self.file_path: Optional[str] = file_path self.file_path: Optional[str] = file_path
self.target_path: Optional[str] = target_path self.target_path: Optional[str] = target_path
self.results_path: Optional[str] = results_path self.results_path: Optional[str] = results_path
self.module_options: Optional[Dict[str, Any]] = ( self.serial: Optional[str] = None
module_options if module_options else {} self.module_options: Dict[str, Any] = module_options if module_options else {}
)
self.log = log self.log = log
self.indicators: Optional[Indicators] = None self.indicators: Optional[Indicators] = None
self.alertstore: AlertStore = AlertStore(log=log) self.alertstore: AlertStore = AlertStore(log=log)
self.results: ModuleResults = results if results else [] self.results: ModuleResults = results if results is not None else []
self.timeline: ModuleTimeline = [] self.timeline: ModuleTimeline = []
self.url_results: list[URLResult] = []
self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {}
def get_dependency_results(
self, module_class: type["MVTModule"]
) -> ModuleResults:
"""Return the results produced by a prerequisite module."""
return self.dependency_modules[module_class].results
@classmethod @classmethod
def from_json(cls, json_path: str, log: logging.Logger): def from_json(cls, json_path: str, log: logging.Logger):
@@ -102,6 +112,23 @@ class MVTModule:
def check_indicators(self) -> None: def check_indicators(self) -> None:
raise NotImplementedError 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: def save_to_json(self) -> None:
if not self.results_path: if not self.results_path:
return return
@@ -109,11 +136,14 @@ class MVTModule:
name = self.get_slug() name = self.get_slug()
if self.results: if self.results:
converted_results: Any
if isinstance(self.results, dict): if isinstance(self.results, dict):
converted_results = self.results converted_results = self.results
else: else:
converted_results = [ converted_results = [
asdict(result) if is_dataclass(result) else result asdict(result)
if is_dataclass(result) and not isinstance(result, type)
else result
for result in self.results for result in self.results
] ]
results_file_name = f"{name}.json" results_file_name = f"{name}.json"
@@ -238,6 +268,15 @@ def run_module(module: MVTModule) -> None:
"The %s module produced no detections!", module.__class__.__name__ "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: try:
module.to_timeline() module.to_timeline()
except NotImplementedError: except NotImplementedError:
@@ -259,7 +298,7 @@ def save_timeline(timeline: list, timeline_path: str, is_utc: bool = True) -> No
:param timeline_path: Path to the csv file to store the timeline to :param timeline_path: Path to the csv file to store the timeline to
""" """
with open(timeline_path, "a+", encoding="utf-8") as handle: with open(timeline_path, "w", encoding="utf-8") as handle:
csvoutput = csv.writer( csvoutput = csv.writer(
handle, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL, escapechar="\\" handle, delimiter=",", quotechar='"', quoting=csv.QUOTE_ALL, escapechar="\\"
) )
+134
View File
@@ -0,0 +1,134 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import hashlib
import importlib.util
import inspect
import logging
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Iterable, Optional
from .module import MVTModule
MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES"
log = logging.getLogger(__name__)
class CustomModuleLoadError(Exception):
pass
def _module_name_for_path(path: Path) -> str:
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
return f"_mvt_custom_module_{path.stem}_{digest}"
def _iter_module_files(path: Path) -> Iterable[Path]:
if path.is_file():
if path.suffix != ".py":
raise CustomModuleLoadError(f"Custom module file is not a Python file: {path}")
yield path
return
if path.is_dir():
for child in sorted(path.iterdir()):
if child.name.startswith("."):
continue
if child.name == "__init__.py":
continue
if child.is_file() and child.suffix == ".py":
yield child
return
raise CustomModuleLoadError(f"Custom module path does not exist: {path}")
def _load_python_file(path: Path) -> ModuleType:
module_name = _module_name_for_path(path)
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise CustomModuleLoadError(f"Unable to load custom module file: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception as exc:
raise CustomModuleLoadError(f"Unable to import custom module {path}: {exc}") from exc
return module
def discover_mvt_modules(module: ModuleType) -> list[type[MVTModule]]:
modules = []
for _, obj in inspect.getmembers(module, inspect.isclass):
if obj is MVTModule:
continue
if obj.__module__ != module.__name__:
continue
if not issubclass(obj, MVTModule):
continue
modules.append(obj)
return modules
def load_custom_modules_from_path(path: str) -> list[type[MVTModule]]:
custom_modules: list[type[MVTModule]] = []
seen: set[tuple[str, str]] = set()
resolved_path = Path(path).expanduser().resolve()
for module_file in _iter_module_files(resolved_path):
loaded_module = _load_python_file(module_file)
for module_class in discover_mvt_modules(loaded_module):
key = (str(module_file), module_class.__qualname__)
if key in seen:
continue
seen.add(key)
custom_modules.append(module_class)
return custom_modules
def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTModule]]:
search_paths: list[str] = []
env_path = os.environ.get(MVT_CUSTOM_MODULES_ENV)
if env_path:
search_paths.append(env_path)
if paths:
search_paths.extend(paths)
custom_modules: list[type[MVTModule]] = []
seen: set[tuple[str, str]] = set()
for path in search_paths:
for module_class in load_custom_modules_from_path(path):
source = Path(inspect.getfile(module_class)).resolve()
key = (str(source), module_class.__qualname__)
if key in seen:
continue
seen.add(key)
custom_modules.append(module_class)
return custom_modules
def module_supports_command(
module_class: type[MVTModule],
platform: str,
command: str,
) -> bool:
supported_commands = getattr(module_class, "supported_commands", None)
if not supported_commands:
log.warning(
"Custom module %s has no supported_commands and will not be run. "
"Declare the platform/command pairs it supports.",
module_class.__name__,
)
return False
return (platform, command) in {tuple(entry) for entry in supported_commands}
+12 -2
View File
@@ -4,7 +4,7 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from dataclasses import dataclass 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. # ModuleAtomicResult is a flexible dictionary that can contain any data.
@@ -16,7 +16,17 @@ from typing import Any, Dict, List, Union
ModuleAtomicResult = Dict[str, Any] ModuleAtomicResult = Dict[str, Any]
ModuleResults = List[ModuleAtomicResult] # Extraction modules historically use either a list of records or grouped
# dictionaries keyed by source path. Keep this alias broad until those shapes
# are modeled per module.
ModuleResults = Any
class URLResult(TypedDict):
url: str
expanded_url: Optional[str]
timestamp: Optional[str]
source: str
@dataclass @dataclass
+143
View File
@@ -0,0 +1,143 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Password prompts with masked keyboard feedback.
This is based on the ``echo_char`` support added to :mod:`getpass` in Python
3.14. MVT supports Python 3.10 and later, so it cannot use that API directly.
"""
import contextlib
import io
import os
import sys
import warnings
from typing import TextIO
def prompt_password(prompt: str) -> str:
"""Return a password while displaying an asterisk for each character."""
try:
import termios
return _unix_getpass(prompt, termios)
except ImportError:
try:
import msvcrt
return _windows_getpass(prompt, msvcrt)
except ImportError:
return _fallback_getpass(prompt)
def _unix_getpass(prompt: str, termios) -> str:
password = None
input_stream: TextIO
output_stream: TextIO
with contextlib.ExitStack() as stack:
try:
fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
tty = io.FileIO(fd, "w+")
stack.enter_context(tty)
input_stream = io.TextIOWrapper(tty)
stack.enter_context(input_stream)
output_stream = input_stream
except OSError:
stack.close()
try:
fd = sys.stdin.fileno()
except (AttributeError, ValueError):
return _fallback_getpass(prompt)
input_stream = sys.stdin
output_stream = sys.stderr
try:
old = termios.tcgetattr(fd)
new = old[:]
new[3] &= ~termios.ECHO
new[3] &= ~termios.ICANON
try:
termios.tcsetattr(fd, termios.TCSAFLUSH, new)
password = _readline_with_asterisks(output_stream, input_stream, prompt)
finally:
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
output_stream.flush()
except termios.error:
if password is not None:
raise
password = _fallback_getpass(prompt)
output_stream.write("\n")
return password
def _windows_getpass(prompt: str, msvcrt) -> str:
if sys.stdin is not sys.__stdin__:
return _fallback_getpass(prompt)
for char in prompt:
msvcrt.putwch(char)
password = ""
while True:
char = msvcrt.getwch()
if char in ("\r", "\n"):
break
if char == "\x03":
raise KeyboardInterrupt
if char == "\b":
if password:
msvcrt.putwch("\b")
msvcrt.putwch(" ")
msvcrt.putwch("\b")
password = password[:-1]
else:
password += char
msvcrt.putwch("*")
msvcrt.putwch("\r")
msvcrt.putwch("\n")
return password
def _fallback_getpass(prompt: str) -> str:
warnings.warn(
"Can not control echo on the terminal.",
category=UserWarning,
stacklevel=2,
)
print("Warning: Password input may be echoed.", file=sys.stderr)
return input(prompt)
def _readline_with_asterisks(
output_stream: TextIO, input_stream: TextIO, prompt: str
) -> str:
output_stream.write(prompt)
output_stream.flush()
password = ""
eof_pressed = False
while True:
char = input_stream.read(1)
if char in ("\n", "\r"):
break
if char == "\x03":
raise KeyboardInterrupt
if char in ("\x7f", "\b"):
if password:
output_stream.write("\b \b")
output_stream.flush()
password = password[:-1]
elif char == "\x04":
if eof_pressed:
break
eof_pressed = True
elif char != "\x00":
password += char
output_stream.write("*")
output_stream.flush()
eof_pressed = False
return password
+1 -3
View File
@@ -180,10 +180,8 @@ class IndicatorsUpdates:
def _get_remote_file_latest_commit( def _get_remote_file_latest_commit(
self, owner: str, repo: str, branch: str, path: str self, owner: str, repo: str, branch: str, path: str
) -> int: ) -> int:
# TODO: The branch is currently not taken into consideration.
# How do we specify which branch to look up to the API?
file_commit_url = ( file_commit_url = (
f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}" f"https://api.github.com/repos/{owner}/{repo}/commits?path={path}&sha={branch}"
) )
try: try:
res = requests.get(file_commit_url, timeout=5) res = requests.get(file_commit_url, timeout=5)
+20 -1
View File
@@ -3,11 +3,17 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging
from typing import Optional from typing import Optional
from urllib.parse import urlparse
import requests import requests
from tld import get_tld from tld import get_tld
from .config import settings
log = logging.getLogger(__name__)
SHORTENER_DOMAINS = [ SHORTENER_DOMAINS = [
"0rz.tw", "0rz.tw",
"1drv.ms", "1drv.ms",
@@ -368,6 +374,10 @@ class URL:
:rtype: bool :rtype: bool
""" """
parsed_url = urlparse(self.url if "://" in self.url else f"//{self.url}")
if self.domain.lower() == "goo.gl" and parsed_url.path.startswith("/maps/"):
return False
if self.domain.lower() in SHORTENER_DOMAINS: if self.domain.lower() in SHORTENER_DOMAINS:
self.is_shortened = True self.is_shortened = True
@@ -375,7 +385,16 @@ class URL:
def unshorten(self) -> Optional[str]: def unshorten(self) -> Optional[str]:
"""Unshorten the URL by requesting an HTTP HEAD response.""" """Unshorten the URL by requesting an HTTP HEAD response."""
res = requests.head(self.url)
if settings.NETWORK_ACCESS_ALLOWED is False:
log.info(
"Network access disabled (MVT_NETWORK_ACCESS_ALLOWED=False), "
"skipping unshorten for %s",
self.url,
)
return ""
res = requests.head(self.url, timeout=settings.NETWORK_TIMEOUT)
if str(res.status_code).startswith("30"): if str(res.status_code).startswith("30"):
return res.headers["Location"] return res.headers["Location"]
+1 -2
View File
@@ -123,10 +123,9 @@ def convert_mactime_to_datetime(timestamp: Union[int, float], from_2001: bool =
if from_2001: if from_2001:
timestamp = timestamp + 978307200 timestamp = timestamp + 978307200
# TODO: This is rather ugly. Happens sometimes with invalid timestamps.
try: try:
return convert_unix_to_utc_datetime(timestamp) return convert_unix_to_utc_datetime(timestamp)
except Exception: except (OSError, OverflowError, ValueError):
return None return None
+1 -1
View File
@@ -3,4 +3,4 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
MVT_VERSION = "2026.5.12" MVT_VERSION = "2026.7.29"
+53
View File
@@ -0,0 +1,53 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Any, Optional
import requests
from .config import settings
log = logging.getLogger(__name__)
class VTNoKey(Exception):
pass
class VTQuotaExceeded(Exception):
pass
def virustotal_lookup(file_hash: str) -> Optional[dict[str, Any]]:
if not settings.VT_API_KEY:
raise VTNoKey(
"No VirusTotal API key provided: to use VirusTotal lookups please set "
"MVT_VT_API_KEY or VT_API_KEY in the MVT configuration file"
)
headers = {
"User-Agent": "VirusTotal",
"Content-Type": "application/json",
"x-apikey": settings.VT_API_KEY,
}
res = requests.get(
f"https://www.virustotal.com/api/v3/files/{file_hash}",
headers=headers,
timeout=settings.NETWORK_TIMEOUT,
)
if res.status_code == 200:
report = res.json()
return report["data"]
if res.status_code == 404:
log.info("Could not find results for file with hash %s", file_hash)
elif res.status_code == 429:
raise VTQuotaExceeded("You have exceeded the quota for your VirusTotal API key")
else:
raise RuntimeError(f"Unexpected response from VirusTotal: {res.status_code}")
return None
+200 -12
View File
@@ -8,9 +8,19 @@ import logging
import os import os
import click import click
from rich.prompt import Prompt from mvt.common.cli_plugins import (
IOS_CLI_PLUGIN_GROUP,
MVT_IOS_CUSTOM_COMMANDS_ENV,
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.cmd_check_iocs import CmdCheckIOCS from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.completion import (
SUPPORTED_SHELLS,
completion_instructions,
generate_completion_script,
install_completion_script,
)
from mvt.common.logo import logo from mvt.common.logo import logo
from mvt.common.options import MutuallyExclusiveOption from mvt.common.options import MutuallyExclusiveOption
from mvt.common.updates import IndicatorsUpdates from mvt.common.updates import IndicatorsUpdates
@@ -31,17 +41,23 @@ from mvt.common.help import (
HELP_MSG_OUTPUT, HELP_MSG_OUTPUT,
HELP_MSG_FAST, HELP_MSG_FAST,
HELP_MSG_LIST_MODULES, HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE, HELP_MSG_MODULE,
HELP_MSG_VERBOSE, HELP_MSG_VERBOSE,
HELP_MSG_CHECK_FS, HELP_MSG_CHECK_FS,
HELP_MSG_CHECK_IOCS, HELP_MSG_CHECK_IOCS,
HELP_MSG_STIX2, HELP_MSG_STIX2,
HELP_MSG_CHECK_IOS_BACKUP, HELP_MSG_CHECK_IOS_BACKUP,
HELP_MSG_CHECK_SYSDIAGNOSE,
HELP_MSG_DISABLE_UPDATE_CHECK, HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_COMPLETION,
) )
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.password import prompt_password
from .cmd_check_backup import CmdIOSCheckBackup from .cmd_check_backup import CmdIOSCheckBackup
from .cmd_check_fs import CmdIOSCheckFS from .cmd_check_fs import CmdIOSCheckFS
from .cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
from .decrypt import DecryptBackup from .decrypt import DecryptBackup
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
from .modules.fs import FS_MODULES from .modules.fs import FS_MODULES
@@ -65,10 +81,18 @@ def _get_disable_flags(ctx):
) )
def _load_custom_modules(load_module):
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
raise click.ClickException(str(exc)) from exc
# ============================================================================== # ==============================================================================
# Main # Main
# ============================================================================== # ==============================================================================
@click.group(invoke_without_command=False) @click.group(invoke_without_command=False)
@load_cli_commands_option
@click.option( @click.option(
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK "--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
) )
@@ -82,10 +106,11 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict) ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check ctx.obj["disable_indicator_check"] = disable_indicator_update_check
logo( if ctx.invoked_subcommand != "completion":
disable_version_check=disable_update_check, logo(
disable_indicator_check=disable_indicator_update_check, disable_version_check=disable_update_check,
) disable_indicator_check=disable_indicator_update_check,
)
# ============================================================================== # ==============================================================================
@@ -96,6 +121,40 @@ def version():
return return
# ==============================================================================
# Command: completion
# ==============================================================================
@cli.command("completion", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_COMPLETION)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
@click.pass_context
def completion(ctx, shell, install):
program_name = "mvt-ios"
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(completion_instructions(program_name))
return
root_cli = ctx.find_root().command
if install:
script_path = install_completion_script(root_cli, program_name, shell)
click.echo(f"Installed {shell} completion to {script_path}")
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads completion files automatically.")
return
click.echo(generate_completion_script(root_cli, program_name, shell))
# ============================================================================== # ==============================================================================
# Command: decrypt-backup # Command: decrypt-backup
# ============================================================================== # ==============================================================================
@@ -150,7 +209,7 @@ def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD) log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD)
backup.decrypt_with_password(os.environ[MVT_IOS_BACKUP_PASSWORD]) backup.decrypt_with_password(os.environ[MVT_IOS_BACKUP_PASSWORD])
else: else:
sekrit = Prompt.ask("Enter backup password", password=True) sekrit = prompt_password("Enter backup password: ")
backup.decrypt_with_password(sekrit) backup.decrypt_with_password(sekrit)
if not backup.can_process(): if not backup.can_process():
@@ -202,7 +261,7 @@ def extract_key(password, key_file, backup_path):
log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD) log.info("Using password from %s environment variable", MVT_IOS_BACKUP_PASSWORD)
password = os.environ[MVT_IOS_BACKUP_PASSWORD] password = os.environ[MVT_IOS_BACKUP_PASSWORD]
else: else:
password = Prompt.ask("Enter backup password", password=True) password = prompt_password("Enter backup password: ")
backup.decrypt_with_password(password) backup.decrypt_with_password(password)
backup.get_key() backup.get_key()
@@ -229,15 +288,32 @@ def extract_key(password, key_file, backup_path):
@click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST) @click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("BACKUP_PATH", type=click.Path(exists=True)) @click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_backup( def check_backup(
ctx, iocs, output, fast, list_modules, module, hashes, verbose, backup_path ctx,
iocs,
output,
fast,
list_modules,
module,
load_module,
hashes,
verbose,
backup_path,
): ):
set_verbose_logging(verbose) set_verbose_logging(verbose)
module_options = {"fast_mode": fast} module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckBackup( cmd = CmdIOSCheckBackup(
target_path=backup_path, target_path=backup_path,
@@ -248,13 +324,17 @@ def check_backup(
hashes=hashes, hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
return return
log.info("Checking iTunes backup located at: %s", backup_path) if not cmd.resolve_backup_path():
ctx.exit(1)
log.info("Checking iTunes backup located at: %s", cmd.target_path)
cmd.run() cmd.run()
cmd.show_alerts_brief() cmd.show_alerts_brief()
@@ -277,13 +357,32 @@ def check_backup(
@click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST) @click.option("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES) @click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE) @click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("DUMP_PATH", type=click.Path(exists=True)) @click.argument("DUMP_PATH", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dump_path): def check_fs(
ctx,
iocs,
output,
fast,
list_modules,
module,
load_module,
hashes,
verbose,
dump_path,
):
set_verbose_logging(verbose) set_verbose_logging(verbose)
module_options = {"fast_mode": fast} module_options = {"fast_mode": fast}
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckFS( cmd = CmdIOSCheckFS(
target_path=dump_path, target_path=dump_path,
@@ -294,6 +393,7 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
hashes=hashes, hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
) )
if list_modules: if list_modules:
@@ -307,6 +407,77 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
cmd.show_support_message() cmd.show_support_message()
# ==============================================================================
# Command: check-sysdiagnose
# ==============================================================================
@cli.command(
"check-sysdiagnose",
context_settings=CONTEXT_SETTINGS,
help=HELP_MSG_CHECK_SYSDIAGNOSE,
)
@click.option(
"--iocs",
"-i",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_IOC,
)
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_OUTPUT)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.argument("SYSDIAGNOSE_PATH", type=click.Path(exists=True))
@click.pass_context
def check_sysdiagnose(
ctx,
iocs,
output,
list_modules,
module,
load_module,
hashes,
verbose,
sysdiagnose_path,
):
set_verbose_logging(verbose)
custom_modules = _load_custom_modules(load_module)
cmd = CmdIOSCheckSysdiagnose(
target_path=sysdiagnose_path,
results_path=output,
ioc_files=iocs,
module_name=module,
hashes=hashes,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
)
if not cmd._available_modules():
raise click.ClickException(
"No custom modules support mvt-ios check-sysdiagnose. "
"Load a module that declares supported_commands = "
"((\"ios\", \"check-sysdiagnose\"),)."
)
if list_modules:
cmd.list_modules()
return
log.info("Checking iOS sysdiagnose at path: %s", sysdiagnose_path)
cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message()
# ============================================================================== # ==============================================================================
# Command: check-iocs # Command: check-iocs
# ============================================================================== # ==============================================================================
@@ -321,15 +492,25 @@ def check_fs(ctx, iocs, output, fast, list_modules, module, hashes, verbose, dum
) )
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES) @click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE) @click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option(
"--load-module",
type=click.Path(exists=True),
multiple=True,
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.argument("FOLDER", type=click.Path(exists=True)) @click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context @click.pass_context
def check_iocs(ctx, iocs, list_modules, module, folder): def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS( cmd = CmdCheckIOCS(
target_path=folder, target_path=folder,
ioc_files=iocs, ioc_files=iocs,
module_name=module, module_name=module,
disable_version_check=_get_disable_flags(ctx)[0], disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1], disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
platform="ios",
) )
cmd.modules = BACKUP_MODULES + FS_MODULES + MIXED_MODULES cmd.modules = BACKUP_MODULES + FS_MODULES + MIXED_MODULES
@@ -349,3 +530,10 @@ def check_iocs(ctx, iocs, list_modules, module, folder):
def download_iocs(): def download_iocs():
ioc_updates = IndicatorsUpdates() ioc_updates = IndicatorsUpdates()
ioc_updates.update() ioc_updates.update()
register_cli_plugins(
cli,
entry_point_group=IOS_CLI_PLUGIN_GROUP,
environment_variable=MVT_IOS_CUSTOM_COMMANDS_ENV,
)
+52
View File
@@ -4,10 +4,12 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging import logging
import os
from typing import Optional from typing import Optional
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
from .modules.mixed import MIXED_MODULES from .modules.mixed import MIXED_MODULES
@@ -15,6 +17,12 @@ from .modules.mixed import MIXED_MODULES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def is_ios_backup_folder(path: str) -> bool:
return os.path.isfile(os.path.join(path, "Manifest.db")) and os.path.isfile(
os.path.join(path, "Info.plist")
)
class CmdIOSCheckBackup(Command): class CmdIOSCheckBackup(Command):
def __init__( def __init__(
self, self,
@@ -29,6 +37,7 @@ class CmdIOSCheckBackup(Command):
sub_command: bool = False, sub_command: bool = False,
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -43,10 +52,53 @@ class CmdIOSCheckBackup(Command):
log=log, log=log,
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "ios"
self.name = "check-backup" self.name = "check-backup"
self.modules = BACKUP_MODULES + MIXED_MODULES self.modules = BACKUP_MODULES + MIXED_MODULES
def resolve_backup_path(self) -> bool:
target_path = getattr(self, "target_path", None)
if not isinstance(target_path, str) or not target_path:
return False
if is_ios_backup_folder(target_path):
return True
if not os.path.isdir(target_path):
self.log.critical(
"%s does not appear to be an iTunes backup folder. "
"Expected Manifest.db and Info.plist.",
target_path,
)
return False
candidates = []
for entry_name in sorted(os.listdir(target_path)):
entry_path = os.path.join(target_path, entry_name)
if os.path.isdir(entry_path) and is_ios_backup_folder(entry_path):
candidates.append(entry_path)
if len(candidates) == 1:
self.log.info("Found iTunes backup in subfolder: %s", candidates[0])
self.target_path = candidates[0]
return True
if candidates:
self.log.critical(
"Found multiple iTunes backups in %s. Please specify one backup folder.",
target_path,
)
return False
self.log.critical(
"%s does not appear to be an iTunes backup folder. "
"Expected Manifest.db and Info.plist.",
target_path,
)
return False
def module_init(self, module): def module_init(self, module):
module.is_backup = True module.is_backup = True
+4
View File
@@ -8,6 +8,7 @@ from typing import Optional
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
from .modules.fs import FS_MODULES from .modules.fs import FS_MODULES
from .modules.mixed import MIXED_MODULES from .modules.mixed import MIXED_MODULES
@@ -29,6 +30,7 @@ class CmdIOSCheckFS(Command):
sub_command: bool = False, sub_command: bool = False,
disable_version_check: bool = False, disable_version_check: bool = False,
disable_indicator_check: bool = False, disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
@@ -42,8 +44,10 @@ class CmdIOSCheckFS(Command):
log=log, log=log,
disable_version_check=disable_version_check, disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check, disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
) )
self.platform = "ios"
self.name = "check-fs" self.name = "check-fs"
self.modules = FS_MODULES + MIXED_MODULES self.modules = FS_MODULES + MIXED_MODULES
+173
View File
@@ -0,0 +1,173 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import json
import logging
import os
import shutil
import tarfile
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from typing import Any, Optional
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
log = logging.getLogger(__name__)
class CmdIOSCheckSysdiagnose(Command):
def __init__(
self,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
ioc_files: Optional[list] = None,
iocs: Optional[Indicators] = None,
module_name: Optional[str] = None,
serial: Optional[str] = None,
module_options: Optional[dict] = None,
hashes: bool = False,
sub_command: bool = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
) -> None:
super().__init__(
target_path=target_path,
results_path=results_path,
ioc_files=ioc_files,
iocs=iocs,
module_name=module_name,
serial=serial,
module_options=module_options,
hashes=hashes,
sub_command=sub_command,
log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
)
self.platform = "ios"
self.name = "check-sysdiagnose"
self.sysdiagnose_format: Optional[str] = None
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
self.sysdiagnose_files: list[str] = []
self.ips_files: list[dict[str, Any]] = []
self.temp_sysdiagnose_dir: Optional[TemporaryDirectory[str]] = None
self.extracted_sysdiagnose_path: Optional[str] = None
@staticmethod
def _parse_bugtype_header(data: bytes) -> Optional[int]:
try:
header = json.loads(data.split(b"\n", 1)[0].decode("utf-8"))
return int(header["bug_type"])
except (json.JSONDecodeError, KeyError, UnicodeDecodeError, ValueError):
return None
def _add_ips_file(self, file_path: str, data: bytes) -> None:
bug_type = self._parse_bugtype_header(data)
if bug_type is not None:
self.ips_files.append({"file_path": file_path, "bug_type": bug_type})
def init(self) -> None:
if not self.target_path:
raise ValueError("A sysdiagnose path is required")
if os.path.isdir(self.target_path):
self.sysdiagnose_format = "dir"
parent_path = Path(self.target_path).absolute().parent
for root, _, filenames in os.walk(self.target_path):
for filename in filenames:
absolute_path = os.path.join(root, filename)
file_path = os.path.relpath(absolute_path, parent_path)
self.sysdiagnose_files.append(file_path)
if filename.endswith(".ips"):
with open(absolute_path, "rb") as handle:
self._add_ips_file(absolute_path, handle.read())
return
if not os.path.isfile(self.target_path):
raise ValueError(f"Sysdiagnose path does not exist: {self.target_path}")
self.log.info("Parsing sysdiagnose archive. This might take a while...")
self.sysdiagnose_format = "tar"
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
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.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:
module.from_sysdiagnose_folder(self.target_path, self.sysdiagnose_files)
return
raise RuntimeError("Sysdiagnose input has not been initialized")
def finish(self) -> None:
if self.sysdiagnose_archive is not None:
self.sysdiagnose_archive.close()
self.sysdiagnose_archive = None
if self.temp_sysdiagnose_dir is not None:
self.temp_sysdiagnose_dir.cleanup()
self.temp_sysdiagnose_dir = None
+8
View File
@@ -1216,6 +1216,10 @@
"version": "18.7.9", "version": "18.7.9",
"build": "22H355" "build": "22H355"
}, },
{
"version": "18.7.10",
"build": "22H373"
},
{ {
"version": "26", "version": "26",
"build": "23A341" "build": "23A341"
@@ -1255,5 +1259,9 @@
{ {
"version": "26.5", "version": "26.5",
"build": "23F77" "build": "23F77"
},
{
"version": "26.6.1",
"build": "23G82"
} }
] ]
+2 -2
View File
@@ -25,7 +25,7 @@ class BackupInfo(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -36,7 +36,7 @@ class BackupInfo(IOSExtraction):
results=results, results=results,
) )
self.results: dict = {} self.results: dict = results if results is not None else {}
def run(self) -> None: def run(self) -> None:
if not self.target_path: if not self.target_path:
@@ -33,7 +33,7 @@ class ConfigurationProfiles(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -85,6 +85,35 @@ class ConfigurationProfiles(IOSExtraction):
self.alertstore.medium(warning_message, "", result) self.alertstore.medium(warning_message, "", result)
continue continue
@staticmethod
def _b64encode_key(d: dict, key: str) -> None:
if key in d:
d[key] = b64encode(d[key])
@staticmethod
def _b64encode_keys(d: dict, keys: list) -> None:
for key in keys:
if key in d:
d[key] = b64encode(d[key])
def _b64encode_plist_bytes(self, plist: dict) -> None:
"""Encode binary plist values to base64 for JSON serialization."""
if "SignerCerts" in plist:
plist["SignerCerts"] = [b64encode(x) for x in plist["SignerCerts"]]
self._b64encode_keys(plist, ["PushTokenDataSentToServerKey", "LastPushTokenHash"])
if "OTAProfileStub" in plist:
stub = plist["OTAProfileStub"]
if "SignerCerts" in stub:
stub["SignerCerts"] = [b64encode(x) for x in stub["SignerCerts"]]
if "PayloadContent" in stub:
self._b64encode_key(stub["PayloadContent"], "EnrollmentIdentityPersistentID")
if "PayloadContent" in plist:
for entry in plist["PayloadContent"]:
self._b64encode_keys(entry, ["PERSISTENT_REF", "IdentityPersistentRef"])
def run(self) -> None: def run(self) -> None:
for conf_file in self._get_backup_files_from_manifest( for conf_file in self._get_backup_files_from_manifest(
domain=CONF_PROFILES_DOMAIN domain=CONF_PROFILES_DOMAIN
@@ -113,65 +142,7 @@ class ConfigurationProfiles(IOSExtraction):
except Exception: except Exception:
conf_plist = {} conf_plist = {}
# TODO: Tidy up the following code hell. self._b64encode_plist_bytes(conf_plist)
if "SignerCerts" in conf_plist:
conf_plist["SignerCerts"] = [
b64encode(x) for x in conf_plist["SignerCerts"]
]
if "OTAProfileStub" in conf_plist:
if "SignerCerts" in conf_plist["OTAProfileStub"]:
conf_plist["OTAProfileStub"]["SignerCerts"] = [
b64encode(x)
for x in conf_plist["OTAProfileStub"]["SignerCerts"]
]
if "PayloadContent" in conf_plist["OTAProfileStub"]:
if (
"EnrollmentIdentityPersistentID"
in conf_plist["OTAProfileStub"]["PayloadContent"]
):
conf_plist["OTAProfileStub"]["PayloadContent"][
"EnrollmentIdentityPersistentID"
] = b64encode(
conf_plist["OTAProfileStub"]["PayloadContent"][
"EnrollmentIdentityPersistentID"
]
)
if "PushTokenDataSentToServerKey" in conf_plist:
conf_plist["PushTokenDataSentToServerKey"] = b64encode(
conf_plist["PushTokenDataSentToServerKey"]
)
if "LastPushTokenHash" in conf_plist:
conf_plist["LastPushTokenHash"] = b64encode(
conf_plist["LastPushTokenHash"]
)
if "PayloadContent" in conf_plist:
for content_entry in range(len(conf_plist["PayloadContent"])):
if "PERSISTENT_REF" in conf_plist["PayloadContent"][content_entry]:
conf_plist["PayloadContent"][content_entry][
"PERSISTENT_REF"
] = b64encode(
conf_plist["PayloadContent"][content_entry][
"PERSISTENT_REF"
]
)
if (
"IdentityPersistentRef"
in conf_plist["PayloadContent"][content_entry]
):
conf_plist["PayloadContent"][content_entry][
"IdentityPersistentRef"
] = b64encode(
conf_plist["PayloadContent"][content_entry][
"IdentityPersistentRef"
]
)
self.results.append( self.results.append(
{ {
+1 -1
View File
@@ -33,7 +33,7 @@ class Manifest(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
+1 -1
View File
@@ -34,7 +34,7 @@ class ProfileEvents(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
+64 -21
View File
@@ -9,6 +9,7 @@ import os
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
import tempfile
from pathlib import Path from pathlib import Path
from typing import Iterator, Optional, Union from typing import Iterator, Optional, Union
@@ -20,6 +21,20 @@ from mvt.common.module import (
) )
class TemporarySQLiteConnection(sqlite3.Connection):
"""SQLite connection that owns a temporary copy of a database."""
temporary_directory: Optional[tempfile.TemporaryDirectory] = None
def close(self) -> None:
try:
super().close()
finally:
if self.temporary_directory:
self.temporary_directory.cleanup()
self.temporary_directory = None
class IOSExtraction(MVTModule): class IOSExtraction(MVTModule):
"""This class provides a base for all iOS filesystem/backup extraction """This class provides a base for all iOS filesystem/backup extraction
modules.""" modules."""
@@ -31,7 +46,7 @@ class IOSExtraction(MVTModule):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -44,6 +59,8 @@ class IOSExtraction(MVTModule):
self.is_backup = False self.is_backup = False
self.is_fs_dump = False self.is_fs_dump = False
self._recovered_sqlite_paths: dict[str, str] = {}
self._sqlite_temp_directories: list[tempfile.TemporaryDirectory] = []
def _recover_sqlite_db_if_needed( def _recover_sqlite_db_if_needed(
self, file_path: str, forced: bool = False self, file_path: str, forced: bool = False
@@ -53,17 +70,12 @@ class IOSExtraction(MVTModule):
:param file_path: Path to the malformed database file. :param file_path: Path to the malformed database file.
""" """
# TODO: Find a better solution.
if not forced: if not forced:
# If the database is open, do not use immutable conn = self._open_sqlite_db(file_path)
if os.path.isfile(file_path + "-shm"):
conn = sqlite3.connect(file_path)
else:
conn = self._open_sqlite_db(file_path)
cur = conn.cursor() cur = conn.cursor()
recover = False
try: try:
recover = False
cur.execute("SELECT name FROM sqlite_master WHERE type='table';") cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
except sqlite3.DatabaseError as exc: except sqlite3.DatabaseError as exc:
if "database disk image is malformed" in str(exc): if "database disk image is malformed" in str(exc):
@@ -82,27 +94,54 @@ class IOSExtraction(MVTModule):
raise DatabaseCorruptedError( raise DatabaseCorruptedError(
"failed to recover without sqlite3 binary: please install sqlite3!" "failed to recover without sqlite3 binary: please install sqlite3!"
) )
if '"' in file_path: temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_recover_")
raise DatabaseCorruptedError( temporary_path = Path(temporary_directory.name)
f"database at path '{file_path}' is corrupted. unable to " source_path = temporary_path / "source.db"
'recover because it has a quotation mark (") in its name' recovered_path = temporary_path / "recovered.db"
) shutil.copy2(file_path, source_path)
for suffix in ("-wal", "-shm"):
bak_path = f"{file_path}.bak" sidecar = Path(file_path + suffix)
shutil.move(file_path, bak_path) if sidecar.is_file():
shutil.copy2(sidecar, Path(str(source_path) + suffix))
ret = subprocess.call( ret = subprocess.call(
["sqlite3", bak_path, f'.clone "{file_path}"'], ["sqlite3", str(source_path), f'.clone "{recovered_path}"'],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) )
if ret != 0: if ret != 0:
temporary_directory.cleanup()
raise DatabaseCorruptedError("failed to recover database") raise DatabaseCorruptedError("failed to recover database")
self._sqlite_temp_directories.append(temporary_directory)
self._recovered_sqlite_paths[file_path] = str(recovered_path)
self.log.info("Database at path %s recovered successfully!", file_path) self.log.info("Database at path %s recovered successfully!", file_path)
def _open_sqlite_db(self, file_path: str) -> sqlite3.Connection: def _open_sqlite_db(self, file_path: str) -> sqlite3.Connection:
return sqlite3.connect(f"file:{file_path}?immutable=1", uri=True) database_path = self._recovered_sqlite_paths.get(file_path, file_path)
if not os.path.isfile(database_path + "-wal"):
uri = Path(database_path).resolve().as_uri() + "?mode=ro&immutable=1"
return sqlite3.connect(uri, uri=True)
temporary_directory = tempfile.TemporaryDirectory(prefix="mvt_sqlite_")
temporary_path = Path(temporary_directory.name) / Path(database_path).name
shutil.copy2(database_path, temporary_path)
for suffix in ("-wal", "-shm"):
sidecar = Path(database_path + suffix)
if sidecar.is_file():
shutil.copy2(sidecar, Path(str(temporary_path) + suffix))
try:
conn = sqlite3.connect(
temporary_path.resolve().as_uri() + "?mode=ro",
uri=True,
factory=TemporarySQLiteConnection,
)
except Exception:
temporary_directory.cleanup()
raise
conn.temporary_directory = temporary_directory
return conn
def _get_backup_files_from_manifest( def _get_backup_files_from_manifest(
self, relative_path: Optional[str] = None, domain: Optional[str] = None self, relative_path: Optional[str] = None, domain: Optional[str] = None
@@ -166,7 +205,11 @@ class IOSExtraction(MVTModule):
if not self.target_path: if not self.target_path:
return None return None
file_path = os.path.join(self.target_path, file_id[0:2], file_id) file_path = os.path.join(self.target_path, file_id[0:2], file_id)
if not Path(file_path).resolve().is_relative_to(Path(self.target_path).resolve()): if (
not Path(file_path)
.resolve()
.is_relative_to(Path(self.target_path).resolve())
):
return None return None
if os.path.exists(file_path): if os.path.exists(file_path):
return file_path return file_path
@@ -197,9 +240,9 @@ class IOSExtraction(MVTModule):
:param backup_ids: Default value = None) :param backup_ids: Default value = None)
""" """
file_path: Optional[str] = None file_path: Optional[str] = self.file_path
# First we check if the was an explicit file path specified. # First we check if the was an explicit file path specified.
if not self.file_path: if not file_path:
# Type narrowing: we know self.file_path is None here, work with local file_path # Type narrowing: we know self.file_path is None here, work with local file_path
# If not, we first try with backups. # If not, we first try with backups.
# We construct the path to the file according to the iTunes backup # We construct the path to the file according to the iTunes backup
+4 -2
View File
@@ -34,7 +34,7 @@ class Analytics(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -44,7 +44,7 @@ class Analytics(IOSExtraction):
log=log, log=log,
results=results, results=results,
) )
self.results: list = [] self.results: list = [] if results is None else results
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
return { return {
@@ -134,6 +134,8 @@ class Analytics(IOSExtraction):
isodate = "" isodate = ""
data = plistlib.loads(row[1]) data = plistlib.loads(row[1])
data["isodate"] = isodate data["isodate"] = isodate
else:
continue
data["artifact"] = artifact data["artifact"] = artifact
@@ -30,7 +30,7 @@ class AnalyticsIOSVersions(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
+5 -1
View File
@@ -25,7 +25,7 @@ class CacheFiles(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -99,6 +99,10 @@ class CacheFiles(IOSExtraction):
def run(self) -> None: def run(self) -> None:
self.results: dict = {} self.results: dict = {}
if not self.target_path:
self.log.error("No filesystem dump path provided")
return
for root, _, files in os.walk(self.target_path): for root, _, files in os.walk(self.target_path):
for file_name in files: for file_name in files:
if file_name != "Cache.db": if file_name != "Cache.db":
+5 -1
View File
@@ -29,7 +29,7 @@ class Filesystem(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -73,6 +73,10 @@ class Filesystem(IOSExtraction):
) )
def run(self) -> None: def run(self) -> None:
if not self.target_path:
self.log.error("No filesystem dump path provided")
return
for root, dirs, files in os.walk(self.target_path): for root, dirs, files in os.walk(self.target_path):
for dir_name in dirs: for dir_name in dirs:
try: try:
+1 -1
View File
@@ -30,7 +30,7 @@ class Netusage(NetBase):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
+2 -2
View File
@@ -31,7 +31,7 @@ class SafariFavicon(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -41,7 +41,7 @@ class SafariFavicon(IOSExtraction):
log=log, log=log,
results=results, results=results,
) )
self.results: list = [] self.results: list = [] if results is None else results
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
return { return {
+17 -8
View File
@@ -6,6 +6,7 @@
import logging import logging
from typing import Optional from typing import Optional
from mvt.common.module import DatabaseNotFoundError
from mvt.common.module_types import ( from mvt.common.module_types import (
ModuleAtomicResult, ModuleAtomicResult,
ModuleResults, ModuleResults,
@@ -17,6 +18,7 @@ from ..base import IOSExtraction
SHUTDOWN_LOG_PATH = [ SHUTDOWN_LOG_PATH = [
"private/var/db/diagnostics/shutdown.log", "private/var/db/diagnostics/shutdown.log",
"private/var/db/diagnostics/shutdown.*.log",
] ]
@@ -30,7 +32,7 @@ class ShutdownLog(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -78,7 +80,7 @@ class ShutdownLog(IOSExtraction):
recent_processes = [] recent_processes = []
times_delayed = 0 times_delayed = 0
delay = 0.0 delay = 0.0
for line in content.split("\n"): for line in content.splitlines():
line = line.strip() line = line.strip()
if line.startswith("remaining client pid:"): if line.startswith("remaining client pid:"):
@@ -132,10 +134,17 @@ class ShutdownLog(IOSExtraction):
self.results = sorted(self.results, key=lambda entry: entry["isodate"]) self.results = sorted(self.results, key=lambda entry: entry["isodate"])
def run(self) -> None: def run(self) -> None:
self._find_ios_database(root_paths=SHUTDOWN_LOG_PATH) if self.file_path:
self.log.info("Found shutdown log at path: %s", self.file_path) shutdown_log_paths = [self.file_path]
else:
shutdown_log_paths = sorted(
self._get_fs_files_from_patterns(SHUTDOWN_LOG_PATH)
)
if not self.file_path: if not shutdown_log_paths:
return raise DatabaseNotFoundError("unable to find any shutdown log files")
with open(self.file_path, "r", encoding="utf-8") as handle:
self.process_shutdownlog(handle.read()) for shutdown_log_path in shutdown_log_paths:
self.log.info("Found shutdown log at path: %s", shutdown_log_path)
with open(shutdown_log_path, "r", encoding="utf-8") as handle:
self.process_shutdownlog(handle.read())
+2 -2
View File
@@ -32,7 +32,7 @@ class IOSVersionHistory(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -42,7 +42,7 @@ class IOSVersionHistory(IOSExtraction):
log=log, log=log,
results=results, results=results,
) )
self.results: list = [] self.results: list = [] if results is None else results
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
return { return {
+1 -1
View File
@@ -34,7 +34,7 @@ class WebkitIndexedDB(WebkitBase):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,7 +32,7 @@ class WebkitLocalStorage(WebkitBase):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -28,7 +28,7 @@ class WebkitSafariViewService(WebkitBase):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
+1 -1
View File
@@ -41,7 +41,7 @@ class Applications(IOSExtraction):
results_path: Optional[str] = None, results_path: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__), log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [], results: Optional[ModuleResults] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more