Compare commits

..

3 Commits

Author SHA1 Message Date
Donncha Ó Cearbhaill
82f5e5c627 Merge branch 'main' into feature/android-sub-module-loading 2025-02-06 20:51:59 +01:00
Donncha Ó Cearbhaill
2bb613fe09 Return after loading bugreport module 2024-10-28 11:19:45 +01:00
Donncha Ó Cearbhaill
355850bd5c WIP: Run bugreport modules against bugreport.zip in AndroidQF extraction 2024-10-28 11:12:20 +01:00
212 changed files with 3664 additions and 4045 deletions

View File

@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"

View File

@@ -12,7 +12,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] python-version: ['3.8', '3.9', '3.10'] # , '3.11']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -35,4 +35,4 @@ jobs:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
with: with:
pytest-coverage-path: ./pytest-coverage.txt pytest-coverage-path: ./pytest-coverage.txt
junitxml-path: ./pytest.xml junitxml-path: ./pytest.xml

View File

@@ -21,7 +21,6 @@ jobs:
title: '[auto] Update iOS releases and versions' title: '[auto] Update iOS releases and versions'
commit-message: Add new iOS versions and build numbers commit-message: Add new iOS versions and build numbers
branch: auto/add-new-ios-releases branch: auto/add-new-ios-releases
draft: true
body: | body: |
This is an automated pull request to update the iOS releases and version numbers. This is an automated pull request to update the iOS releases and version numbers.
add-paths: | add-paths: |

View File

@@ -103,7 +103,7 @@ RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
# Create main image # Create main image
FROM ubuntu:24.04 as main FROM ubuntu:22.04 as main
LABEL org.opencontainers.image.url="https://mvt.re" LABEL org.opencontainers.image.url="https://mvt.re"
LABEL org.opencontainers.image.documentation="https://docs.mvt.re" LABEL org.opencontainers.image.documentation="https://docs.mvt.re"
@@ -135,7 +135,8 @@ COPY --from=build-usbmuxd /build /
COPY . mvt/ COPY . mvt/
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y git python3-pip \ && apt-get install -y git python3-pip \
&& PIP_NO_CACHE_DIR=1 pip3 install --break-system-packages ./mvt \ && PIP_NO_CACHE_DIR=1 pip3 install --upgrade pip \
&& PIP_NO_CACHE_DIR=1 pip3 install ./mvt \
&& apt-get remove -y python3-pip git && apt-get autoremove -y \ && apt-get remove -y python3-pip git && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& rm -rf mvt && rm -rf mvt

View File

@@ -1,9 +1,14 @@
PWD = $(shell pwd) PWD = $(shell pwd)
autofix:
ruff format .
ruff check --fix .
check: ruff mypy check: ruff mypy
ruff: ruff:
ruff check . ruff format --check .
ruff check -q .
mypy: mypy:
mypy mypy
@@ -18,7 +23,7 @@ install:
python3 -m pip install --upgrade -e . python3 -m pip install --upgrade -e .
test-requirements: test-requirements:
python3 -m pip install --upgrade --group dev python3 -m pip install --upgrade -r test-requirements.txt
generate-proto-parsers: generate-proto-parsers:
# Generate python parsers for protobuf files # Generate python parsers for protobuf files

View File

@@ -2,61 +2,4 @@
Thank you for your interest in reporting security issues and vulnerabilities! Security research is of utmost importance and we take all reports seriously. If you discover an issue please report it to us right away! Thank you for your interest in reporting security issues and vulnerabilities! Security research is of utmost importance and we take all reports seriously. If you discover an issue please report it to us right away!
Please DO NOT file a public issue, instead send your report privately to the MVT maintainers at Amnesty International via `security [at] amnesty [dot] tech`. Please DO NOT file a public issue, instead send your report privately to *nex [at] nex [dot] sx*. You can also write PGP-encrypted emails to [this key](https://keybase.io/nex/pgp_keys.asc?fingerprint=05216f3b86848a303c2fe37dd166f1667359d880).
You can also write PGP-encrypted emails to key `CFBF9698DCA8EB2A80F48ADEA035A030FA04ED13`. The corresponding PGP public key is lited below.
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBGlFPwsBEADQ+d7SeHrFPYv3wPOjWs2oMpp0DPdfIyGbg+iYWOC36FegZhKY
+WeK96GqJWt8wD6kwFUVwQI795WZrjSd1q4a7wR+kj/h7xlRB6ZfVICA6O5DOOm6
GNMvqy7ESm8g1XZDpb2u1BXmSS9X8f6rjB0e86kYsF1mB5/2USTM63jgDs0GGTkZ
Q1z4Mq4gYyqH32b3gvXkbb68LeQmONUIM3cgmec9q8/pNc1l7fcoLWhOVADRj17Q
plisa/EUf/SYqdtk9w7EHGggNenKNwVM235mkPcMqmE72bTpjT6XCxvZY3ByG5yi
7L+tHJU45ZuXtt62EvX03azxThVfSmH/WbRk8lH8+CW8XMmiWZphG4ydPWqgVKCB
2UOXm+6CQnKA+7Dt1AeK2t5ciATrv9LvwgSxk5WKc3288XFLA6eGMrTdQygYlLjJ
+42RSdK/7fCt/qk4q13oUw8ZTVcCia98uZFi704XuuYTH6NrntIB7j/0oucIS4Y9
cTWNO5LBerez4v8VI4YHcYESPeIWGFkXhvJzo0VMg1zidBLtiPoGF2JKZGwaK7/p
yY1xALskLp4H+5OY4eB1kf8kl4vGsEK8xA/NNzOiapVmwBXpvVvmXIQJE2k+olNf
sAuyB8+aO1Ws7tFYt3D+olC7iaprOdK7uA4GCgmYYhq6QQPg+cxfczgHfwARAQAB
tD1TZWN1cml0eSBMYWIgYXQgQW1uZXN0eSBJbnRlcm5hdGlvbmFsIDxzZWN1cml0
eUBhbW5lc3R5LnRlY2g+iQJRBBMBCAA7FiEEz7+WmNyo6yqA9IreoDWgMPoE7RMF
AmlFPwsCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQoDWgMPoE7RNr
2w//a88uP90uSN6lgeIwKsHr1ri27QIBbzCV6hLN/gZBFR2uaiOn/xfFDbnR0Cjo
5nMCJCT1k4nrPbMTlfmWLCD+YKELBzVqWlw4J2SOg3nznPl2JrL8QBKjwts0sF+h
QbRWDsT54wBZnl6ZJJ79eLShNTokBbKnQ7071dMrENr5e2P2sClQXyiIc51ga4FM
fHyhsx+GsrdiZNd2AH8912ljW1GuEi3epTO7KMZprmr37mjpZSUToiV59Yhl1Gbo
2pixkYJqi62DG02/gTpCjq9NH3cEMxcxjh4E7yCA8ggLG6+IN6woIvPIdOsnQ+Yj
d3H4rMNBjPSKoL+bdHILkCnp5HokcbVjNY3QAyOAF4qWhk4GtgpTshwxUmb4Tbay
tWLJC2bzjuUBxLkGzMVFfU3B96sVS4Fi0sBaEMBtHskl2f45X8LJhSq//Lw/2L/8
34uP/RxDSn+DPvj/yqMpekdCcmeFSTX1A19xkPcc0rVhMRde4VL338R86vzh0gMI
1LySDAhXZyVWzrQ5s3n6N3EvCaHCn3qu7ieyFJifCSR7gZqevCEznMQRVpkMTzUt
rk13Z6NOOb4IlTW7HFoY3omJG8Z5jV4kMIE7n6nb0qpNYQiG+YvjenQ3VrMoISyh
lpS2De8+oOtwrxBVX3+qKWvQqzufeE3416kw2Z+5mxH7bx25Ag0EaUU/CwEQALyZ
b+kwLN1yHObTm2yDBEn5HbCT3H1GremvPNmbAaTnfrjUngoKa8MuWWzbX5ptgmZR
UpYY/ylOYcgGydz58vUNrPlhIZT9UhmiifPgZLEXyd0uFpr/NsbRajHMkK10iEZf
h5bHNobiB7pGCu4Uj9e1cMiIZ4yEaYeyXYUoNHf6ISP39mJhHy6ov5yIpm9q0wzm
tGUQPupxGXmEZlOPr3lxqXQ3Ekdv6cWDY5r/oOq71QJ/HUQ13QUuGFIbhnMbT8zd
zaS6f/v772YKsWPc4NNUhtlf25VnQ4FuUtjCe3p6iYP4OVD8gJm0GvXyvyTuiQbL
CSk/378JiNT7nZzYXxrWchMwvEoMIU55+/UaBc50HI5xvDQ858CX7PYGiimcdsO1
EkQzhVxRfjlILfWrC2lgt+H5qhTn4Fah250Xe1PnLjXGHVUQnY/f3MFeiWQgf92b
02+MfvOeC5OKttP1z5lcx6RFWCIa1E/u8Nj7YrH9hk0ZBRAnBaeAncDFY8dfX2zX
VMoc0dV16gM7RrZ6i7D3CG3eLLkQlX0jbW9dzTuG/3f098EWB1p8vOfS/RbNCBRX
jqGiqacL/aFF3Ci3nQ4O5tSv1XipbgrUhvXnwm9pxrLPS/45iaO59WN4RRGWLLQ7
LHmeBxoa9avv0SdBYUL+eBxY46GXb/j5VLzHYhSnABEBAAGJAjYEGAEIACAWIQTP
v5aY3KjrKoD0it6gNaAw+gTtEwUCaUU/CwIbDAAKCRCgNaAw+gTtEyvsEACnyFFD
alOZTrrJTXNnUejuiExLh+qTO3T91p5bte597jpwCZnYGwkxEfffsqqhlY6ftEOf
d5tNWE5isai4v8XCbplWomz4KBpepxcn2b+9o5dSyr1vohEFuCJziZDsta1J2DX5
IE9U48kTgLDfdIBhuOyHNRkvXRHP2OVLCaiw4d9q+hlrraR8pehHt2BJSxh+QZoe
n0iHvIZCBIUA45zLEGmXFpNTGeEf2dKPp3xOkAXOhAMPptE0V1itkF3R7kEW4aFO
SZo8L3C1aWSz/gQ4/vvW5t1IJxirNMUgTMQFvqEkAwX3fm6GCxlgRSvTTRXdcrS8
6qyFdH1nkCNsavPahN3N2RGGIlWtODEMTO1Hjy0kZtTYdW+JH9sendliCoJES+yN
DjM125SgdAgrqlSYm/g8n9knWpxZv1QM6jU/sVz1J+l6/ixugL2i+CAL2d6uv4tT
QmXnu7Ei4/2kHBUu3Lf59MNgmLHm6F7AhOWErszSeoJKsp+3yA1oTT/npz67sRzY
VVyxz4NBIollna59a1lz0RhlWzNKqNB27jhylyM4ltdzHB7r4VMAVJyttozmIIOC
35ucYxl5BHLuapaRSaYHdUId1LOccYyaOOFF/PSyCu9dKzXk7zEz2HNcIboWSkAE
8ZDExMYM4WVpVCOj+frdsaBvzItHacRWuijtkw==
=JAXX
-----END PGP PUBLIC KEY BLOCK-----
```

View File

@@ -16,35 +16,27 @@ Now you can try launching MVT with:
mvt-android check-adb --output /path/to/results mvt-android check-adb --output /path/to/results
``` ```
!!! warning
The `check-adb` command is deprecated and will be removed in a future release.
Whenever possible, prefer acquiring device data using the AndroidQF project (https://github.com/mvt-project/androidqf/) and then analyze those acquisitions with MVT.
Running `mvt-android check-adb` will also emit a runtime deprecation warning advising you to migrate to AndroidQF.
If you have previously started an adb daemon MVT will alert you and require you to kill it with `adb kill-server` and relaunch the command. If you have previously started an adb daemon MVT will alert you and require you to kill it with `adb kill-server` and relaunch the command.
!!! warning !!! warning
MVT relies on the Python library [adb-shell](https://pypi.org/project/adb-shell/) to connect to an Android device, which relies on libusb for the USB transport. Because of known driver issues, Windows users [are recommended](https://github.com/JeffLIrion/adb_shell/issues/118) to install appropriate drivers using [Zadig](https://zadig.akeo.ie/). Alternatively, an easier option might be to use the TCP transport and connect over Wi-Fi as describe next.
The `mvt-android check-adb` command has been deprecated and removed from MVT. ## Connecting over Wi-FI
The ability to analyze Android devices over ADB (`mvt-android check-adb`) has been removed from MVT due to several technical and forensic limitations. When connecting to the device over USB is not possible or not working properly, an alternative option is to connect over the network. In order to do so, first launch an adb daemon at a fixed port number:
## Reasons for Deprecation ```bash
adb tcpip 5555
```
1. **Inconsistent Data Collection Across Devices** Then you can specify the IP address of the phone with the adb port number to MVT like so:
Android devices vary significantly in their system architecture, security policies, and available diagnostic logs. This inconsistency makes it difficult to ensure that MVT can reliably collect necessary forensic data across all devices.
2. **Incomplete Forensic Data Acquisition** ```bash
The `check-adb` command did not retrieve a full forensic snapshot of all available data on the device. For example, critical logs such as the **full bugreport** were not systematically collected, leading to potential gaps in forensic analysis. This can be a serious problem in scenarios where the analyst only had one time access to the Android device. mvt-android check-adb --serial 192.168.1.20:5555 --output /path/to/results
```
4. **Code Duplication and Difficulty Ensuring Consistent Behavior Across Sources** Where `192.168.1.20` is the correct IP address of your device.
Similar forensic data such as "dumpsys" logs were being loaded and parsed by MVT's ADB, AndroidQF and Bugreport commands. Multiple modules were needed to handle each source format which created duplication leading to inconsistent
behavior and difficulties in maintaining the code base.
5. **Alignment with iOS Workflow** ## MVT modules requiring root privileges
MVTs forensic workflow for iOS relies on pre-extracted artifacts, such as iTunes backups or filesystem dumps, rather than preforming commands or interactions directly on a live device. Removing the ADB functionality ensures a more consistent methodology across both Android and iOS mobile forensic.
## Alternative: Using AndroidQF for Forensic Data Collection Of the currently available `mvt-android check-adb` modules a handful require root privileges to function correctly. This is because certain files, such as browser history and SMS messages databases are not accessible with user privileges through adb. These modules are to be considered OPTIONALLY available in case the device was already jailbroken. **Do NOT jailbreak your own device unless you are sure of what you are doing!** Jailbreaking your phone exposes it to considerable security risks!
To replace the deprecated ADB-based approach, forensic analysts should use [AndroidQF](https://github.com/mvt-project/androidqf) for comprehensive data collection, followed by MVT for forensic analysis. The workflow is outlined in the MVT [Android methodology](./methodology.md)

View File

@@ -1,53 +1,23 @@
# Methodology for Android forensic # Methodology for Android forensic
Unfortunately Android devices provide fewer complete forensically useful datasources than their iOS cousins. Unlike iOS, the Android backup feature only provides a limited about of relevant data. Unfortunately Android devices provide much less observability than their iOS cousins. Android stores very little diagnostic information useful to triage potential compromises, and because of this `mvt-android` capabilities are limited as well.
Android diagnostic logs such as *bugreport files* can be inconsistent in format and structure across different Android versions and device vendors. The limited diagnostic information available makes it difficult to triage potential compromises, and because of this `mvt-android` capabilities are limited as well.
However, not all is lost. However, not all is lost.
## Check Android devices with AndroidQF and MVT ## Check installed Apps
The [AndroidQF](https://github.com/mvt-project/androidqf) tool can be used to collect a wide range of forensic artifacts from an Android device including an Android backup, a bugreport file, and a range of system logs. MVT natively supports analyzing the generated AndroidQF output for signs of device compromise. Because malware attacks over Android typically take the form of malicious or backdoored apps, the very first thing you might want to do is to extract and verify all installed Android packages and triage quickly if there are any which stand out as malicious or which might be atypical.
### Why Use AndroidQF? While it is out of the scope of this documentation to dwell into details on how to analyze Android apps, MVT does allow to easily and automatically extract information about installed apps, download copies of them, and quickly look them up on services such as [VirusTotal](https://www.virustotal.com).
- **Complete and raw data extraction** !!! info "Using VirusTotal"
AndroidQF collects full forensic artifacts using an on-device forensic collection agent, ensuring that no crucial data is overlooked. The data collection does not depended on the shell environment or utilities available on the device. Please note that in order to use VirusTotal lookups you are required to provide your own API key through the `MVT_VT_API_KEY` environment variable. You should also note that VirusTotal enforces strict API usage. Be mindful that MVT might consume your hourly search quota.
- **Consistent and standardized output**
By collecting a predefined and complete set of forensic files, AndroidQF ensures consistency in data acquisition across different Android devices.
- **Future-proof analysis**
Since the full forensic artifacts are preserved, analysts can extract new evidence or apply updated analysis techniques without requiring access to the original device.
- **Cross-platform tool without dependencies**
AndroidQF is a standalone Go binary which can be used to remotely collect data from an Android device without the device owner needing to install MVT or a Python environment.
### Workflow for Android Forensic Analysis with AndroidQF
With AndroidQF the analysis process is split into a separate data collection and data analysis stages.
1. **Extract Data Using AndroidQF**
Deploy the AndroidQF forensic collector to acquire all relevant forensic artifacts from the Android device.
2. **Analyze Extracted Data with MVT**
Use the `mvt-android check-androidqf` command to perform forensic analysis on the extracted artifacts.
By separating artifact collection from forensic analysis, this approach ensures a more reliable and scalable methodology for Android forensic investigations.
For more information, refer to the [AndroidQF project documentation](https://github.com/mvt-project/androidqf).
## Check the device over Android Debug Bridge ## Check the device over Android Debug Bridge
The ability to analyze Android devices over ADB (`mvt-android check-adb`) has been removed from MVT. Some additional diagnostic information can be extracted from the phone using the [Android Debug Bridge (adb)](https://developer.android.com/studio/command-line/adb). `mvt-android` allows to automatically extract information including [dumpsys](https://developer.android.com/studio/command-line/dumpsys) results, details on installed packages (without download), running processes, presence of root binaries and packages, and more.
See the [Android ADB documentation](./adb.md) for more information.
## Check an Android Backup (SMS messages) ## Check an Android Backup (SMS messages)
Although Android backups are becoming deprecated, it is still possible to generate one. Unfortunately, because apps these days typically favor backup over the cloud, the amount of data available is limited. Although Android backups are becoming deprecated, it is still possible to generate one. Unfortunately, because apps these days typically favor backup over the cloud, the amount of data available is limited. Currently, `mvt-android check-backup` only supports checking SMS messages containing links.
The `mvt-android check-androidqf` command will automatically check an Android backup and SMS messages if an SMS backup is included in the AndroidQF extraction.
The `mvt-android check-backup` command can also be used directly with an Android backup file.

View File

@@ -31,4 +31,21 @@ Test if the image was created successfully:
docker run -it mvt docker run -it mvt
``` ```
If a prompt is spawned successfully, you can close it with `exit`. If a prompt is spawned successfully, you can close it with `exit`.
## Docker usage with Android devices
If you wish to use MVT to test an Android device you will need to enable the container's access to the host's USB devices. You can do so by enabling the `--privileged` flag and mounting the USB bus device as a volume:
```bash
docker run -it --privileged -v /dev/bus/usb:/dev/bus/usb mvt
```
**Please note:** the `--privileged` parameter is generally regarded as a security risk. If you want to learn more about this check out [this explainer on container escapes](https://blog.trailofbits.com/2019/07/19/understanding-docker-container-escapes/) as it gives access to the whole system.
Recent versions of Docker provide a `--device` parameter allowing to specify a precise USB device without enabling `--privileged`:
```bash
docker run -it --device=/dev/<your_usb_port> mvt
```

View File

@@ -1,5 +1,5 @@
mkdocs==1.6.1 mkdocs==1.6.1
mkdocs-autorefs==1.4.3 mkdocs-autorefs==1.2.0
mkdocs-material==9.6.20 mkdocs-material==9.5.42
mkdocs-material-extensions==1.3.1 mkdocs-material-extensions==1.3.1
mkdocstrings==1.0.0 mkdocstrings==0.23.0

View File

@@ -1,11 +1,13 @@
[project] [project]
name = "mvt" name = "mvt"
dynamic = ["version"] dynamic = ["version"]
authors = [{ name = "Claudio Guarnieri", email = "nex@nex.sx" }] authors = [
{name = "Claudio Guarnieri", email = "nex@nex.sx"}
]
maintainers = [ maintainers = [
{ name = "Etienne Maynier", email = "tek@randhome.io" }, {name = "Etienne Maynier", email = "tek@randhome.io"},
{ name = "Donncha Ó Cearbhaill", email = "donncha.ocearbhaill@amnesty.org" }, {name = "Donncha Ó Cearbhaill", email = "donncha.ocearbhaill@amnesty.org"},
{ name = "Rory Flynn", email = "rory.flynn@amnesty.org" }, {name = "Rory Flynn", email = "rory.flynn@amnesty.org"}
] ]
description = "Mobile Verification Toolkit" description = "Mobile Verification Toolkit"
readme = "README.md" readme = "README.md"
@@ -14,61 +16,48 @@ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Information Technology", "Intended Audience :: Information Technology",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python", "Programming Language :: Python"
] ]
dependencies = [ dependencies = [
"click==8.3.1", "click >=8.1.3",
"rich==14.1.0", "rich >=12.6.0",
"tld==0.13.1", "tld >=0.12.6",
"requests==2.32.5", "requests >=2.28.1",
"simplejson==3.20.2", "simplejson >=3.17.6",
"packaging==25.0", "packaging >=21.3",
"appdirs==1.4.4", "appdirs >=1.4.4",
"iOSbackup==0.9.925", "iOSbackup >=0.9.923",
"adb-shell[usb]==0.4.4", "adb-shell[usb] >=0.4.3",
"libusb1==3.3.1", "libusb1 >=3.0.0",
"cryptography==46.0.5", "cryptography >=42.0.5",
"PyYAML>=6.0.2", "pyyaml >=6.0",
"pyahocorasick==2.2.0", "pyahocorasick >= 2.0.0",
"betterproto==1.2.5", "betterproto >=1.2.0",
"pydantic==2.12.5", "pydantic >= 2.10.0",
"pydantic-settings==2.10.1", "pydantic-settings >= 2.7.0",
"NSKeyedUnArchiver==1.5.2", 'backports.zoneinfo; python_version < "3.9"',
"python-dateutil==2.9.0.post0",
"tzdata==2025.2",
] ]
requires-python = ">= 3.10" requires-python = ">= 3.8"
[project.urls] [project.urls]
homepage = "https://docs.mvt.re/en/latest/" homepage = "https://docs.mvt.re/en/latest/"
repository = "https://github.com/mvt-project/mvt" repository = "https://github.com/mvt-project/mvt"
[project.scripts] [project.scripts]
mvt-ios = "mvt.ios:cli" mvt-ios = "mvt.ios:cli"
mvt-android = "mvt.android:cli" mvt-android = "mvt.android:cli"
[dependency-groups]
dev = [
"requests>=2.31.0",
"pytest>=7.4.3",
"pytest-cov>=4.1.0",
"pytest-github-actions-annotate-failures>=0.2.0",
"pytest-mock>=3.14.0",
"stix2>=3.0.1",
"ruff>=0.1.6",
"mypy>=1.7.1",
"betterproto[compiler]",
]
[build-system] [build-system]
requires = ["setuptools>=61.0"] requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.coverage.run] [tool.coverage.run]
omit = ["tests/*"] omit = [
"tests/*",
]
[tool.coverage.html] [tool.coverage.html]
directory = "htmlcov" directory= "htmlcov"
[tool.mypy] [tool.mypy]
install_types = true install_types = true
@@ -78,13 +67,15 @@ packages = "src"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-ra -q --cov=mvt --cov-report html --junitxml=pytest.xml --cov-report=term-missing:skip-covered" addopts = "-ra -q --cov=mvt --cov-report html --junitxml=pytest.xml --cov-report=term-missing:skip-covered"
testpaths = ["tests"] testpaths = [
"tests"
]
[tool.ruff] [tool.ruff.lint]
lint.select = ["C90", "E", "F", "W"] # flake8 default set select = ["C90", "E", "F", "W"] # flake8 default set
lint.ignore = [ ignore = [
"E501", # don't enforce line length violations "E501", # don't enforce line length violations
"C901", # complex-structure "C901", # complex-structure
# These were previously ignored but don't seem to be required: # These were previously ignored but don't seem to be required:
# "E265", # no-space-after-block-comment # "E265", # no-space-after-block-comment
@@ -96,14 +87,14 @@ lint.ignore = [
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"] # unused-import "__init__.py" = ["F401"] # unused-import
[tool.ruff.lint.mccabe] [tool.ruff.lint.mccabe]
max-complexity = 10 max-complexity = 10
[tool.setuptools] [tool.setuptools]
include-package-data = true include-package-data = true
package-dir = { "" = "src" } package-dir = {"" = "src"}
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
@@ -112,4 +103,4 @@ where = ["src"]
mvt = ["ios/data/*.json"] mvt = ["ios/data/*.json"]
[tool.setuptools.dynamic] [tool.setuptools.dynamic]
version = { attr = "mvt.common.version.MVT_VERSION" } version = {attr = "mvt.common.version.MVT_VERSION"}

View File

@@ -20,39 +20,23 @@ class AndroidArtifact(Artifact):
:param binary: whether the dumpsys should be pared as binary or not (bool) :param binary: whether the dumpsys should be pared as binary or not (bool)
:return: section extracted (string or bytes) :return: section extracted (string or bytes)
""" """
lines = []
in_section = False in_section = False
delimiter_str = "------------------------------------------------------------------------------" delimiter = "------------------------------------------------------------------------------"
delimiter_bytes = b"------------------------------------------------------------------------------"
if binary: if binary:
lines_bytes = [] delimiter = delimiter.encode("utf-8")
for line in dumpsys.splitlines(): # type: ignore[union-attr]
if line.strip() == separator: # type: ignore[arg-type]
in_section = True
continue
if not in_section: for line in dumpsys.splitlines():
continue if line.strip() == separator:
in_section = True
continue
if line.strip().startswith(delimiter_bytes): # type: ignore[arg-type] if not in_section:
break continue
lines_bytes.append(line) # type: ignore[arg-type] if line.strip().startswith(delimiter):
break
return b"\n".join(lines_bytes) # type: ignore[return-value,arg-type] lines.append(line)
else:
lines_str = []
for line in dumpsys.splitlines(): # type: ignore[union-attr]
if line.strip() == separator: # type: ignore[arg-type]
in_section = True
continue
if not in_section: return b"\n".join(lines) if binary else "\n".join(lines)
continue
if line.strip().startswith(delimiter_str): # type: ignore[arg-type]
break
lines_str.append(line) # type: ignore[arg-type]
return "\n".join(lines_str) # type: ignore[return-value,arg-type]

View File

@@ -14,11 +14,10 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
return return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc = self.indicators.check_app_id(result["package_name"])
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
continue continue
def parse(self, content: str) -> None: def parse(self, content: str) -> None:

View File

@@ -4,14 +4,13 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import base64 import base64
import binascii
import hashlib import hashlib
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
class DumpsysADBArtifact(AndroidArtifact): class DumpsysADBArtifact(AndroidArtifact):
multiline_fields = ["user_keys", "keystore"] multiline_fields = ["user_keys"]
def indented_dump_parser(self, dump_data): def indented_dump_parser(self, dump_data):
""" """
@@ -68,38 +67,14 @@ class DumpsysADBArtifact(AndroidArtifact):
return res return res
def parse_xml(self, xml_data):
"""
Parse XML data from dumpsys ADB output
"""
import xml.etree.ElementTree as ET
keystore = []
keystore_root = ET.fromstring(xml_data)
for adb_key in keystore_root.findall("adbKey"):
key_info = self.calculate_key_info(adb_key.get("key").encode("utf-8"))
key_info["last_connected"] = adb_key.get("lastConnection")
keystore.append(key_info)
return keystore
@staticmethod @staticmethod
def calculate_key_info(user_key: bytes) -> dict: def calculate_key_info(user_key: bytes) -> str:
if b" " in user_key: key_base64, user = user_key.split(b" ", 1)
key_base64, user = user_key.split(b" ", 1) key_raw = base64.b64decode(key_base64)
else: key_fingerprint = hashlib.md5(key_raw).hexdigest().upper()
key_base64, user = user_key, b"" key_fingerprint_colon = ":".join(
[key_fingerprint[i : i + 2] for i in range(0, len(key_fingerprint), 2)]
try: )
key_raw = base64.b64decode(key_base64)
key_fingerprint = hashlib.md5(key_raw).hexdigest().upper()
key_fingerprint_colon = ":".join(
[key_fingerprint[i : i + 2] for i in range(0, len(key_fingerprint), 2)]
)
except binascii.Error:
# Impossible to parse base64
key_fingerprint_colon = ""
return { return {
"user": user.decode("utf-8"), "user": user.decode("utf-8"),
"fingerprint": key_fingerprint_colon, "fingerprint": key_fingerprint_colon,
@@ -140,24 +115,8 @@ class DumpsysADBArtifact(AndroidArtifact):
if parsed.get("debugging_manager") is None: if parsed.get("debugging_manager") is None:
self.log.error("Unable to find expected ADB entries in dumpsys output") # noqa self.log.error("Unable to find expected ADB entries in dumpsys output") # noqa
return return
# Keystore can be in different levels, as the basic parser
# is not always consistent due to different dumpsys formats.
if parsed.get("keystore"):
keystore_data = b"\n".join(parsed["keystore"])
elif parsed["debugging_manager"].get("keystore"):
keystore_data = b"\n".join(parsed["debugging_manager"]["keystore"])
else: else:
keystore_data = None parsed = parsed["debugging_manager"]
# Keystore is in XML format on some devices and we need to parse it
if keystore_data and keystore_data.startswith(b"<?xml"):
parsed["debugging_manager"]["keystore"] = self.parse_xml(keystore_data)
else:
# Keystore is not XML format
parsed["debugging_manager"]["keystore"] = keystore_data
parsed = parsed["debugging_manager"]
# Calculate key fingerprints for better readability # Calculate key fingerprints for better readability
key_info = [] key_info = []

View File

@@ -4,13 +4,13 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, Dict, List, Union
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
RISKY_PERMISSIONS = ["REQUEST_INSTALL_PACKAGES"] RISKY_PERMISSIONS = ["REQUEST_INSTALL_PACKAGES"]
RISKY_PACKAGES = ["com.android.shell"] RISKY_PACKAGES = ["com.android.shell"]
@@ -20,9 +20,9 @@ class DumpsysAppopsArtifact(AndroidArtifact):
Parser for dumpsys app ops info Parser for dumpsys app ops info
""" """
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
records = [] records = []
for perm in result["permissions"]: for perm in record["permissions"]:
if "entries" not in perm: if "entries" not in perm:
continue continue
@@ -33,7 +33,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
"timestamp": entry["timestamp"], "timestamp": entry["timestamp"],
"module": self.__class__.__name__, "module": self.__class__.__name__,
"event": entry["access"], "event": entry["access"],
"data": f"{result['package_name']} access to " "data": f"{record['package_name']} access to "
f"{perm['name']}: {entry['access']}", f"{perm['name']}: {entry['access']}",
} }
) )
@@ -43,51 +43,51 @@ class DumpsysAppopsArtifact(AndroidArtifact):
def check_indicators(self) -> None: def check_indicators(self) -> None:
for result in self.results: for result in self.results:
if self.indicators: if self.indicators:
ioc_match = self.indicators.check_app_id(result.get("package_name")) ioc = self.indicators.check_app_id(result.get("package_name"))
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
continue continue
# We use a placeholder entry to create a basic alert even without permission entries. detected_permissions = []
placeholder_entry = {"access": "Unknown", "timestamp": ""}
for perm in result["permissions"]: for perm in result["permissions"]:
if ( if (
perm["name"] in RISKY_PERMISSIONS perm["name"] in RISKY_PERMISSIONS
# and perm["access"] == "allow" # and perm["access"] == "allow"
): ):
for entry in sorted( detected_permissions.append(perm)
perm["entries"] or [placeholder_entry], for entry in sorted(perm["entries"], key=lambda x: x["timestamp"]):
key=lambda x: x["timestamp"], self.log.warning(
): "Package '%s' had risky permission '%s' set to '%s' at %s",
cleaned_result = result.copy() result["package_name"],
cleaned_result["permissions"] = [perm] perm["name"],
self.alertstore.medium( entry["access"],
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
entry["timestamp"], entry["timestamp"],
cleaned_result,
) )
elif result["package_name"] in RISKY_PACKAGES: elif result["package_name"] in RISKY_PACKAGES:
for entry in sorted( detected_permissions.append(perm)
perm["entries"] or [placeholder_entry], for entry in sorted(perm["entries"], key=lambda x: x["timestamp"]):
key=lambda x: x["timestamp"], self.log.warning(
): "Risky package '%s' had '%s' permission set to '%s' at %s",
cleaned_result = result.copy() result["package_name"],
cleaned_result["permissions"] = [perm] perm["name"],
self.alertstore.medium( entry["access"],
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
entry["timestamp"], entry["timestamp"],
cleaned_result,
) )
if detected_permissions:
# We clean the result to only include the risky permission, otherwise the timeline
# will be polluted with all the other irrelevant permissions
cleaned_result = result.copy()
cleaned_result["permissions"] = detected_permissions
self.detected.append(cleaned_result)
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
# self.results: List[Dict[str, Any]] = [] self.results: List[Dict[str, Any]] = []
perm: dict[str, Any] = {} perm = {}
package: dict[str, Any] = {} package = {}
entry: dict[str, Any] = {} entry = {}
uid = None uid = None
in_packages = False in_packages = False

View File

@@ -3,9 +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/
from typing import Any from typing import Union
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
@@ -15,24 +13,13 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
Parser for dumpsys dattery daily updates. Parser for dumpsys dattery daily updates.
""" """
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
action = record.get("action", "update")
package_name = record["package_name"]
vers = record["vers"]
if vers == "0":
data = f"Recorded uninstall of package {package_name} (vers 0)"
elif action == "downgrade":
prev_vers = record.get("previous_vers", "unknown")
data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}"
else:
data = f"Recorded update of package {package_name} with vers {vers}"
return { return {
"timestamp": record["from"], "timestamp": record["from"],
"module": self.__class__.__name__, "module": self.__class__.__name__,
"event": "battery_daily", "event": "battery_daily",
"data": data, "data": f"Recorded update of package {record['package_name']} "
f"with vers {record['vers']}",
} }
def check_indicators(self) -> None: def check_indicators(self) -> None:
@@ -40,19 +27,15 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
return return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc = self.indicators.check_app_id(result["package_name"])
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
continue continue
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
daily = None daily = None
daily_updates: list[dict[str, Any]] = [] daily_updates = []
package_versions: dict[
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:
@@ -81,44 +64,15 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
break break
if not already_seen: if not already_seen:
update_record: dict[str, Any] = { daily_updates.append(
"action": "update", {
"from": daily["from"], "action": "update",
"to": daily["to"], "from": daily["from"],
"package_name": package_name, "to": daily["to"],
"vers": vers_nr, "package_name": package_name,
} "vers": vers_nr,
}
# Check for uninstall (version 0) )
if vers_nr == "0":
self.log.warning(
"Detected uninstall of package %s (vers 0) on %s",
package_name,
daily["from"],
)
# 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.log.warning(
"Detected downgrade of package %s from vers %d to vers %d on %s",
package_name,
previous_vers,
current_vers,
daily["from"],
)
except ValueError:
# If version numbers aren't integers, skip comparison
pass
# Update tracking dictionary
package_versions[package_name] = vers_nr
daily_updates.append(update_record)
if len(daily_updates) > 0: if len(daily_updates) > 0:
self.results.extend(daily_updates) self.results.extend(daily_updates)

View File

@@ -16,11 +16,10 @@ class DumpsysBatteryHistoryArtifact(AndroidArtifact):
return return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc = self.indicators.check_app_id(result["package_name"])
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
continue continue
def parse(self, data: str) -> None: def parse(self, data: str) -> None:

View File

@@ -20,11 +20,10 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
for result in self.results: for result in self.results:
path = result.get("path", "") path = result.get("path", "")
for part in path.split("/"): for part in path.split("/"):
ioc_match = self.indicators.check_app_id(part) ioc = self.indicators.check_app_id(part)
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
continue continue
def parse(self, output: str) -> None: def parse(self, output: str) -> None:

View File

@@ -12,11 +12,10 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
return return
for activity in self.results: for activity in self.results:
ioc_match = self.indicators.check_app_id(activity["package_name"]) ioc = self.indicators.check_app_id(activity["package_name"])
if ioc_match: if ioc:
self.alertstore.critical( activity["matched_indicator"] = ioc
ioc_match.message, "", activity, matched_indicator=ioc_match.ioc self.detected.append(activity)
)
continue continue
def parse(self, content: str): def parse(self, content: str):

View File

@@ -4,10 +4,9 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import re import re
from typing import Any, Dict, List from typing import Any, Dict, List, Union
from mvt.android.utils import ROOT_PACKAGES from mvt.android.utils import ROOT_PACKAGES
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
@@ -15,28 +14,25 @@ from .artifact import AndroidArtifact
class DumpsysPackagesArtifact(AndroidArtifact): class DumpsysPackagesArtifact(AndroidArtifact):
def check_indicators(self) -> None: def check_indicators(self) -> None:
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:
self.alertstore.medium( self.log.warning(
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"', 'Found an installed package related to rooting/jailbreaking: "%s"',
"", result["package_name"],
result,
) )
self.alertstore.log_latest() self.detected.append(result)
continue continue
if not self.indicators: if not self.indicators:
continue continue
ioc_match = self.indicators.check_app_id(result.get("package_name", "")) ioc = self.indicators.check_app_id(result.get("package_name", ""))
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
self.alertstore.log_latest()
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
records = [] records = []
timestamps = [ timestamps = [
{"event": "package_install", "timestamp": record["timestamp"]}, {"event": "package_install", "timestamp": record["timestamp"]},
{ {
@@ -63,15 +59,15 @@ class DumpsysPackagesArtifact(AndroidArtifact):
""" """
Parse one entry of a dumpsys package information Parse one entry of a dumpsys package information
""" """
details: Dict[str, Any] = { details = {
"uid": "", "uid": "",
"version_name": "", "version_name": "",
"version_code": "", "version_code": "",
"timestamp": "", "timestamp": "",
"first_install_time": "", "first_install_time": "",
"last_update_time": "", "last_update_time": "",
"permissions": list(), "permissions": [],
"requested_permissions": list(), "requested_permissions": [],
} }
in_install_permissions = False in_install_permissions = False
in_runtime_permissions = False in_runtime_permissions = False
@@ -149,7 +145,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
results = [] results = []
package_name = None package_name = None
package = {} package = {}
lines: list[str] = [] lines = []
for line in output.splitlines(): for line in output.splitlines():
if line.startswith(" Package ["): if line.startswith(" Package ["):
if len(lines) > 0: if len(lines) > 0:

View File

@@ -16,11 +16,10 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
return return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc = self.indicators.check_app_id(result["package_name"])
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
continue continue
def parse(self, data: str) -> None: def parse(self, data: str) -> None:

View File

@@ -50,18 +50,14 @@ class DumpsysReceiversArtifact(AndroidArtifact):
if not self.indicators: if not self.indicators:
continue continue
ioc_match = self.indicators.check_app_id(receiver["package_name"]) ioc = self.indicators.check_app_id(receiver["package_name"])
if ioc_match: if ioc:
self.alertstore.critical( receiver["matched_indicator"] = ioc
ioc_match.message, self.detected.append({intent: receiver})
"",
{intent: receiver},
matched_indicator=ioc_match.ioc,
)
continue continue
def parse(self, output: str) -> None: def parse(self, output: str) -> None:
self.results: dict[str, list[dict[str, str]]] = {} self.results = {}
in_receiver_resolver_table = False in_receiver_resolver_table = False
in_non_data_actions = False in_non_data_actions = False

View File

@@ -2,13 +2,13 @@
# Copyright (c) 2021-2023 The MVT Authors. # Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from typing import Union
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
class FileTimestampsArtifact(AndroidArtifact): class FileTimestampsArtifact(AndroidArtifact):
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
records = [] records = []
for ts in set( for ts in set(

View File

@@ -39,10 +39,10 @@ class GetProp(AndroidArtifact):
if not matches or len(matches[0]) != 2: if not matches or len(matches[0]) != 2:
continue continue
prop_entry = {"name": matches[0][0], "value": matches[0][1]} entry = {"name": matches[0][0], "value": matches[0][1]}
self.results.append(prop_entry) self.results.append(entry)
def get_device_timezone(self) -> str | None: def get_device_timezone(self) -> str:
""" """
Get the device timezone from the getprop results Get the device timezone from the getprop results
@@ -59,18 +59,13 @@ class GetProp(AndroidArtifact):
self.log.info("%s: %s", entry["name"], entry["value"]) self.log.info("%s: %s", entry["name"], entry["value"])
if entry["name"] == "ro.build.version.security_patch": if entry["name"] == "ro.build.version.security_patch":
warning_message = warn_android_patch_level(entry["value"], self.log) warn_android_patch_level(entry["value"], self.log)
if isinstance(warning_message, str):
self.alertstore.medium(warning_message, "", entry)
if not self.indicators: if not self.indicators:
return return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_android_property_name( ioc = self.indicators.check_android_property_name(result.get("name", ""))
result.get("name", "") if ioc:
) result["matched_indicator"] = ioc
if ioc_match: self.detected.append(result)
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)

View File

@@ -1,197 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from typing import Any
from .artifact import AndroidArtifact
SUSPICIOUS_MOUNT_POINTS = [
"/system",
"/vendor",
"/product",
"/system_ext",
]
SUSPICIOUS_OPTIONS = [
"rw",
"remount",
"noatime",
"nodiratime",
]
ALLOWLIST_NOATIME = [
"/system_dlkm",
"/system_ext",
"/product",
"/vendor",
"/vendor_dlkm",
]
class Mounts(AndroidArtifact):
"""
This artifact parses mount information from /proc/mounts or similar mount data.
It can detect potentially suspicious mount configurations that may indicate
a rooted or compromised device.
"""
def parse(self, entry: str) -> None:
"""
Parse mount information from the provided entry.
Examples:
/dev/block/bootdevice/by-name/system /system ext4 ro,seclabel,relatime 0 0
/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)
"""
self.results: list[dict[str, Any]] = []
for line in entry.splitlines():
line = line.strip()
if not line:
continue
device = None
mount_point = None
filesystem_type = None
mount_options = ""
if " on " in line and " type " in line:
try:
# Format: device on mount_point type filesystem_type (options)
device_part, rest = line.split(" on ", 1)
device = device_part.strip()
# Split by 'type' to get mount_point and filesystem info
mount_part, fs_part = rest.split(" type ", 1)
mount_point = mount_part.strip()
# Parse filesystem and options
if "(" in fs_part and fs_part.endswith(")"):
# Format: filesystem_type (options)
fs_and_opts = fs_part.strip()
paren_idx = fs_and_opts.find("(")
filesystem_type = fs_and_opts[:paren_idx].strip()
mount_options = fs_and_opts[paren_idx + 1 : -1].strip()
else:
# No options in parentheses, just filesystem type
filesystem_type = fs_part.strip()
mount_options = ""
# Skip if we don't have essential info
if not device or not mount_point or not filesystem_type:
continue
# Parse options into list
options_list = (
[opt.strip() for opt in mount_options.split(",") if opt.strip()]
if mount_options
else []
)
# Check if it's a system partition
is_system_partition = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
mount_point.startswith(sp) for sp in SUSPICIOUS_MOUNT_POINTS
)
# Check if it's mounted read-write
is_read_write = "rw" in options_list
mount_entry = {
"device": device,
"mount_point": mount_point,
"filesystem_type": filesystem_type,
"mount_options": mount_options,
"options_list": options_list,
"is_system_partition": is_system_partition,
"is_read_write": is_read_write,
}
self.results.append(mount_entry)
except ValueError:
# If parsing fails, skip this line
continue
else:
# Skip lines that don't match expected format
continue
def check_indicators(self) -> None:
"""
Check for suspicious mount configurations that may indicate root access
or other security concerns.
"""
system_rw_mounts = []
suspicious_mounts = []
for mount in self.results:
mount_point = mount["mount_point"]
options = mount["options_list"]
# Check for system partitions mounted as read-write
if mount["is_system_partition"] and mount["is_read_write"]:
system_rw_mounts.append(mount)
if mount_point == "/system":
self.alertstore.high(
"Root detected /system partition is mounted as read-write (rw)",
"",
mount,
)
else:
self.alertstore.high(
f"System partition {mount_point} is mounted as read-write (rw). This may indicate system modifications.",
"",
mount,
)
# Check for other suspicious mount options
suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS]
if suspicious_opts and mount["is_system_partition"]:
if (
"noatime" in mount["mount_options"]
and mount["mount_point"] in ALLOWLIST_NOATIME
):
continue
suspicious_mounts.append(mount)
self.alertstore.high(
f"Suspicious mount options found for {mount_point}: {', '.join(suspicious_opts)}",
"",
mount,
)
# Log interesting mount information
if mount_point == "/data" or mount_point.startswith("/sdcard"):
self.log.info(
"Data partition: %s mounted as %s with options: %s",
mount_point,
mount["filesystem_type"],
mount["mount_options"],
)
self.log.info("Parsed %d mount entries", len(self.results))
# Check indicators if available
if not self.indicators:
return
for mount in self.results:
# Check if any mount points match indicators
ioc = self.indicators.check_file_path(mount.get("mount_point", ""))
if ioc:
self.alertstore.critical(
f"Mount point matches indicator: {mount.get('mount_point', '')}",
"",
mount,
matched_indicator=ioc,
)
# Check device paths for indicators
ioc = self.indicators.check_file_path(mount.get("device", ""))
if ioc:
self.alertstore.critical(
f"Device path matches indicator: {mount.get('device', '')}",
"",
mount,
matched_indicator=ioc,
)

View File

@@ -58,15 +58,13 @@ class Processes(AndroidArtifact):
if result["proc_name"] == "gatekeeperd": if result["proc_name"] == "gatekeeperd":
continue continue
ioc_match = self.indicators.check_app_id(proc_name) ioc = self.indicators.check_app_id(proc_name)
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
continue continue
ioc_match = self.indicators.check_process(proc_name) ioc = self.indicators.check_process(proc_name)
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)

View File

@@ -51,6 +51,11 @@ ANDROID_DANGEROUS_SETTINGS = [
"key": "send_action_app_error", "key": "send_action_app_error",
"safe_value": "1", "safe_value": "1",
}, },
{
"description": "enabled installation of non Google Play apps",
"key": "install_non_market_apps",
"safe_value": "0",
},
{ {
"description": "enabled accessibility services", "description": "enabled accessibility services",
"key": "accessibility_enabled", "key": "accessibility_enabled",

View File

@@ -4,18 +4,16 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import datetime import datetime
from typing import List, Optional from typing import List, Optional, Union
import betterproto
import pydantic import pydantic
from dateutil import parser import betterproto
from mvt.android.parsers.proto.tombstone import Tombstone
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
from mvt.android.parsers.proto.tombstone import Tombstone
from .artifact import AndroidArtifact from .artifact import AndroidArtifact
TOMBSTONE_DELIMITER = "*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***" TOMBSTONE_DELIMITER = "*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***"
# Map the legacy crash file keys to the new format. # Map the legacy crash file keys to the new format.
@@ -54,7 +52,7 @@ class TombstoneCrashResult(pydantic.BaseModel):
file_name: str file_name: str
file_timestamp: str # We store the timestamp as a string to avoid timezone issues file_timestamp: str # We store the timestamp as a string to avoid timezone issues
build_fingerprint: str build_fingerprint: str
revision: str revision: int
arch: Optional[str] = None arch: Optional[str] = None
timestamp: str # We store the timestamp as a string to avoid timezone issues timestamp: str # We store the timestamp as a string to avoid timezone issues
process_uptime: Optional[int] = None process_uptime: Optional[int] = None
@@ -64,20 +62,20 @@ class TombstoneCrashResult(pydantic.BaseModel):
process_name: Optional[str] = None process_name: Optional[str] = None
binary_path: Optional[str] = None binary_path: Optional[str] = None
selinux_label: Optional[str] = None selinux_label: Optional[str] = None
uid: int uid: Optional[int] = None
signal_info: SignalInfo signal_info: SignalInfo
cause: Optional[str] = None cause: Optional[str] = None
extra: Optional[str] = None extra: Optional[str] = None
class TombstoneCrashArtifact(AndroidArtifact): class TombstoneCrashArtifact(AndroidArtifact):
""" """ "
Parser for Android tombstone crash files. Parser for Android tombstone crash files.
This parser can parse both text and protobuf tombstone crash files. This parser can parse both text and protobuf tombstone crash files.
""" """
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
return { return {
"timestamp": record["timestamp"], "timestamp": record["timestamp"],
"module": self.__class__.__name__, "module": self.__class__.__name__,
@@ -93,21 +91,18 @@ class TombstoneCrashArtifact(AndroidArtifact):
return return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_process(result["process_name"]) ioc = self.indicators.check_process(result["process_name"])
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
continue continue
if result.get("command_line", []): if result.get("command_line", []):
command_name = result.get("command_line")[0].split("/")[-1] command_name = result.get("command_line")[0].split("/")[-1]
command_name = result["command_line"][0] ioc = self.indicators.check_process(command_name)
ioc_match = self.indicators.check_process(command_name) if ioc:
if ioc_match: result["matched_indicator"] = ioc
self.alertstore.critical( self.detected.append(result)
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
continue continue
SUSPICIOUS_UIDS = [ SUSPICIOUS_UIDS = [
@@ -116,23 +111,20 @@ class TombstoneCrashArtifact(AndroidArtifact):
2000, # shell 2000, # shell
] ]
if result["uid"] in SUSPICIOUS_UIDS: if result["uid"] in SUSPICIOUS_UIDS:
self.alertstore.medium( self.log.warning(
( f"Potentially suspicious crash in process '{result['process_name']}' "
f"Potentially suspicious crash in process '{result['process_name']}' " f"running as UID '{result['uid']}' in tombstone '{result['file_name']}' at {result['timestamp']}"
f"running as UID '{result['uid']}' in tombstone '{result['file_name']}' at {result['timestamp']}"
),
"",
result,
) )
self.detected.append(result)
def parse_protobuf( def parse_protobuf(
self, file_name: str, file_timestamp: datetime.datetime, data: bytes self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> None: ) -> None:
"""Parse Android tombstone crash files from a protobuf object.""" """
Parse Android tombstone crash files from a protobuf object.
"""
tombstone_pb = Tombstone().parse(data) tombstone_pb = Tombstone().parse(data)
tombstone_dict = tombstone_pb.to_dict( tombstone_dict = tombstone_pb.to_dict(betterproto.Casing.SNAKE)
betterproto.Casing.SNAKE, include_default_values=True
)
# Add some extra metadata # Add some extra metadata
tombstone_dict["timestamp"] = self._parse_timestamp_string( tombstone_dict["timestamp"] = self._parse_timestamp_string(
@@ -149,23 +141,21 @@ class TombstoneCrashArtifact(AndroidArtifact):
def parse( def parse(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> None: ) -> None:
"""Parse text Android tombstone crash files.""" """
Parse text Android tombstone crash files.
"""
# Split the tombstone file into a dictonary
tombstone_dict = { tombstone_dict = {
"file_name": file_name, "file_name": file_name,
"file_timestamp": convert_datetime_to_iso(file_timestamp), "file_timestamp": convert_datetime_to_iso(file_timestamp),
} }
lines = content.decode("utf-8").splitlines() lines = content.decode("utf-8").splitlines()
for line_num, line in enumerate(lines, 1): for line in lines:
if not line.strip() or TOMBSTONE_DELIMITER in line: if not line.strip() or TOMBSTONE_DELIMITER in line:
continue continue
try: for key, destination_key in TOMBSTONE_TEXT_KEY_MAPPINGS.items():
for key, destination_key in TOMBSTONE_TEXT_KEY_MAPPINGS.items(): self._parse_tombstone_line(line, key, destination_key, tombstone_dict)
if self._parse_tombstone_line(
line, key, destination_key, tombstone_dict
):
break
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {str(e)}")
# Validate the tombstone and add it to the results # Validate the tombstone and add it to the results
tombstone = TombstoneCrashResult.model_validate(tombstone_dict) tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
@@ -175,7 +165,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
self, line: str, key: str, destination_key: str, tombstone: dict self, line: str, key: str, destination_key: str, tombstone: dict
) -> bool: ) -> bool:
if not line.startswith(f"{key}"): if not line.startswith(f"{key}"):
return False return None
if key == "pid": if key == "pid":
return self._load_pid_line(line, tombstone) return self._load_pid_line(line, tombstone)
@@ -194,7 +184,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
raise ValueError(f"Expected key {key}, got {line_key}") raise ValueError(f"Expected key {key}, got {line_key}")
value_clean = value.strip().strip("'") value_clean = value.strip().strip("'")
if destination_key == "uid": if destination_key in ["uid", "revision"]:
tombstone[destination_key] = int(value_clean) tombstone[destination_key] = int(value_clean)
elif destination_key == "process_uptime": elif destination_key == "process_uptime":
# eg. "Process uptime: 40s" # eg. "Process uptime: 40s"
@@ -207,50 +197,51 @@ class TombstoneCrashArtifact(AndroidArtifact):
return True return True
def _load_pid_line(self, line: str, tombstone: dict) -> bool: def _load_pid_line(self, line: str, tombstone: dict) -> bool:
try: pid_part, tid_part, name_part = [part.strip() for part in line.split(",")]
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
process_info = parts[0]
# Parse pid, tid, name from process info pid_key, pid_value = pid_part.split(":", 1)
info_parts = [p.strip() for p in process_info.split(",")] if pid_key != "pid":
for info in info_parts: raise ValueError(f"Expected key pid, got {pid_key}")
key, value = info.split(":", 1) pid_value = int(pid_value.strip())
key = key.strip()
value = value.strip()
if key == "pid": tid_key, tid_value = tid_part.split(":", 1)
tombstone["pid"] = int(value) if tid_key != "tid":
elif key == "tid": raise ValueError(f"Expected key tid, got {tid_key}")
tombstone["tid"] = int(value) tid_value = int(tid_value.strip())
elif key == "name":
tombstone["process_name"] = value
# Extract binary path if it exists name_key, name_value = name_part.split(":", 1)
if len(parts) > 1: if name_key != "name":
tombstone["binary_path"] = parts[1].strip().rstrip(" <") raise ValueError(f"Expected key name, got {name_key}")
name_value = name_value.strip()
process_name, binary_path = self._parse_process_name(name_value, tombstone)
return True tombstone["pid"] = pid_value
tombstone["tid"] = tid_value
tombstone["process_name"] = process_name
tombstone["binary_path"] = binary_path
return True
except Exception as e: def _parse_process_name(self, process_name_part, tombstone: dict) -> bool:
raise ValueError(f"Failed to parse PID line: {str(e)}") process_name, process_path = process_name_part.split(">>>")
process_name = process_name.strip()
binary_path = process_path.strip().split(" ")[0]
return process_name, binary_path
def _load_signal_line(self, line: str, tombstone: dict) -> bool: def _load_signal_line(self, line: str, tombstone: dict) -> bool:
signal_part, code_part = map(str.strip, line.split(",")[:2]) signal, code, _ = [part.strip() for part in line.split(",", 2)]
signal = signal.split("signal ")[1]
signal_code, signal_name = signal.split(" ")
signal_name = signal_name.strip("()")
def parse_part(part: str, prefix: str) -> tuple[int, str]: code_part = code.split("code ")[1]
match = part.split(prefix)[1] code_number, code_name = code_part.split(" ")
number = int(match.split()[0]) code_name = code_name.strip("()")
name = match.split("(")[1].split(")")[0] if "(" in match else "UNKNOWN"
return number, name
signal_number, signal_name = parse_part(signal_part, "signal ")
code_number, code_name = parse_part(code_part, "code ")
tombstone["signal_info"] = { tombstone["signal_info"] = {
"code": code_number, "code": int(code_number),
"code_name": code_name, "code_name": code_name,
"name": signal_name, "name": signal_name,
"number": signal_number, "number": int(signal_code),
} }
return True return True
@@ -261,7 +252,13 @@ class TombstoneCrashArtifact(AndroidArtifact):
@staticmethod @staticmethod
def _parse_timestamp_string(timestamp: str) -> str: def _parse_timestamp_string(timestamp: str) -> str:
timestamp_parsed = parser.parse(timestamp) timestamp_date, timezone = timestamp.split("+")
# Truncate microseconds before parsing
timestamp_without_micro = timestamp_date.split(".")[0] + "+" + timezone
timestamp_parsed = datetime.datetime.strptime(
timestamp_without_micro, "%Y-%m-%d %H:%M:%S%z"
)
# HACK: Swap the local timestamp to UTC, so keep the original time and avoid timezone conversion. # HACK: Swap the local timestamp to UTC, so keep the original time and avoid timezone conversion.
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc) local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
return convert_datetime_to_iso(local_timestamp) return convert_datetime_to_iso(local_timestamp)

View File

@@ -9,33 +9,40 @@ import click
from mvt.common.cmd_check_iocs import CmdCheckIOCS from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.help import ( from mvt.common.help import (
HELP_MSG_ANDROID_BACKUP_PASSWORD, HELP_MSG_VERSION,
HELP_MSG_CHECK_ADB_REMOVED, HELP_MSG_OUTPUT,
HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION, HELP_MSG_SERIAL,
HELP_MSG_CHECK_ANDROID_BACKUP, HELP_MSG_DOWNLOAD_APKS,
HELP_MSG_CHECK_ANDROIDQF, HELP_MSG_DOWNLOAD_ALL_APKS,
HELP_MSG_CHECK_BUGREPORT, HELP_MSG_VIRUS_TOTAL,
HELP_MSG_CHECK_IOCS, HELP_MSG_APK_OUTPUT,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK, HELP_MSG_APKS_FROM_FILE,
HELP_MSG_DISABLE_UPDATE_CHECK, HELP_MSG_VERBOSE,
HELP_MSG_HASHES, HELP_MSG_CHECK_ADB,
HELP_MSG_IOC, HELP_MSG_IOC,
HELP_MSG_FAST,
HELP_MSG_LIST_MODULES, HELP_MSG_LIST_MODULES,
HELP_MSG_MODULE, HELP_MSG_MODULE,
HELP_MSG_NONINTERACTIVE, HELP_MSG_NONINTERACTIVE,
HELP_MSG_OUTPUT, HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_BUGREPORT,
HELP_MSG_CHECK_ANDROID_BACKUP,
HELP_MSG_CHECK_ANDROIDQF,
HELP_MSG_HASHES,
HELP_MSG_CHECK_IOCS,
HELP_MSG_STIX2, HELP_MSG_STIX2,
HELP_MSG_VERBOSE,
HELP_MSG_VERSION,
) )
from mvt.common.logo import logo from mvt.common.logo import logo
from mvt.common.updates import IndicatorsUpdates from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import init_logging, set_verbose_logging from mvt.common.utils import init_logging, set_verbose_logging
from .cmd_check_adb import CmdAndroidCheckADB
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
from .cmd_check_backup import CmdAndroidCheckBackup from .cmd_check_backup import CmdAndroidCheckBackup
from .cmd_check_bugreport import CmdAndroidCheckBugreport from .cmd_check_bugreport import CmdAndroidCheckBugreport
from .modules.androidqf import ANDROIDQF_MODULES from .cmd_download_apks import DownloadAPKs
from .modules.adb import ADB_MODULES
from .modules.adb.packages import Packages
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
from .modules.backup.helpers import cli_load_android_backup_password from .modules.backup.helpers import cli_load_android_backup_password
from .modules.bugreport import BUGREPORT_MODULES from .modules.bugreport import BUGREPORT_MODULES
@@ -46,37 +53,12 @@ log = logging.getLogger("mvt")
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def _get_disable_flags(ctx):
"""Helper function to safely get disable flags from context."""
if ctx.obj is None:
return False, False
return (
ctx.obj.get("disable_version_check", False),
ctx.obj.get("disable_indicator_check", False),
)
# ============================================================================== # ==============================================================================
# Main # Main
# ============================================================================== # ==============================================================================
@click.group(invoke_without_command=False) @click.group(invoke_without_command=False)
@click.option( def cli():
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK logo()
)
@click.option(
"--disable-indicator-update-check",
is_flag=True,
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
)
@click.pass_context
def cli(ctx, disable_update_check, disable_indicator_update_check):
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
# ============================================================================== # ==============================================================================
@@ -88,14 +70,117 @@ def version():
# ============================================================================== # ==============================================================================
# Command: check-adb (removed) # Command: download-apks
# ============================================================================== # ==============================================================================
@cli.command( @cli.command(
"check-adb", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_CHECK_ADB_REMOVED "download-apks", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_DOWNLOAD_APKS
) )
@click.option("--serial", "-s", type=str, help=HELP_MSG_SERIAL)
@click.option("--all-apks", "-a", is_flag=True, help=HELP_MSG_DOWNLOAD_ALL_APKS)
@click.option("--virustotal", "-V", is_flag=True, help=HELP_MSG_VIRUS_TOTAL)
@click.option("--output", "-o", type=click.Path(exists=False), help=HELP_MSG_APK_OUTPUT)
@click.option(
"--from-file", "-f", type=click.Path(exists=True), help=HELP_MSG_APKS_FROM_FILE
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.pass_context @click.pass_context
def check_adb(ctx): def download_apks(ctx, all_apks, virustotal, output, from_file, serial, verbose):
log.error(HELP_MSG_CHECK_ADB_REMOVED_DESCRIPTION) set_verbose_logging(verbose)
try:
if from_file:
download = DownloadAPKs.from_json(from_file)
else:
# TODO: Do we actually want to be able to run without storing any
# file?
if not output:
log.critical("You need to specify an output folder with --output!")
ctx.exit(1)
download = DownloadAPKs(results_path=output, all_apks=all_apks)
if serial:
download.serial = serial
download.run()
packages_to_lookup = []
if all_apks:
packages_to_lookup = download.packages
else:
for package in download.packages:
if not package.get("system", False):
packages_to_lookup.append(package)
if len(packages_to_lookup) == 0:
return
if virustotal:
m = Packages()
m.check_virustotal(packages_to_lookup)
except KeyboardInterrupt:
print("")
ctx.exit(1)
# ==============================================================================
# Command: check-adb
# ==============================================================================
@cli.command("check-adb", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_CHECK_ADB)
@click.option("--serial", "-s", type=str, help=HELP_MSG_SERIAL)
@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("--fast", "-f", is_flag=True, help=HELP_MSG_FAST)
@click.option("--list-modules", "-l", is_flag=True, help=HELP_MSG_LIST_MODULES)
@click.option("--module", "-m", help=HELP_MSG_MODULE)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.pass_context
def check_adb(
ctx,
serial,
iocs,
output,
fast,
list_modules,
module,
non_interactive,
backup_password,
verbose,
):
set_verbose_logging(verbose)
module_options = {
"fast_mode": fast,
"interactive": not non_interactive,
"backup_password": cli_load_android_backup_password(log, backup_password),
}
cmd = CmdAndroidCheckADB(
results_path=output,
ioc_files=iocs,
module_name=module,
serial=serial,
module_options=module_options,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking Android device over debug bridge")
cmd.run()
if cmd.detected_count > 0:
log.warning(
"The analysis of the Android device produced %d detections!",
cmd.detected_count,
)
# ============================================================================== # ==============================================================================
@@ -127,8 +212,6 @@ def check_bugreport(ctx, iocs, output, list_modules, module, verbose, bugreport_
ioc_files=iocs, ioc_files=iocs,
module_name=module, module_name=module,
hashes=True, hashes=True,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
) )
if list_modules: if list_modules:
@@ -138,8 +221,12 @@ 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() cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message() if cmd.detected_count > 0:
log.warning(
"The analysis of the Android bug report produced %d detections!",
cmd.detected_count,
)
# ============================================================================== # ==============================================================================
@@ -187,8 +274,6 @@ def check_backup(
"interactive": not non_interactive, "interactive": not non_interactive,
"backup_password": cli_load_android_backup_password(log, backup_password), "backup_password": cli_load_android_backup_password(log, backup_password),
}, },
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
) )
if list_modules: if list_modules:
@@ -198,8 +283,12 @@ def check_backup(
log.info("Checking Android backup at path: %s", backup_path) log.info("Checking Android backup at path: %s", backup_path)
cmd.run() cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message() if cmd.detected_count > 0:
log.warning(
"The analysis of the Android backup produced %d detections!",
cmd.detected_count,
)
# ============================================================================== # ==============================================================================
@@ -249,8 +338,6 @@ def check_androidqf(
"interactive": not non_interactive, "interactive": not non_interactive,
"backup_password": cli_load_android_backup_password(log, backup_password), "backup_password": cli_load_android_backup_password(log, backup_password),
}, },
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
) )
if list_modules: if list_modules:
@@ -260,9 +347,12 @@ def check_androidqf(
log.info("Checking AndroidQF acquisition at path: %s", androidqf_path) log.info("Checking AndroidQF acquisition at path: %s", androidqf_path)
cmd.run() cmd.run()
cmd.show_alerts_brief()
cmd.show_disable_adb_warning() if cmd.detected_count > 0:
cmd.show_support_message() log.warning(
"The analysis of the AndroidQF acquisition produced %d detections!",
cmd.detected_count,
)
# ============================================================================== # ==============================================================================
@@ -283,15 +373,13 @@ def check_androidqf(
@click.pass_context @click.pass_context
def check_iocs(ctx, iocs, list_modules, module, folder): def check_iocs(ctx, iocs, list_modules, module, folder):
cmd = CmdCheckIOCS(target_path=folder, ioc_files=iocs, module_name=module) cmd = CmdCheckIOCS(target_path=folder, ioc_files=iocs, module_name=module)
cmd.modules = BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES cmd.modules = BACKUP_MODULES + ADB_MODULES + BUGREPORT_MODULES
if list_modules: if list_modules:
cmd.list_modules() cmd.list_modules()
return return
cmd.run() cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message()
# ============================================================================== # ==============================================================================

View File

@@ -0,0 +1,37 @@
# 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.common.command import Command
from .modules.adb import ADB_MODULES
log = logging.getLogger(__name__)
class CmdAndroidCheckADB(Command):
def __init__(
self,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
ioc_files: Optional[list] = None,
module_name: Optional[str] = None,
serial: Optional[str] = None,
module_options: Optional[dict] = None,
) -> None:
super().__init__(
target_path=target_path,
results_path=results_path,
ioc_files=ioc_files,
module_name=module_name,
serial=serial,
module_options=module_options,
log=log,
)
self.name = "check-adb"
self.modules = ADB_MODULES

View File

@@ -9,194 +9,97 @@ import zipfile
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from mvt.android.cmd_check_backup import CmdAndroidCheckBackup
from mvt.android.cmd_check_bugreport import CmdAndroidCheckBugreport
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators
from .modules.androidqf import ANDROIDQF_MODULES from .modules.androidqf import ANDROIDQF_MODULES
from .modules.androidqf.base import AndroidQFModule from .modules.bugreport import BUGREPORT_MODULES
from .modules.bugreport.base import BugReportModule
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class NoAndroidQFTargetPath(Exception):
pass
class NoAndroidQFBugReport(Exception):
pass
class NoAndroidQFBackup(Exception):
pass
class CmdAndroidCheckAndroidQF(Command): class CmdAndroidCheckAndroidQF(Command):
def __init__( def __init__(
self, self,
target_path: Optional[str] = None, target_path: Optional[str] = None,
results_path: Optional[str] = None, results_path: Optional[str] = None,
ioc_files: Optional[list] = None, ioc_files: Optional[list] = None,
iocs: Optional[Indicators] = None,
module_name: Optional[str] = None, module_name: Optional[str] = None,
serial: Optional[str] = None, serial: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
hashes: Optional[bool] = False, hashes: bool = False,
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
results_path=results_path, results_path=results_path,
ioc_files=ioc_files, ioc_files=ioc_files,
iocs=iocs,
module_name=module_name, module_name=module_name,
serial=serial, serial=serial,
module_options=module_options, module_options=module_options,
hashes=hashes, hashes=hashes,
sub_command=sub_command,
log=log, log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
) )
self.name = "check-androidqf" self.name = "check-androidqf"
self.modules = ANDROIDQF_MODULES
self.__format: Optional[str] = None # We can load AndroidQF and bugreport modules here, as
self.__zip: Optional[zipfile.ZipFile] = None # AndroidQF dump will contain a bugreport.
self.__files: List[str] = [] self.modules = ANDROIDQF_MODULES + BUGREPORT_MODULES
# TODO: Check how to namespace and deduplicate modules.
self.format: Optional[str] = None
self.archive: Optional[zipfile.ZipFile] = None
self.files: List[str] = []
def init(self): def init(self):
if not self.target_path:
raise NoAndroidQFTargetPath
if os.path.isdir(self.target_path): if os.path.isdir(self.target_path):
self.__format = "dir" self.format = "dir"
parent_path = Path(self.target_path).absolute().parent.as_posix() parent_path = Path(self.target_path).absolute().parent.as_posix()
target_abs_path = os.path.abspath(self.target_path) target_abs_path = os.path.abspath(self.target_path)
for root, subdirs, subfiles in os.walk(target_abs_path): for root, subdirs, subfiles in os.walk(target_abs_path):
for fname in subfiles: for fname in subfiles:
file_path = os.path.relpath(os.path.join(root, fname), parent_path) file_path = os.path.relpath(os.path.join(root, fname), parent_path)
self.__files.append(file_path) self.files.append(file_path)
elif os.path.isfile(self.target_path): elif os.path.isfile(self.target_path):
self.__format = "zip" self.format = "zip"
self.__zip = zipfile.ZipFile(self.target_path) self.archive = zipfile.ZipFile(self.target_path)
self.__files = self.__zip.namelist() self.files = self.archive.namelist()
def module_init(self, module: AndroidQFModule) -> None: # type: ignore[override] def load_bugreport(self):
if self.__format == "zip" and self.__zip: # Refactor this file list loading
module.from_zip(self.__zip, self.__files) # First we need to find the bugreport file location
return
if not self.target_path:
raise NoAndroidQFTargetPath
parent_path = Path(self.target_path).absolute().parent.as_posix()
module.from_dir(parent_path, self.__files)
def load_bugreport(self) -> zipfile.ZipFile:
bugreport_zip_path = None bugreport_zip_path = None
for file_name in self.__files: for file_name in self.files:
if file_name.endswith("bugreport.zip"): if file_name.endswith("bugreport.zip"):
bugreport_zip_path = file_name bugreport_zip_path = file_name
break break
else: else:
raise NoAndroidQFBugReport self.log.warning("No bugreport.zip found in the AndroidQF dump")
return None
if self.__format == "zip" and self.__zip: if self.format == "zip":
handle = self.__zip.open(bugreport_zip_path) # Create handle to the bugreport.zip file inside the AndroidQF dump
return zipfile.ZipFile(handle) handle = self.archive.open(bugreport_zip_path)
bugreport_zip = zipfile.ZipFile(handle)
if self.__format == "dir" and self.target_path: else:
# Load the bugreport.zip file from the extracted AndroidQF dump on disk.
parent_path = Path(self.target_path).absolute().parent.as_posix() parent_path = Path(self.target_path).absolute().parent.as_posix()
bug_report_path = os.path.join(parent_path, bugreport_zip_path) bug_report_path = os.path.join(parent_path, bugreport_zip_path)
return zipfile.ZipFile(bug_report_path) bugreport_zip = zipfile.ZipFile(bug_report_path)
raise NoAndroidQFBugReport return bugreport_zip
def load_backup(self) -> bytes: def module_init(self, module):
backup_ab_path = None if isinstance(module, BugReportModule):
for file_name in self.__files: bugreport_archive = self.load_bugreport()
if file_name.endswith("backup.ab"): if not bugreport_archive:
backup_ab_path = file_name return
break module.from_zip(bugreport_archive, bugreport_archive.namelist())
return
if self.format == "zip":
module.from_zip_file(self.archive, self.files)
else: else:
raise NoAndroidQFBackup
if self.__format == "zip" and self.__zip:
backup_file_handle = self.__zip.open(backup_ab_path)
return backup_file_handle.read()
if self.__format == "dir" and self.target_path:
parent_path = Path(self.target_path).absolute().parent.as_posix() parent_path = Path(self.target_path).absolute().parent.as_posix()
backup_path = os.path.join(parent_path, backup_ab_path) module.from_folder(parent_path, self.files)
with open(backup_path, "rb") as backup_file:
backup_ab_data = backup_file.read()
return backup_ab_data
raise NoAndroidQFBackup
def run_bugreport_cmd(self) -> bool:
bugreport = None
try:
bugreport = self.load_bugreport()
except NoAndroidQFBugReport:
self.log.warning(
"Skipping bugreport modules as no bugreport.zip found in AndroidQF data."
)
return False
else:
cmd = CmdAndroidCheckBugreport(
target_path=None,
results_path=self.results_path,
ioc_files=self.ioc_files,
iocs=self.iocs,
module_options=self.module_options,
hashes=self.hashes,
sub_command=True,
)
cmd.from_zip(bugreport)
cmd.run()
self.timeline.extend(cmd.timeline)
self.alertstore.extend(cmd.alertstore.alerts)
finally:
if bugreport:
bugreport.close()
return True
def run_backup_cmd(self) -> bool:
try:
backup = self.load_backup()
except NoAndroidQFBackup:
self.log.warning(
"Skipping backup modules as no backup.ab found in AndroidQF data."
)
return False
cmd = CmdAndroidCheckBackup(
target_path=None,
results_path=self.results_path,
ioc_files=self.ioc_files,
iocs=self.iocs,
module_options=self.module_options,
hashes=self.hashes,
sub_command=True,
)
cmd.from_ab(backup)
cmd.run()
self.timeline.extend(cmd.timeline)
self.alertstore.extend(cmd.alertstore.alerts)
return True
def finish(self) -> None:
"""
Run the bugreport and backup modules if the respective files are found in the AndroidQF data.
"""
self.run_bugreport_cmd()
self.run_backup_cmd()

View File

@@ -11,7 +11,7 @@ import tarfile
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from mvt.android.modules.backup.base import BackupModule from mvt.android.modules.backup.base import BackupExtraction
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
from mvt.android.parsers.backup import ( from mvt.android.parsers.backup import (
AndroidBackupParsingError, AndroidBackupParsingError,
@@ -20,7 +20,6 @@ from mvt.android.parsers.backup import (
parse_backup_file, parse_backup_file,
) )
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators
from .modules.backup import BACKUP_MODULES from .modules.backup import BACKUP_MODULES
@@ -33,88 +32,73 @@ class CmdAndroidCheckBackup(Command):
target_path: Optional[str] = None, target_path: Optional[str] = None,
results_path: Optional[str] = None, results_path: Optional[str] = None,
ioc_files: Optional[list] = None, ioc_files: Optional[list] = None,
iocs: Optional[Indicators] = None,
module_name: Optional[str] = None, module_name: Optional[str] = None,
serial: Optional[str] = None, serial: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
hashes: Optional[bool] = False, hashes: bool = False,
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
results_path=results_path, results_path=results_path,
ioc_files=ioc_files, ioc_files=ioc_files,
iocs=iocs,
module_name=module_name, module_name=module_name,
serial=serial, serial=serial,
module_options=module_options, module_options=module_options,
hashes=hashes, hashes=hashes,
sub_command=sub_command,
log=log, log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
) )
self.name = "check-backup" self.name = "check-backup"
self.modules = BACKUP_MODULES self.modules = BACKUP_MODULES
self.__type: str = "" self.backup_type: str = ""
self.__tar: Optional[tarfile.TarFile] = None self.backup_archive: Optional[tarfile.TarFile] = None
self.__files: List[str] = [] self.backup_files: List[str] = []
def from_ab(self, ab_file_bytes: bytes) -> None:
self.__type = "ab"
header = parse_ab_header(ab_file_bytes)
if not header["backup"]:
log.critical("Invalid backup format, file should be in .ab format")
sys.exit(1)
password = None
if header["encryption"] != "none":
password = prompt_or_load_android_backup_password(log, self.module_options)
if not password:
log.critical("No backup password provided.")
sys.exit(1)
try:
tardata = parse_backup_file(ab_file_bytes, password=password)
except InvalidBackupPassword:
log.critical("Invalid backup password")
sys.exit(1)
except AndroidBackupParsingError as exc:
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1)
dbytes = io.BytesIO(tardata)
self.__tar = tarfile.open(fileobj=dbytes)
for member in self.__tar:
self.__files.append(member.name)
def init(self) -> None: def init(self) -> None:
if not self.target_path: # type: ignore[has-type] if not self.target_path:
return return
# Type guard: we know it's not None here after the check above if os.path.isfile(self.target_path):
assert self.target_path is not None # type: ignore[has-type] self.backup_type = "ab"
# Use a different local variable name to avoid any scoping issues with open(self.target_path, "rb") as handle:
backup_path: str = self.target_path # type: ignore[has-type] data = handle.read()
if os.path.isfile(backup_path): header = parse_ab_header(data)
self.__type = "ab" if not header["backup"]:
with open(backup_path, "rb") as handle: log.critical("Invalid backup format, file should be in .ab format")
ab_file_bytes = handle.read() sys.exit(1)
self.from_ab(ab_file_bytes)
elif os.path.isdir(backup_path): password = None
self.__type = "folder" if header["encryption"] != "none":
backup_path = Path(backup_path).absolute().as_posix() password = prompt_or_load_android_backup_password(
self.target_path = backup_path log, self.module_options
for root, subdirs, subfiles in os.walk(os.path.abspath(backup_path)): )
if not password:
log.critical("No backup password provided.")
sys.exit(1)
try:
tardata = parse_backup_file(data, password=password)
except InvalidBackupPassword:
log.critical("Invalid backup password")
sys.exit(1)
except AndroidBackupParsingError as exc:
log.critical("Impossible to parse this backup file: %s", exc)
log.critical("Please use Android Backup Extractor (ABE) instead")
sys.exit(1)
dbytes = io.BytesIO(tardata)
self.backup_archive = tarfile.open(fileobj=dbytes)
for member in self.backup_archive:
self.backup_files.append(member.name)
elif os.path.isdir(self.target_path):
self.backup_type = "folder"
self.target_path = Path(self.target_path).absolute().as_posix()
for root, subdirs, subfiles in os.walk(os.path.abspath(self.target_path)):
for fname in subfiles: for fname in subfiles:
self.__files.append( self.backup_files.append(
os.path.relpath(os.path.join(root, fname), backup_path) os.path.relpath(os.path.join(root, fname), self.target_path)
) )
else: else:
log.critical( log.critical(
@@ -123,12 +107,8 @@ class CmdAndroidCheckBackup(Command):
) )
sys.exit(1) sys.exit(1)
def module_init(self, module: BackupModule) -> None: # type: ignore[override] def module_init(self, module: BackupExtraction) -> None: # type: ignore[override]
if self.__type == "folder": if self.backup_type == "folder":
module.from_dir(self.target_path, self.__files) module.from_folder(self.target_path, self.backup_files)
else: else:
module.from_ab(self.target_path, self.__tar, self.__files) module.from_ab(self.target_path, self.backup_archive, self.backup_files)
def finish(self) -> None:
if self.__tar:
self.__tar.close()

View File

@@ -11,7 +11,6 @@ from zipfile import ZipFile
from mvt.android.modules.bugreport.base import BugReportModule from mvt.android.modules.bugreport.base import BugReportModule
from mvt.common.command import Command from mvt.common.command import Command
from mvt.common.indicators import Indicators
from .modules.bugreport import BUGREPORT_MODULES from .modules.bugreport import BUGREPORT_MODULES
@@ -24,82 +23,54 @@ class CmdAndroidCheckBugreport(Command):
target_path: Optional[str] = None, target_path: Optional[str] = None,
results_path: Optional[str] = None, results_path: Optional[str] = None,
ioc_files: Optional[list] = None, ioc_files: Optional[list] = None,
iocs: Optional[Indicators] = None,
module_name: Optional[str] = None, module_name: Optional[str] = None,
serial: Optional[str] = None, serial: Optional[str] = None,
module_options: Optional[dict] = None, module_options: Optional[dict] = None,
hashes: Optional[bool] = False, hashes: bool = False,
sub_command: Optional[bool] = False,
disable_version_check: bool = False,
disable_indicator_check: bool = False,
) -> None: ) -> None:
super().__init__( super().__init__(
target_path=target_path, target_path=target_path,
results_path=results_path, results_path=results_path,
ioc_files=ioc_files, ioc_files=ioc_files,
iocs=iocs,
module_name=module_name, module_name=module_name,
serial=serial, serial=serial,
module_options=module_options, module_options=module_options,
hashes=hashes, hashes=hashes,
sub_command=sub_command,
log=log, log=log,
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
) )
self.name = "check-bugreport" self.name = "check-bugreport"
self.modules = BUGREPORT_MODULES self.modules = BUGREPORT_MODULES
self.__format: str = "" self.bugreport_format: str = ""
self.__zip: Optional[ZipFile] = None self.bugreport_archive: Optional[ZipFile] = None
self.__files: List[str] = [] self.bugreport_files: List[str] = []
def from_dir(self, dir_path: str) -> None:
"""This method is used to initialize the bug report analysis from an
uncompressed directory.
"""
self.__format = "dir"
self.target_path = dir_path
parent_path = Path(dir_path).absolute().as_posix()
for root, _, subfiles in os.walk(os.path.abspath(dir_path)):
for file_name in subfiles:
file_path = os.path.relpath(os.path.join(root, file_name), parent_path)
self.__files.append(file_path)
def from_zip(self, bugreport_zip: ZipFile) -> None:
"""This method is used to initialize the bug report analysis from a
compressed archive.
"""
# NOTE: This will be invoked either by the CLI directly,or by the
# check-androidqf command. We need this because we want to support
# check-androidqf to analyse compressed archives itself too.
# So, we'll need to extract bugreport.zip from a 'androidqf.zip', and
# since nothing is written on disk, we need to be able to pass this
# command a ZipFile instance in memory.
self.__format = "zip"
self.__zip = bugreport_zip
for file_name in self.__zip.namelist():
self.__files.append(file_name)
def init(self) -> None: def init(self) -> None:
if not self.target_path: if not self.target_path:
return return
if os.path.isfile(self.target_path): if os.path.isfile(self.target_path):
self.from_zip(ZipFile(self.target_path)) self.bugreport_format = "zip"
self.bugreport_archive = ZipFile(self.target_path)
for file_name in self.bugreport_archive.namelist():
self.bugreport_files.append(file_name)
elif os.path.isdir(self.target_path): elif os.path.isdir(self.target_path):
self.from_dir(self.target_path) self.bugreport_format = "dir"
parent_path = Path(self.target_path).absolute().as_posix()
for root, _, subfiles in os.walk(os.path.abspath(self.target_path)):
for file_name in subfiles:
file_path = os.path.relpath(
os.path.join(root, file_name), parent_path
)
self.bugreport_files.append(file_path)
def module_init(self, module: BugReportModule) -> None: # type: ignore[override] def module_init(self, module: BugReportModule) -> None: # type: ignore[override]
if self.__format == "zip": if self.bugreport_format == "zip":
module.from_zip(self.__zip, self.__files) module.from_zip(self.bugreport_archive, self.bugreport_files)
else: else:
if not self.target_path: module.from_folder(self.target_path, self.bugreport_files)
raise ValueError("target_path is not set")
module.from_dir(self.target_path, self.__files)
def finish(self) -> None: def finish(self) -> None:
if self.__zip: if self.bugreport_archive:
self.__zip.close() self.bugreport_archive.close()

View File

@@ -0,0 +1,184 @@
# 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
from typing import Callable, Optional, Union
from rich.progress import track
from mvt.common.module import InsufficientPrivileges
from .modules.adb.base import AndroidExtraction
from .modules.adb.packages import Packages
log = logging.getLogger(__name__)
class DownloadAPKs(AndroidExtraction):
"""DownloadAPKs is the main class operating the download of APKs
from the device.
"""
def __init__(
self,
results_path: Optional[str] = None,
all_apks: bool = False,
packages: Optional[list] = None,
) -> None:
"""Initialize module.
:param results_path: Path to the folder where data should be stored
:param all_apks: Boolean indicating whether to download all packages
or filter known-goods
:param packages: Provided list of packages, typically for JSON checks
"""
super().__init__(results_path=results_path, log=log)
self.packages = packages
self.all_apks = all_apks
self.results_path_apks = None
@classmethod
def from_json(cls, json_path: str) -> Callable:
"""Initialize this class from an existing apks.json file.
:param json_path: Path to the apks.json file to parse.
"""
with open(json_path, "r", encoding="utf-8") as handle:
packages = json.load(handle)
return cls(packages=packages)
def pull_package_file(
self, package_name: str, remote_path: str
) -> Union[str, None]:
"""Pull files related to specific package from the device.
:param package_name: Name of the package to download
:param remote_path: Path to the file to download
:returns: Path to the local copy
"""
log.info("Downloading %s ...", remote_path)
file_name = ""
if "==/" in remote_path:
file_name = "_" + remote_path.split("==/")[1].replace(".apk", "")
local_path = os.path.join(
self.results_path_apks, f"{package_name}{file_name}.apk"
)
name_counter = 0
while True:
if not os.path.exists(local_path):
break
name_counter += 1
local_path = os.path.join(
self.results_path_apks, f"{package_name}{file_name}_{name_counter}.apk"
)
try:
self._adb_download(remote_path, local_path)
except InsufficientPrivileges:
log.error(
"Unable to pull package file from %s: insufficient privileges, "
"it might be a system app",
remote_path,
)
self._adb_reconnect()
return None
except Exception as exc:
log.exception("Failed to pull package file from %s: %s", remote_path, exc)
self._adb_reconnect()
return None
return local_path
def get_packages(self) -> None:
"""Use the Packages adb module to retrieve the list of packages.
We reuse the same extraction logic to then download the APKs.
"""
self.log.info("Retrieving list of installed packages...")
m = Packages()
m.log = self.log
m.serial = self.serial
m.run()
self.packages = m.results
def pull_packages(self) -> None:
"""Download all files of all selected packages from the device."""
log.info(
"Starting extraction of installed APKs at folder %s", self.results_path
)
# If the user provided the flag --all-apks we select all packages.
packages_selection = []
if self.all_apks:
log.info("Selected all %d available packages", len(self.packages))
packages_selection = self.packages
else:
# Otherwise we loop through the packages and get only those that
# are not marked as system.
for package in self.packages:
if not package.get("system", False):
packages_selection.append(package)
log.info(
'Selected only %d packages which are not marked as "system"',
len(packages_selection),
)
if len(packages_selection) == 0:
log.info("No packages were selected for download")
return
log.info("Downloading packages from device. This might take some time ...")
self.results_path_apks = os.path.join(self.results_path, "apks")
if not os.path.exists(self.results_path_apks):
os.makedirs(self.results_path_apks, exist_ok=True)
for i in track(
range(len(packages_selection)),
description=f"Downloading {len(packages_selection)} packages...",
):
package = packages_selection[i]
log.info(
"[%d/%d] Package: %s",
i,
len(packages_selection),
package["package_name"],
)
# Sometimes the package path contains multiple lines for multiple
# apks. We loop through each line and download each file.
for package_file in package["files"]:
device_path = package_file["path"]
local_path = self.pull_package_file(
package["package_name"], device_path
)
if not local_path:
continue
package_file["local_path"] = local_path
log.info("Download of selected packages completed")
def save_json(self) -> None:
json_path = os.path.join(self.results_path, "apks.json")
with open(json_path, "w", encoding="utf-8") as handle:
json.dump(self.packages, handle, indent=4)
def run(self) -> None:
self.get_packages()
self._adb_connect()
self.pull_packages()
self.save_json()
self._adb_disconnect()

View File

@@ -4,7 +4,15 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .chrome_history import ChromeHistory from .chrome_history import ChromeHistory
from .dumpsys_accessibility import DumpsysAccessibility
from .dumpsys_activities import DumpsysActivities
from .dumpsys_appops import DumpsysAppOps
from .dumpsys_battery_daily import DumpsysBatteryDaily
from .dumpsys_battery_history import DumpsysBatteryHistory
from .dumpsys_dbinfo import DumpsysDBInfo
from .dumpsys_adbstate import DumpsysADBState
from .dumpsys_full import DumpsysFull from .dumpsys_full import DumpsysFull
from .dumpsys_receivers import DumpsysReceivers
from .files import Files from .files import Files
from .getprop import Getprop from .getprop import Getprop
from .logcat import Logcat from .logcat import Logcat
@@ -24,7 +32,15 @@ ADB_MODULES = [
Getprop, Getprop,
Settings, Settings,
SELinuxStatus, SELinuxStatus,
DumpsysBatteryHistory,
DumpsysBatteryDaily,
DumpsysReceivers,
DumpsysActivities,
DumpsysAccessibility,
DumpsysDBInfo,
DumpsysADBState,
DumpsysFull, DumpsysFull,
DumpsysAppOps,
Packages, Packages,
Logcat, Logcat,
RootBinaries, RootBinaries,

View File

@@ -0,0 +1,355 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import base64
import logging
import os
import random
import string
import sys
import tempfile
import time
from typing import Callable, Optional
from adb_shell.adb_device import AdbDeviceTcp, AdbDeviceUsb
from adb_shell.auth.keygen import keygen, write_public_keyfile
from adb_shell.auth.sign_pythonrsa import PythonRSASigner
from adb_shell.exceptions import (
AdbCommandFailureException,
DeviceAuthError,
UsbDeviceNotFoundError,
UsbReadFailedError,
)
from usb1 import USBErrorAccess, USBErrorBusy
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
from mvt.android.parsers.backup import (
InvalidBackupPassword,
parse_ab_header,
parse_backup_file,
)
from mvt.common.module import InsufficientPrivileges, MVTModule
ADB_KEY_PATH = os.path.expanduser("~/.android/adbkey")
ADB_PUB_KEY_PATH = os.path.expanduser("~/.android/adbkey.pub")
class AndroidExtraction(MVTModule):
"""This class provides a base for all Android extraction modules."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.device = None
self.serial = None
@staticmethod
def _adb_check_keys() -> None:
"""Make sure Android adb keys exist."""
if not os.path.isdir(os.path.dirname(ADB_KEY_PATH)):
os.makedirs(os.path.dirname(ADB_KEY_PATH))
if not os.path.exists(ADB_KEY_PATH):
keygen(ADB_KEY_PATH)
if not os.path.exists(ADB_PUB_KEY_PATH):
write_public_keyfile(ADB_KEY_PATH, ADB_PUB_KEY_PATH)
def _adb_connect(self) -> None:
"""Connect to the device over adb."""
self._adb_check_keys()
with open(ADB_KEY_PATH, "rb") as handle:
priv_key = handle.read()
with open(ADB_PUB_KEY_PATH, "rb") as handle:
pub_key = handle.read()
signer = PythonRSASigner(pub_key, priv_key)
# If no serial was specified or if the serial does not seem to be
# a HOST:PORT definition, we use the USB transport.
if not self.serial or ":" not in self.serial:
try:
self.device = AdbDeviceUsb(serial=self.serial)
except UsbDeviceNotFoundError:
self.log.critical(
"No device found. Make sure it is connected and unlocked."
)
sys.exit(-1)
# Otherwise we try to use the TCP transport.
else:
addr = self.serial.split(":")
if len(addr) < 2:
raise ValueError(
"TCP serial number must follow the format: `address:port`"
)
self.device = AdbDeviceTcp(
addr[0], int(addr[1]), default_transport_timeout_s=30.0
)
while True:
try:
self.device.connect(rsa_keys=[signer], auth_timeout_s=5)
except (USBErrorBusy, USBErrorAccess):
self.log.critical(
"Device is busy, maybe run `adb kill-server` and try again."
)
sys.exit(-1)
except DeviceAuthError:
self.log.error(
"You need to authorize this computer on the Android device. "
"Retrying in 5 seconds..."
)
time.sleep(5)
except UsbReadFailedError:
self.log.error(
"Unable to connect to the device over USB. "
"Try to unplug, plug the device and start again."
)
sys.exit(-1)
except OSError as exc:
if exc.errno == 113 and self.serial:
self.log.critical(
"Unable to connect to the device %s: "
"did you specify the correct IP address?",
self.serial,
)
sys.exit(-1)
else:
break
def _adb_disconnect(self) -> None:
"""Close adb connection to the device."""
self.device.close()
def _adb_reconnect(self) -> None:
"""Reconnect to device using adb."""
self.log.info("Reconnecting ...")
self._adb_disconnect()
self._adb_connect()
def _adb_command(self, command: str, decode: bool = True) -> str:
"""Execute an adb shell command.
:param command: Shell command to execute
:returns: Output of command
"""
return self.device.shell(command, read_timeout_s=200.0, decode=decode)
def _adb_check_if_root(self) -> bool:
"""Check if we have a `su` binary on the Android device.
:returns: Boolean indicating whether a `su` binary is present or not
"""
result = self._adb_command("command -v su && su -c true")
return bool(result) and "Permission denied" not in result
def _adb_root_or_die(self) -> None:
"""Check if we have a `su` binary, otherwise raise an Exception."""
if not self._adb_check_if_root():
raise InsufficientPrivileges(
"This module is optionally available "
"in case the device is already rooted."
" Do NOT root your own device!"
)
def _adb_command_as_root(self, command):
"""Execute an adb shell command.
:param command: Shell command to execute as root
:returns: Output of command
"""
return self._adb_command(f"su -c {command}")
def _adb_check_file_exists(self, file: str) -> bool:
"""Verify that a file exists.
:param file: Path of the file
:returns: Boolean indicating whether the file exists or not
"""
# TODO: Need to support checking files without root privileges as well.
# Check if we have root, if not raise an Exception.
self._adb_root_or_die()
return bool(self._adb_command_as_root(f"[ ! -f {file} ] || echo 1"))
def _adb_download(
self,
remote_path: str,
local_path: str,
progress_callback: Optional[Callable] = None,
retry_root: Optional[bool] = True,
) -> None:
"""Download a file form the device.
:param remote_path: Path to download from the device
:param local_path: Path to where to locally store the copy of the file
:param progress_callback: Callback for download progress bar
(Default value = None)
:param retry_root: Default value = True)
"""
try:
self.device.pull(remote_path, local_path, progress_callback)
except AdbCommandFailureException as exc:
if retry_root:
self._adb_download_root(remote_path, local_path, progress_callback)
else:
raise Exception(
f"Unable to download file {remote_path}: {exc}"
) from exc
def _adb_download_root(
self,
remote_path: str,
local_path: str,
progress_callback: Optional[Callable] = None,
) -> None:
try:
# Check if we have root, if not raise an Exception.
self._adb_root_or_die()
# We generate a random temporary filename.
allowed_chars = (
string.ascii_uppercase + string.ascii_lowercase + string.digits
)
tmp_filename = "tmp_" + "".join(random.choices(allowed_chars, k=10))
# We create a temporary local file.
new_remote_path = f"/sdcard/{tmp_filename}"
# We copy the file from the data folder to /sdcard/.
cp_output = self._adb_command_as_root(f"cp {remote_path} {new_remote_path}")
if (
cp_output.startswith("cp: ")
and "No such file or directory" in cp_output
):
raise Exception(f"Unable to process file {remote_path}: File not found")
if cp_output.startswith("cp: ") and "Permission denied" in cp_output:
raise Exception(
f"Unable to process file {remote_path}: Permission denied"
)
# We download from /sdcard/ to the local temporary file.
# If it doesn't work now, don't try again (retry_root=False)
self._adb_download(
new_remote_path, local_path, progress_callback, retry_root=False
)
# Delete the copy on /sdcard/.
self._adb_command(f"rm -rf {new_remote_path}")
except AdbCommandFailureException as exc:
raise Exception(f"Unable to download file {remote_path}: {exc}") from exc
def _adb_process_file(self, remote_path: str, process_routine: Callable) -> None:
"""Download a local copy of a file which is only accessible as root.
This is a wrapper around process_routine.
:param remote_path: Path of the file on the device to process
:param process_routine: Function to be called on the local copy of the
downloaded file
"""
# Connect to the device over adb.
# Check if we have root, if not raise an Exception.
self._adb_root_or_die()
# We create a temporary local file.
tmp = tempfile.NamedTemporaryFile()
local_path = tmp.name
local_name = os.path.basename(tmp.name)
new_remote_path = f"/sdcard/Download/{local_name}"
# We copy the file from the data folder to /sdcard/.
cp_output = self._adb_command_as_root(f"cp {remote_path} {new_remote_path}")
if cp_output.startswith("cp: ") and "No such file or directory" in cp_output:
raise Exception(f"Unable to process file {remote_path}: File not found")
if cp_output.startswith("cp: ") and "Permission denied" in cp_output:
raise Exception(f"Unable to process file {remote_path}: Permission denied")
# We download from /sdcard/ to the local temporary file.
self._adb_download(new_remote_path, local_path)
# Launch the provided process routine!
process_routine(local_path)
# Delete the local copy.
tmp.close()
# Delete the copy on /sdcard/.
self._adb_command(f"rm -f {new_remote_path}")
def _generate_backup(self, package_name: str) -> bytes:
self.log.info(
"Please check phone and accept Android backup prompt. "
"You may need to set a backup password. \a"
)
if self.module_options.get("backup_password", None):
self.log.warning(
"Backup password already set from command line or environment "
"variable. You should use the same password if enabling encryption!"
)
# TODO: Base64 encoding as temporary fix to avoid byte-mangling over
# the shell transport...
cmd = f"/system/bin/bu backup -nocompress '{package_name}' | base64"
backup_output_b64 = self._adb_command(cmd)
backup_output = base64.b64decode(backup_output_b64)
header = parse_ab_header(backup_output)
if not header["backup"]:
self.log.error(
"Extracting SMS via Android backup failed. No valid backup data found."
)
return None
if header["encryption"] == "none":
return parse_backup_file(backup_output, password=None)
for _ in range(0, 3):
backup_password = prompt_or_load_android_backup_password(
self.log, self.module_options
)
if not backup_password:
# Fail as no backup password loaded for this encrypted backup
self.log.critical("No backup password provided.")
try:
decrypted_backup_tar = parse_backup_file(backup_output, backup_password)
return decrypted_backup_tar
except InvalidBackupPassword:
self.log.error("You provided the wrong password! Please try again...")
self.log.error("All attempts to decrypt backup with password failed!")
return None
def run(self) -> None:
"""Run the main procedure."""
raise NotImplementedError

View File

@@ -6,13 +6,8 @@
import logging import logging
import os import os
import sqlite3 import sqlite3
from typing import Optional from typing import Optional, Union
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleResults,
ModuleSerializedResult,
)
from mvt.common.utils import convert_chrometime_to_datetime, convert_datetime_to_iso from mvt.common.utils import convert_chrometime_to_datetime, convert_datetime_to_iso
from .base import AndroidExtraction from .base import AndroidExtraction
@@ -30,7 +25,7 @@ class ChromeHistory(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -40,9 +35,9 @@ class ChromeHistory(AndroidExtraction):
log=log, log=log,
results=results, results=results,
) )
self.results: list = [] self.results = []
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
return { return {
"timestamp": record["isodate"], "timestamp": record["isodate"],
"module": self.__class__.__name__, "module": self.__class__.__name__,
@@ -56,11 +51,9 @@ class ChromeHistory(AndroidExtraction):
return return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_url(result["url"]) if self.indicators.check_url(result["url"]):
if ioc_match: self.detected.append(result)
self.alertstore.critical( continue
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
def _parse_db(self, db_path: str) -> None: def _parse_db(self, db_path: str) -> None:
"""Parse a Chrome History database file. """Parse a Chrome History database file.

View File

@@ -0,0 +1,49 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
from .base import AndroidExtraction
class DumpsysAccessibility(DumpsysAccessibilityArtifact, AndroidExtraction):
"""This module extracts stats on accessibility."""
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 run(self) -> None:
self._adb_connect()
output = self._adb_command("dumpsys accessibility")
self._adb_disconnect()
self.parse(output)
for result in self.results:
self.log.info(
'Found installed accessibility service "%s"', result.get("service")
)
self.log.info(
"Identified a total of %d accessibility services", len(self.results)
)

View File

@@ -0,0 +1,45 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_package_activities import (
DumpsysPackageActivitiesArtifact,
)
from .base import AndroidExtraction
class DumpsysActivities(DumpsysPackageActivitiesArtifact, AndroidExtraction):
"""This module extracts details on receivers for risky activities."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results = results if results else []
def run(self) -> None:
self._adb_connect()
output = self._adb_command("dumpsys package")
self._adb_disconnect()
self.parse(output)
self.log.info("Extracted %d package activities", len(self.results))

View File

@@ -0,0 +1,45 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
from .base import AndroidExtraction
class DumpsysADBState(DumpsysADBArtifact, AndroidExtraction):
"""This module extracts ADB keystore state."""
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 run(self) -> None:
self._adb_connect()
output = self._adb_command("dumpsys adb", decode=False)
self._adb_disconnect()
self.parse(output)
if self.results:
self.log.info(
"Identified a total of %d trusted ADB keys",
len(self.results[0].get("user_keys", [])),
)

View File

@@ -0,0 +1,46 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_appops import DumpsysAppopsArtifact
from .base import AndroidExtraction
class DumpsysAppOps(DumpsysAppopsArtifact, AndroidExtraction):
"""This module extracts records from App-op Manager."""
slug = "dumpsys_appops"
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 run(self) -> None:
self._adb_connect()
output = self._adb_command("dumpsys appops")
self._adb_disconnect()
self.parse(output)
self.log.info(
"Extracted a total of %d records from app-ops manager", len(self.results)
)

View File

@@ -0,0 +1,44 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_battery_daily import DumpsysBatteryDailyArtifact
from .base import AndroidExtraction
class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, AndroidExtraction):
"""This module extracts records from battery daily updates."""
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 run(self) -> None:
self._adb_connect()
output = self._adb_command("dumpsys batterystats --daily")
self._adb_disconnect()
self.parse(output)
self.log.info(
"Extracted %d records from battery daily stats", len(self.results)
)

View File

@@ -0,0 +1,42 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_battery_history import DumpsysBatteryHistoryArtifact
from .base import AndroidExtraction
class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, AndroidExtraction):
"""This module extracts records from battery history events."""
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 run(self) -> None:
self._adb_connect()
output = self._adb_command("dumpsys batterystats --history")
self._adb_disconnect()
self.parse(output)
self.log.info("Extracted %d records from battery history", len(self.results))

View File

@@ -0,0 +1,47 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_dbinfo import DumpsysDBInfoArtifact
from .base import AndroidExtraction
class DumpsysDBInfo(DumpsysDBInfoArtifact, AndroidExtraction):
"""This module extracts records from battery daily updates."""
slug = "dumpsys_dbinfo"
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 run(self) -> None:
self._adb_connect()
output = self._adb_command("dumpsys dbinfo")
self._adb_disconnect()
self.parse(output)
self.log.info(
"Extracted a total of %d records from database information",
len(self.results),
)

View File

@@ -8,7 +8,6 @@ import os
from typing import Optional from typing import Optional
from .base import AndroidExtraction from .base import AndroidExtraction
from mvt.common.module_types import ModuleResults
class DumpsysFull(AndroidExtraction): class DumpsysFull(AndroidExtraction):
@@ -21,7 +20,7 @@ class DumpsysFull(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -0,0 +1,44 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_receivers import DumpsysReceiversArtifact
from .base import AndroidExtraction
class DumpsysReceivers(DumpsysReceiversArtifact, AndroidExtraction):
"""This module extracts details on receivers for risky activities."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results = results if results else {}
def run(self) -> None:
self._adb_connect()
output = self._adb_command("dumpsys package")
self.parse(output)
self._adb_disconnect()
self.log.info("Extracted receivers for %d intents", len(self.results))

View File

@@ -8,7 +8,6 @@ import os
import stat import stat
from typing import Optional, Union from typing import Optional, Union
from mvt.common.module_types import ModuleResults
from mvt.common.utils import convert_unix_to_iso from mvt.common.utils import convert_unix_to_iso
from .base import AndroidExtraction from .base import AndroidExtraction
@@ -33,7 +32,7 @@ class Files(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -64,15 +63,11 @@ class Files(AndroidExtraction):
result["path"], result["path"],
) )
if self.indicators: if self.indicators and self.indicators.check_file_path(result["path"]):
ioc_match = self.indicators.check_file_path(result["path"]) self.log.warning(
if ioc_match: 'Found a known suspicous file at path: "%s"', result["path"]
self.alertstore.critical( )
f'Found a known suspicious file at path: "{result["path"]}"', self.detected.append(result)
"",
result,
matched_indicator=ioc_match,
)
def backup_file(self, file_path: str) -> None: def backup_file(self, file_path: str) -> None:
if not self.results_path: if not self.results_path:

View File

@@ -9,7 +9,6 @@ from typing import Optional
from mvt.android.artifacts.getprop import GetProp as GetPropArtifact from mvt.android.artifacts.getprop import GetProp as GetPropArtifact
from .base import AndroidExtraction from .base import AndroidExtraction
from mvt.common.module_types import ModuleResults
class Getprop(GetPropArtifact, AndroidExtraction): class Getprop(GetPropArtifact, AndroidExtraction):
@@ -22,7 +21,7 @@ class Getprop(GetPropArtifact, AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -8,7 +8,6 @@ import os
from typing import Optional from typing import Optional
from .base import AndroidExtraction from .base import AndroidExtraction
from mvt.common.module_types import ModuleResults
class Logcat(AndroidExtraction): class Logcat(AndroidExtraction):
@@ -21,7 +20,7 @@ class Logcat(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -4,7 +4,12 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging import logging
from typing import Optional from typing import Optional, Union
from rich.console import Console
from rich.progress import track
from rich.table import Table
from rich.text import Text
from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact
from mvt.android.utils import ( from mvt.android.utils import (
@@ -14,11 +19,7 @@ from mvt.android.utils import (
SECURITY_PACKAGES, SECURITY_PACKAGES,
SYSTEM_UPDATE_PACKAGES, SYSTEM_UPDATE_PACKAGES,
) )
from mvt.common.module_types import ( from mvt.common.virustotal import VTNoKey, VTQuotaExceeded, virustotal_lookup
ModuleAtomicResult,
ModuleResults,
ModuleSerializedResult,
)
from .base import AndroidExtraction from .base import AndroidExtraction
@@ -33,7 +34,7 @@ class Packages(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -45,7 +46,7 @@ class Packages(AndroidExtraction):
) )
self._user_needed = False self._user_needed = False
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
records = [] records = []
timestamps = [ timestamps = [
@@ -73,11 +74,11 @@ class Packages(AndroidExtraction):
def check_indicators(self) -> None: def check_indicators(self) -> None:
for result in self.results: for result in self.results:
if result["package_name"] in ROOT_PACKAGES: if result["package_name"] in ROOT_PACKAGES:
self.alertstore.high( self.log.warning(
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"', 'Found an installed package related to rooting/jailbreaking: "%s"',
"", result["package_name"],
result,
) )
self.detected.append(result)
continue continue
if result["package_name"] in SECURITY_PACKAGES and result["disabled"]: if result["package_name"] in SECURITY_PACKAGES and result["disabled"]:
@@ -94,71 +95,70 @@ class Packages(AndroidExtraction):
if not self.indicators: if not self.indicators:
continue continue
ioc_match = self.indicators.check_app_id(result["package_name"]) ioc = self.indicators.check_app_id(result.get("package_name"))
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
) continue
for package_file in result.get("files", []): for package_file in result.get("files", []):
ioc_match = self.indicators.check_file_hash(package_file["sha256"]) ioc = self.indicators.check_file_hash(package_file["sha256"])
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
# @staticmethod @staticmethod
# def check_virustotal(packages: list) -> None: def check_virustotal(packages: list) -> None:
# hashes = [] hashes = []
# for package in packages: for package in packages:
# for file in package.get("files", []): for file in package.get("files", []):
# if file["sha256"] not in hashes: if file["sha256"] not in hashes:
# hashes.append(file["sha256"]) hashes.append(file["sha256"])
# total_hashes = len(hashes) total_hashes = len(hashes)
# detections = {} detections = {}
# progress_desc = f"Looking up {total_hashes} files..." progress_desc = f"Looking up {total_hashes} files..."
# for i in track(range(total_hashes), description=progress_desc): for i in track(range(total_hashes), description=progress_desc):
# try: try:
# results = virustotal_lookup(hashes[i]) results = virustotal_lookup(hashes[i])
# except VTNoKey: except VTNoKey:
# return return
# except VTQuotaExceeded as exc: except VTQuotaExceeded as exc:
# print("Unable to continue: %s", exc) print("Unable to continue: %s", exc)
# break break
# if not results: if not results:
# continue continue
# positives = results["attributes"]["last_analysis_stats"]["malicious"] positives = results["attributes"]["last_analysis_stats"]["malicious"]
# total = len(results["attributes"]["last_analysis_results"]) total = len(results["attributes"]["last_analysis_results"])
# detections[hashes[i]] = f"{positives}/{total}" detections[hashes[i]] = f"{positives}/{total}"
# table = Table(title="VirusTotal Packages Detections") table = Table(title="VirusTotal Packages Detections")
# table.add_column("Package name") table.add_column("Package name")
# table.add_column("File path") table.add_column("File path")
# table.add_column("Detections") table.add_column("Detections")
# for package in packages: for package in packages:
# for file in package.get("files", []): for file in package.get("files", []):
# row = [package["package_name"], file["path"]] row = [package["package_name"], file["path"]]
# if file["sha256"] in detections: if file["sha256"] in detections:
# detection = detections[file["sha256"]] detection = detections[file["sha256"]]
# positives = detection.split("/")[0] positives = detection.split("/")[0]
# if int(positives) > 0: if int(positives) > 0:
# row.append(Text(detection, "red bold")) row.append(Text(detection, "red bold"))
# else: else:
# row.append(detection) row.append(detection)
# else: else:
# row.append("not found") row.append("not found")
# table.add_row(*row) table.add_row(*row)
# console = Console() console = Console()
# console.print(table) console.print(table)
@staticmethod @staticmethod
def parse_package_for_details(output: str) -> dict: def parse_package_for_details(output: str) -> dict:

View File

@@ -9,7 +9,6 @@ from typing import Optional
from mvt.android.artifacts.processes import Processes as ProcessesArtifact from mvt.android.artifacts.processes import Processes as ProcessesArtifact
from .base import AndroidExtraction from .base import AndroidExtraction
from mvt.common.module_types import ModuleResults
class Processes(ProcessesArtifact, AndroidExtraction): class Processes(ProcessesArtifact, AndroidExtraction):
@@ -22,7 +21,7 @@ class Processes(ProcessesArtifact, AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -6,8 +6,6 @@
import logging import logging
from typing import Optional from typing import Optional
from mvt.common.module_types import ModuleResults
from .base import AndroidExtraction from .base import AndroidExtraction
@@ -21,7 +19,7 @@ class RootBinaries(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -34,11 +32,8 @@ class RootBinaries(AndroidExtraction):
def check_indicators(self) -> None: def check_indicators(self) -> None:
for root_binary in self.results: for root_binary in self.results:
self.alertstore.high( self.detected.append(root_binary)
f'Found root binary "{root_binary}"', self.log.warning('Found root binary "%s"', root_binary)
"",
root_binary,
)
def run(self) -> None: def run(self) -> None:
root_binaries = [ root_binaries = [

View File

@@ -6,8 +6,6 @@
import logging import logging
from typing import Optional from typing import Optional
from mvt.common.module_types import ModuleResults
from .base import AndroidExtraction from .base import AndroidExtraction
@@ -23,7 +21,7 @@ class SELinuxStatus(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -34,7 +32,7 @@ class SELinuxStatus(AndroidExtraction):
results=results, results=results,
) )
self.results: dict = {} self.results = {} if not results else results
def run(self) -> None: def run(self) -> None:
self._adb_connect() self._adb_connect()

View File

@@ -7,7 +7,6 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.settings import Settings as SettingsArtifact from mvt.android.artifacts.settings import Settings as SettingsArtifact
from mvt.common.module_types import ModuleResults
from .base import AndroidExtraction from .base import AndroidExtraction
@@ -22,7 +21,7 @@ class Settings(SettingsArtifact, AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -6,15 +6,10 @@
import logging import logging
import os import os
import sqlite3 import sqlite3
from typing import Optional from typing import Optional, Union
from mvt.android.parsers.backup import AndroidBackupParsingError, parse_tar_for_sms from mvt.android.parsers.backup import AndroidBackupParsingError, parse_tar_for_sms
from mvt.common.module import InsufficientPrivileges from mvt.common.module import InsufficientPrivileges
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleResults,
ModuleSerializedResult,
)
from mvt.common.utils import check_for_links, convert_unix_to_iso from mvt.common.utils import check_for_links, convert_unix_to_iso
from .base import AndroidExtraction from .base import AndroidExtraction
@@ -56,7 +51,7 @@ class SMS(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -69,7 +64,7 @@ class SMS(AndroidExtraction):
self.sms_db_type = 0 self.sms_db_type = 0
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
body = record["body"].replace("\n", "\\n") body = record["body"].replace("\n", "\\n")
return { return {
"timestamp": record["isodate"], "timestamp": record["isodate"],
@@ -90,11 +85,9 @@ class SMS(AndroidExtraction):
if message_links == []: if message_links == []:
message_links = check_for_links(message["body"]) message_links = check_for_links(message["body"])
ioc_match = self.indicators.check_urls(message_links) if self.indicators.check_urls(message_links):
if ioc_match: self.detected.append(message)
self.alertstore.critical( continue
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
def _parse_db(self, db_path: str) -> None: def _parse_db(self, db_path: str) -> None:
"""Parse an Android bugle_db SMS database file. """Parse an Android bugle_db SMS database file.

View File

@@ -7,13 +7,8 @@ import base64
import logging import logging
import os import os
import sqlite3 import sqlite3
from typing import Optional from typing import Optional, Union
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleResults,
ModuleSerializedResult,
)
from mvt.common.utils import check_for_links, convert_unix_to_iso from mvt.common.utils import check_for_links, convert_unix_to_iso
from .base import AndroidExtraction from .base import AndroidExtraction
@@ -31,7 +26,7 @@ class Whatsapp(AndroidExtraction):
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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -42,7 +37,7 @@ class Whatsapp(AndroidExtraction):
results=results, results=results,
) )
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
text = record["data"].replace("\n", "\\n") text = record["data"].replace("\n", "\\n")
return { return {
"timestamp": record["isodate"], "timestamp": record["isodate"],
@@ -60,11 +55,8 @@ class Whatsapp(AndroidExtraction):
continue continue
message_links = check_for_links(message["data"]) message_links = check_for_links(message["data"])
ioc_match = self.indicators.check_urls(message_links) if self.indicators.check_urls(message_links):
if ioc_match: self.detected.append(message)
self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
continue continue
def _parse_db(self, db_path: str) -> None: def _parse_db(self, db_path: str) -> None:

View File

@@ -3,22 +3,38 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .aqf_files import AQFFiles from .dumpsys_accessibility import DumpsysAccessibility
from .aqf_getprop import AQFGetProp from .dumpsys_activities import DumpsysActivities
from .aqf_packages import AQFPackages from .dumpsys_appops import DumpsysAppops
from .aqf_processes import AQFProcesses from .dumpsys_battery_daily import DumpsysBatteryDaily
from .aqf_settings import AQFSettings from .dumpsys_battery_history import DumpsysBatteryHistory
from .mounts import Mounts from .dumpsys_dbinfo import DumpsysDBInfo
from .root_binaries import RootBinaries from .dumpsys_packages import DumpsysPackages
from .dumpsys_receivers import DumpsysReceivers
from .dumpsys_adb import DumpsysADBState
from .getprop import Getprop
from .packages import Packages
from .dumpsys_platform_compat import DumpsysPlatformCompat
from .processes import Processes
from .settings import Settings
from .sms import SMS from .sms import SMS
from .files import Files
ANDROIDQF_MODULES = [ ANDROIDQF_MODULES = [
AQFPackages, DumpsysActivities,
AQFProcesses, DumpsysReceivers,
AQFGetProp, DumpsysAccessibility,
AQFSettings, DumpsysAppops,
AQFFiles, DumpsysDBInfo,
DumpsysBatteryDaily,
DumpsysBatteryHistory,
DumpsysADBState,
Packages,
DumpsysPlatformCompat,
Processes,
Getprop,
Settings,
SMS, SMS,
RootBinaries, DumpsysPackages,
Mounts, Files,
] ]

View File

@@ -1,146 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import json
import logging
from typing import Optional
from mvt.android.utils import (
BROWSER_INSTALLERS,
PLAY_STORE_INSTALLERS,
ROOT_PACKAGES,
SECURITY_PACKAGES,
SYSTEM_UPDATE_PACKAGES,
THIRD_PARTY_STORE_INSTALLERS,
)
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule
class AQFPackages(AndroidQFModule):
"""This module examines the installed packages in packages.json"""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
def check_indicators(self) -> None:
for result in self.results:
if result["name"] in ROOT_PACKAGES:
self.alertstore.medium(
f'Found an installed package related to rooting/jailbreaking: "{result["name"]}"',
"",
result,
)
self.alertstore.log_latest()
continue
# Detections for apps installed via unusual methods.
if result["installer"] in THIRD_PARTY_STORE_INSTALLERS:
self.alertstore.info(
f'Found a package installed via a third party store (installer="{result["installer"]}"): "{result["name"]}"',
"",
result,
)
self.alertstore.log_latest()
elif result["installer"] in BROWSER_INSTALLERS:
self.alertstore.medium(
f'Found a package installed via a browser (installer="{result["installer"]}"): "{result["name"]}"',
"",
result,
)
self.alertstore.log_latest()
elif result["installer"] == "null" and result["system"] is False:
self.alertstore.high(
f'Found a non-system package installed via adb or another method: "{result["name"]}"',
"",
result,
)
self.alertstore.log_latest()
elif result["installer"] in PLAY_STORE_INSTALLERS:
pass
# Check for disabled security or software update packages.
package_disabled = result.get("disabled", None)
if result["name"] in SECURITY_PACKAGES and package_disabled:
self.alertstore.high(
f'Security package "{result["name"]}" disabled on the phone',
"",
result,
)
self.alertstore.log_latest()
if result["name"] in SYSTEM_UPDATE_PACKAGES and package_disabled:
self.alertstore.high(
f'System OTA update package "{result["name"]}" disabled on the phone',
"",
result,
)
self.alertstore.log_latest()
if not self.indicators:
continue
ioc_match = self.indicators.check_app_id(result.get("name") or "")
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
self.alertstore.log_latest()
for package_file in result.get("files", []):
ioc_match = self.indicators.check_file_hash(
package_file.get("sha256") or ""
)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
self.alertstore.log_latest()
if "certificate" not in package_file:
continue
# The keys generated by AndroidQF have a leading uppercase character.
for hash_type in ["Md5", "Sha1", "Sha256"]:
certificate_hash = package_file["certificate"][hash_type]
ioc_match = self.indicators.check_app_certificate_hash(
certificate_hash
)
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
result,
matched_indicator=ioc_match.ioc,
)
self.alertstore.log_latest()
break
def run(self) -> None:
packages = self._get_files_by_pattern("*/packages.json")
if not packages:
self.log.error(
"packages.json file not found in this androidqf bundle. Possibly malformed?"
)
return
self.results = json.loads(self._get_file_content(packages[0]))
self.log.info("Found %d packages in packages.json", len(self.results))

View File

@@ -7,10 +7,9 @@ import fnmatch
import logging import logging
import os import os
import zipfile import zipfile
from typing import List, Optional from typing import Any, Dict, List, Optional, Union
from mvt.common.module import MVTModule from mvt.common.module import MVTModule
from mvt.common.module_types import ModuleResults
class AndroidQFModule(MVTModule): class AndroidQFModule(MVTModule):
@@ -23,7 +22,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: Union[List[Dict[str, Any]], Dict[str, Any], None] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -33,16 +32,16 @@ class AndroidQFModule(MVTModule):
log=log, log=log,
results=results, results=results,
) )
self.parent_path: Optional[str] = None self.parent_path = None
self._path: Optional[str] = target_path self._path: str = target_path
self.files: List[str] = [] self.files: List[str] = []
self.archive: Optional[zipfile.ZipFile] = None self.archive: Optional[zipfile.ZipFile] = None
def from_dir(self, parent_path: str, files: List[str]) -> None: def from_folder(self, parent_path: str, files: List[str]):
self.parent_path = parent_path self.parent_path = parent_path
self.files = files self.files = files
def from_zip(self, archive: zipfile.ZipFile, files: List[str]) -> None: def from_zip_file(self, archive: zipfile.ZipFile, files: List[str]):
self.archive = archive self.archive = archive
self.files = files self.files = files

View File

@@ -0,0 +1,51 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
from .base import AndroidQFModule
class DumpsysAccessibility(DumpsysAccessibilityArtifact, AndroidQFModule):
"""This module analyses dumpsys accessibility"""
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 run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
data = self._get_file_content(dumpsys_file[0]).decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(data, "DUMP OF SERVICE accessibility:")
self.parse(content)
for result in self.results:
self.log.info(
'Found installed accessibility service "%s"', result.get("service")
)
self.log.info(
"Identified a total of %d accessibility services", len(self.results)
)

View File

@@ -0,0 +1,50 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_package_activities import (
DumpsysPackageActivitiesArtifact,
)
from .base import AndroidQFModule
class DumpsysActivities(DumpsysPackageActivitiesArtifact, AndroidQFModule):
"""This module extracts details on receivers for risky activities."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results = results if results else []
def run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
# Get data and extract the dumpsys section
data = self._get_file_content(dumpsys_file[0]).decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(data, "DUMP OF SERVICE package:")
# Parse it
self.parse(content)
self.log.info("Extracted %d package activities", len(self.results))

View File

@@ -0,0 +1,51 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
from .base import AndroidQFModule
class DumpsysADBState(DumpsysADBArtifact, AndroidQFModule):
"""This module extracts ADB keystore state."""
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 run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
full_dumpsys = self._get_file_content(dumpsys_file[0])
content = self.extract_dumpsys_section(
full_dumpsys,
b"DUMP OF SERVICE adb:",
binary=True,
)
self.parse(content)
if self.results:
self.log.info(
"Identified a total of %d trusted ADB keys",
len(self.results[0].get("user_keys", [])),
)

View File

@@ -0,0 +1,46 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_appops import DumpsysAppopsArtifact
from .base import AndroidQFModule
class DumpsysAppops(DumpsysAppopsArtifact, AndroidQFModule):
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 run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
# Extract section
data = self._get_file_content(dumpsys_file[0])
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE appops:"
)
# Parse it
self.parse(section)
self.log.info("Identified %d applications in AppOps Manager", len(self.results))

View File

@@ -0,0 +1,46 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_battery_daily import DumpsysBatteryDailyArtifact
from .base import AndroidQFModule
class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, AndroidQFModule):
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 run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
# Extract section
data = self._get_file_content(dumpsys_file[0])
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
)
# Parse it
self.parse(section)
self.log.info("Extracted a total of %d battery daily stats", len(self.results))

View File

@@ -0,0 +1,46 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_battery_history import DumpsysBatteryHistoryArtifact
from .base import AndroidQFModule
class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, AndroidQFModule):
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 run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
# Extract section
data = self._get_file_content(dumpsys_file[0])
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
)
# Parse it
self.parse(section)
self.log.info("Extracted a total of %d battery daily stats", len(self.results))

View File

@@ -0,0 +1,46 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_dbinfo import DumpsysDBInfoArtifact
from .base import AndroidQFModule
class DumpsysDBInfo(DumpsysDBInfoArtifact, AndroidQFModule):
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 run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
# Extract dumpsys DBInfo section
data = self._get_file_content(dumpsys_file[0])
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE dbinfo:"
)
# Parse it
self.parse(section)
self.log.info("Identified %d DB Info entries", len(self.results))

View File

@@ -0,0 +1,62 @@
# 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, Dict, List, Optional
from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact
from mvt.android.modules.adb.packages import (
DANGEROUS_PERMISSIONS,
DANGEROUS_PERMISSIONS_THRESHOLD,
)
from .base import AndroidQFModule
class DumpsysPackages(DumpsysPackagesArtifact, AndroidQFModule):
"""This module analyse dumpsys packages"""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[List[Dict[str, Any]]] = 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 run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if len(dumpsys_file) != 1:
self.log.info("Dumpsys file not found")
return
data = self._get_file_content(dumpsys_file[0]).decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(data, "DUMP OF SERVICE package:")
self.parse(content)
for result in self.results:
dangerous_permissions_count = 0
for perm in result["permissions"]:
if perm["name"] in DANGEROUS_PERMISSIONS:
dangerous_permissions_count += 1
if dangerous_permissions_count >= DANGEROUS_PERMISSIONS_THRESHOLD:
self.log.info(
'Found package "%s" requested %d potentially dangerous permissions',
result["package_name"],
dangerous_permissions_count,
)
self.log.info("Extracted details on %d packages", len(self.results))

View File

@@ -0,0 +1,44 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_platform_compat import DumpsysPlatformCompatArtifact
from .base import AndroidQFModule
class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, AndroidQFModule):
"""This module extracts details on uninstalled apps."""
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 run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
data = self._get_file_content(dumpsys_file[0]).decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(data, "DUMP OF SERVICE platform_compat:")
self.parse(content)
self.log.info("Found %d uninstalled apps", len(self.results))

View File

@@ -0,0 +1,49 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Any, Dict, List, Optional, Union
from mvt.android.artifacts.dumpsys_receivers import DumpsysReceiversArtifact
from .base import AndroidQFModule
class DumpsysReceivers(DumpsysReceiversArtifact, AndroidQFModule):
"""This module analyse dumpsys receivers"""
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: Union[List[Any], Dict[str, Any], None] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results = results if results else {}
def run(self) -> None:
dumpsys_file = self._get_files_by_pattern("*/dumpsys.txt")
if not dumpsys_file:
return
data = self._get_file_content(dumpsys_file[0])
dumpsys_section = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
)
self.parse(dumpsys_section)
self.log.info("Extracted receivers for %d intents", len(self.results))

View File

@@ -10,15 +10,10 @@ import logging
try: try:
import zoneinfo import zoneinfo
except ImportError: except ImportError:
from backports import zoneinfo # type: ignore from backports import zoneinfo
from typing import Optional from typing import Optional, Union
from mvt.android.modules.androidqf.base import AndroidQFModule from mvt.android.modules.androidqf.base import AndroidQFModule
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleResults,
ModuleSerializedResult,
)
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
SUSPICIOUS_PATHS = [ SUSPICIOUS_PATHS = [
@@ -26,13 +21,8 @@ SUSPICIOUS_PATHS = [
] ]
class AQFFiles(AndroidQFModule): class Files(AndroidQFModule):
""" """This module analyse list of files"""
This module analyzes the files.json dump generated by AndroidQF.
The format needs to be kept in sync with the AndroidQF module code.
https://github.com/mvt-project/androidqf/blob/main/android-collector/cmd/find.go#L28
"""
def __init__( def __init__(
self, self,
@@ -41,7 +31,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -52,7 +42,7 @@ class AQFFiles(AndroidQFModule):
results=results, results=results,
) )
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: dict) -> Union[dict, list]:
records = [] records = []
for ts in set( for ts in set(
@@ -87,12 +77,10 @@ class AQFFiles(AndroidQFModule):
return return
for result in self.results: for result in self.results:
ioc_match = self.indicators.check_file_path(result["path"]) ioc = self.indicators.check_file_path(result["path"])
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
self.alertstore.log_latest()
continue continue
# NOTE: Update with final path used for Android collector. # NOTE: Update with final path used for Android collector.
@@ -105,18 +93,20 @@ class AQFFiles(AndroidQFModule):
if self.file_is_executable(result["mode"]): if self.file_is_executable(result["mode"]):
file_type = "executable " file_type = "executable "
msg = f'Found {file_type}file at suspicious path "{result["path"]}"' self.log.warning(
self.alertstore.high(msg, "", result) 'Found %sfile at suspicious path "%s".',
self.alertstore.log_latest() file_type,
result["path"],
)
self.detected.append(result)
if result.get("sha256", "") == "": if result.get("sha256", "") == "":
continue continue
ioc_match = self.indicators.check_file_hash(result.get("sha256") or "") ioc = self.indicators.check_file_hash(result["sha256"])
if ioc_match: if ioc:
self.alertstore.critical( result["matched_indicator"] = ioc
ioc_match.message, "", result, matched_indicator=ioc_match.ioc self.detected.append(result)
)
# TODO: adds SHA1 and MD5 when available in MVT # TODO: adds SHA1 and MD5 when available in MVT

View File

@@ -7,12 +7,11 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.getprop import GetProp as GetPropArtifact from mvt.android.artifacts.getprop import GetProp as GetPropArtifact
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule from .base import AndroidQFModule
class AQFGetProp(GetPropArtifact, AndroidQFModule): class Getprop(GetPropArtifact, AndroidQFModule):
"""This module extracts data from get properties.""" """This module extracts data from get properties."""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,7 +31,7 @@ class AQFGetProp(GetPropArtifact, AndroidQFModule):
log=log, log=log,
results=results, results=results,
) )
self.results: list = [] self.results = []
def run(self) -> None: def run(self) -> None:
getprop_files = self._get_files_by_pattern("*/getprop.txt") getprop_files = self._get_files_by_pattern("*/getprop.txt")

View File

@@ -3,22 +3,20 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import os
import datetime import datetime
import logging import logging
import os
from typing import Optional from typing import Optional
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
from mvt.common.module_types import ModuleResults
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
from .base import AndroidQFModule from .base import AndroidQFModule
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule): class LogsFileTimestamps(FileTimestampsArtifact, AndroidQFModule):
"""This module creates timeline for log files extracted by AQF.""" """This module extracts records from battery daily updates."""
slug = "aqf_log_timestamps" slug = "logfile_timestamps"
def __init__( def __init__(
self, self,
@@ -27,7 +25,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -38,13 +36,11 @@ class AQFLogTimestamps(FileTimestampsArtifact, AndroidQFModule):
results=results, results=results,
) )
def _get_file_modification_time(self, file_path: str) -> datetime.datetime: def _get_file_modification_time(self, file_path: str) -> dict:
if self.archive: if self.archive:
file_timetuple = self.archive.getinfo(file_path).date_time file_timetuple = self.archive.getinfo(file_path).date_time
return datetime.datetime(*file_timetuple) return datetime.datetime(*file_timetuple)
else: else:
if not self.parent_path:
raise ValueError("parent_path is not set")
file_stat = os.stat(os.path.join(self.parent_path, file_path)) file_stat = os.stat(os.path.join(self.parent_path, file_path))
return datetime.datetime.fromtimestamp(file_stat.st_mtime) return datetime.datetime.fromtimestamp(file_stat.st_mtime)

View File

@@ -1,74 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import json
import logging
from typing import Optional
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
from .base import AndroidQFModule
class Mounts(MountsArtifact, AndroidQFModule):
"""This module extracts and analyzes mount information from AndroidQF acquisitions."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results: list = []
def run(self) -> None:
"""
Run the mounts analysis module.
This module looks for mount information files collected by androidqf
and analyzes them for suspicious configurations, particularly focusing
on detecting root access indicators like /system mounted as read-write.
"""
mount_files = self._get_files_by_pattern("*/mounts.json")
if not mount_files:
self.log.info("No mount information file found")
return
self.log.info("Found mount information file: %s", mount_files[0])
try:
data = self._get_file_content(mount_files[0]).decode(
"utf-8", errors="replace"
)
except Exception as exc:
self.log.error("Failed to read mount information file: %s", exc)
return
# Parse the mount data
try:
json_data = json.loads(data)
if isinstance(json_data, list):
# AndroidQF format: array of strings like
# "/dev/block/dm-12 on / type ext4 (ro,seclabel,noatime)"
mount_content = "\n".join(json_data)
self.parse(mount_content)
except Exception as exc:
self.log.error("Failed to parse mount information: %s", exc)
return
self.log.info("Extracted a total of %d mount entries", len(self.results))

View File

@@ -0,0 +1,128 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import json
import logging
from typing import Optional
from mvt.android.utils import (
BROWSER_INSTALLERS,
PLAY_STORE_INSTALLERS,
ROOT_PACKAGES,
THIRD_PARTY_STORE_INSTALLERS,
SECURITY_PACKAGES,
SYSTEM_UPDATE_PACKAGES,
)
from .base import AndroidQFModule
class Packages(AndroidQFModule):
"""This module examines the installed packages in packages.json"""
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:
for result in self.results:
if result["name"] in ROOT_PACKAGES:
self.log.warning(
'Found an installed package related to rooting/jailbreaking: "%s"',
result["name"],
)
self.detected.append(result)
continue
# Detections for apps installed via unusual methods
if result["installer"] in THIRD_PARTY_STORE_INSTALLERS:
self.log.warning(
'Found a package installed via a third party store (installer="%s"): "%s"',
result["installer"],
result["name"],
)
elif result["installer"] in BROWSER_INSTALLERS:
self.log.warning(
'Found a package installed via a browser (installer="%s"): "%s"',
result["installer"],
result["name"],
)
self.detected.append(result)
elif result["installer"] == "null" and result["system"] is False:
self.log.warning(
'Found a non-system package installed via adb or another method: "%s"',
result["name"],
)
self.detected.append(result)
elif result["installer"] in PLAY_STORE_INSTALLERS:
pass
# Check for disabled security or software update packages
package_disabled = result.get("disabled", None)
if result["name"] in SECURITY_PACKAGES and package_disabled:
self.log.warning(
'Security package "%s" disabled on the phone', result["name"]
)
if result["name"] in SYSTEM_UPDATE_PACKAGES and package_disabled:
self.log.warning(
'System OTA update package "%s" disabled on the phone',
result["name"],
)
if not self.indicators:
continue
ioc = self.indicators.check_app_id(result.get("name"))
if ioc:
result["matched_indicator"] = ioc
self.detected.append(result)
for package_file in result.get("files", []):
ioc = self.indicators.check_file_hash(package_file["sha256"])
if ioc:
result["matched_indicator"] = ioc
self.detected.append(result)
if "certificate" not in package_file:
continue
# The keys generated by AndroidQF have a leading uppercase character
for hash_type in ["Md5", "Sha1", "Sha256"]:
certificate_hash = package_file["certificate"][hash_type]
ioc = self.indicators.check_app_certificate_hash(certificate_hash)
if ioc:
result["matched_indicator"] = ioc
self.detected.append(result)
break
# Deduplicate the detected packages
dedupe_detected_dict = {str(item): item for item in self.detected}
self.detected = list(dedupe_detected_dict.values())
def run(self) -> None:
packages = self._get_files_by_pattern("*/packages.json")
if not packages:
self.log.error(
"packages.json file not found in this androidqf bundle. Possibly malformed?"
)
return
self.results = json.loads(self._get_file_content(packages[0]))
self.log.info("Found %d packages in packages.json", len(self.results))

View File

@@ -9,10 +9,9 @@ from typing import Optional
from mvt.android.artifacts.processes import Processes as ProcessesArtifact from mvt.android.artifacts.processes import Processes as ProcessesArtifact
from .base import AndroidQFModule from .base import AndroidQFModule
from mvt.common.module_types import ModuleResults
class AQFProcesses(ProcessesArtifact, AndroidQFModule): class Processes(ProcessesArtifact, AndroidQFModule):
"""This module analyse running processes""" """This module analyse running processes"""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -1,121 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import json
import logging
from typing import Optional
from .base import AndroidQFModule
class RootBinaries(AndroidQFModule):
"""This module analyzes root_binaries.json for root binaries found by androidqf."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
def serialize(self, record: dict) -> dict:
return {
"timestamp": record.get("timestamp"),
"module": self.__class__.__name__,
"event": "root_binary_found",
"data": f"Root binary found: {record['path']} (binary: {record['binary_name']})",
}
def check_indicators(self) -> None:
"""Check for indicators of device rooting."""
if not self.results:
return
# All found root binaries are considered indicators of rooting
for result in self.results:
self.alertstore.high(
f'Found root binary "{result["binary_name"]}" at path "{result["path"]}"',
"",
result,
)
self.alertstore.log_latest()
if self.results:
self.log.warning(
"Device shows signs of rooting with %d root binaries found",
len(self.results),
)
def run(self) -> None:
"""Run the root binaries analysis."""
root_binaries_files = self._get_files_by_pattern("*/root_binaries.json")
if not root_binaries_files:
self.log.info("No root_binaries.json file found")
return
rawdata = self._get_file_content(root_binaries_files[0]).decode(
"utf-8", errors="ignore"
)
try:
root_binary_paths = json.loads(rawdata)
except json.JSONDecodeError as e:
self.log.error("Failed to parse root_binaries.json: %s", e)
return
if not isinstance(root_binary_paths, list):
self.log.error("Expected root_binaries.json to contain a list of paths")
return
# Known root binary names that might be found and their descriptions
# This maps the binary name to a human-readable description
known_root_binaries = {
"su": "SuperUser binary",
"busybox": "BusyBox utilities",
"supersu": "SuperSU root management",
"Superuser.apk": "Superuser app",
"KingoUser.apk": "KingRoot app",
"SuperSu.apk": "SuperSU app",
"magisk": "Magisk root framework",
"magiskhide": "Magisk hide utility",
"magiskinit": "Magisk init binary",
"magiskpolicy": "Magisk policy binary",
}
for path in root_binary_paths:
if not path or not isinstance(path, str):
continue
# Extract binary name from path
binary_name = path.split("/")[-1].lower()
# Check if this matches a known root binary by exact name match
description = "Unknown root binary"
for known_binary in known_root_binaries:
if binary_name == known_binary.lower():
description = known_root_binaries[known_binary]
break
result = {
"path": path.strip(),
"binary_name": binary_name,
"description": description,
}
self.results.append(result)
self.log.info("Found %d root binaries", len(self.results))

View File

@@ -7,12 +7,11 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.settings import Settings as SettingsArtifact from mvt.android.artifacts.settings import Settings as SettingsArtifact
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule from .base import AndroidQFModule
class AQFSettings(SettingsArtifact, AndroidQFModule): class Settings(SettingsArtifact, AndroidQFModule):
"""This module analyse setting files""" """This module analyse setting files"""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,7 +31,7 @@ class AQFSettings(SettingsArtifact, AndroidQFModule):
log=log, log=log,
results=results, results=results,
) )
self.results: dict = {} self.results = {}
def run(self) -> None: def run(self) -> None:
for setting_file in self._get_files_by_pattern("*/settings_*.txt"): for setting_file in self._get_files_by_pattern("*/settings_*.txt"):

View File

@@ -19,13 +19,7 @@ from .base import AndroidQFModule
class SMS(AndroidQFModule): class SMS(AndroidQFModule):
""" """This module analyse SMS file in backup"""
This module analyse SMS file in backup
XXX: We should also de-duplicate this AQF module, but first we
need to add tests for loading encrypted SMS backups through the backup
sub-module.
"""
def __init__( def __init__(
self, self,
@@ -53,11 +47,8 @@ class SMS(AndroidQFModule):
if "body" not in message: if "body" not in message:
continue continue
ioc_match = self.indicators.check_domains(message.get("links", [])) if self.indicators.check_domains(message.get("links", [])):
if ioc_match: self.detected.append(message)
self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
def parse_backup(self, data): def parse_backup(self, data):
header = parse_ab_header(data) header = parse_ab_header(data)

View File

@@ -9,10 +9,10 @@ import os
from tarfile import TarFile from tarfile import TarFile
from typing import List, Optional from typing import List, Optional
from mvt.common.module import ModuleResults, MVTModule from mvt.common.module import MVTModule
class BackupModule(MVTModule): class BackupExtraction(MVTModule):
"""This class provides a base for all backup extractios modules""" """This class provides a base for all backup extractios modules"""
def __init__( def __init__(
@@ -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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -32,12 +32,15 @@ class BackupModule(MVTModule):
log=log, log=log,
results=results, results=results,
) )
self.ab: Optional[str] = None self.ab = None
self.backup_path: Optional[str] = None self.backup_path = None
self.tar: Optional[TarFile] = None self.tar = None
self.files: list = [] self.files = []
def from_dir(self, backup_path: Optional[str], files: List[str]) -> None: def from_folder(self, backup_path: Optional[str], files: List[str]) -> None:
"""
Get all the files and list them
"""
self.backup_path = backup_path self.backup_path = backup_path
self.files = files self.files = files
@@ -55,19 +58,14 @@ class BackupModule(MVTModule):
return fnmatch.filter(self.files, pattern) return fnmatch.filter(self.files, pattern)
def _get_file_content(self, file_path: str) -> bytes: def _get_file_content(self, file_path: str) -> bytes:
handle = None if self.ab:
if self.tar:
try: try:
member = self.tar.getmember(file_path) member = self.tar.getmember(file_path)
handle = self.tar.extractfile(member)
if not handle:
raise ValueError(f"Could not extract file: {file_path}")
except KeyError: except KeyError:
raise FileNotFoundError(f"File not found in tar: {file_path}") return None
elif self.backup_path: handle = self.tar.extractfile(member)
handle = open(os.path.join(self.backup_path, file_path), "rb")
else: else:
raise ValueError("No backup path or tar file provided") handle = open(os.path.join(self.backup_path, file_path), "rb")
data = handle.read() data = handle.read()
handle.close() handle.close()

View File

@@ -4,15 +4,14 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging import logging
from typing import Any, Optional from typing import Optional
from mvt.android.modules.backup.base import BackupModule from mvt.android.modules.backup.base import BackupExtraction
from mvt.android.parsers.backup import parse_sms_file from mvt.android.parsers.backup import parse_sms_file
from mvt.common.module_types import ModuleResults
from mvt.common.utils import check_for_links from mvt.common.utils import check_for_links
class SMS(BackupModule): class SMS(BackupExtraction):
def __init__( def __init__(
self, self,
file_path: Optional[str] = None, file_path: Optional[str] = None,
@@ -20,7 +19,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -30,7 +29,7 @@ class SMS(BackupModule):
log=log, log=log,
results=results, results=results,
) )
self.results: list[dict[str, Any]] = [] self.results = []
def check_indicators(self) -> None: def check_indicators(self) -> None:
if not self.indicators: if not self.indicators:
@@ -44,23 +43,20 @@ 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) if self.indicators.check_urls(message_links):
if ioc_match: self.detected.append(message)
self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
continue continue
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"
for file in self._get_files_by_pattern(sms_path): for file in self._get_files_by_pattern(sms_path):
self.log.debug("Processing SMS backup file at %s", file) self.log.info("Processing SMS backup file at %s", file)
data = self._get_file_content(file) data = self._get_file_content(file)
self.results.extend(parse_sms_file(data)) self.results.extend(parse_sms_file(data))
mms_path = "apps/com.android.providers.telephony/d_f/*_mms_backup" mms_path = "apps/com.android.providers.telephony/d_f/*_mms_backup"
for file in self._get_files_by_pattern(mms_path): for file in self._get_files_by_pattern(mms_path):
self.log.debug("Processing MMS backup file at %s", file) self.log.info("Processing MMS backup file at %s", file)
data = self._get_file_content(file) data = self._get_file_content(file)
self.results.extend(parse_sms_file(data)) self.results.extend(parse_sms_file(data))

View File

@@ -3,31 +3,31 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .dumpsys_accessibility import DumpsysAccessibility from .accessibility import Accessibility
from .dumpsys_activities import DumpsysActivities from .activities import Activities
from .dumpsys_appops import DumpsysAppops from .appops import Appops
from .dumpsys_battery_daily import DumpsysBatteryDaily from .battery_daily import BatteryDaily
from .dumpsys_battery_history import DumpsysBatteryHistory from .battery_history import BatteryHistory
from .dumpsys_dbinfo import DumpsysDBInfo from .dbinfo import DBInfo
from .dumpsys_getprop import DumpsysGetProp from .getprop import Getprop
from .dumpsys_packages import DumpsysPackages from .packages import Packages
from .dumpsys_platform_compat import DumpsysPlatformCompat from .platform_compat import PlatformCompat
from .dumpsys_receivers import DumpsysReceivers from .receivers import Receivers
from .dumpsys_adb_state import DumpsysADBState from .adb_state import DumpsysADBState
from .fs_timestamps import BugReportTimestamps from .fs_timestamps import BugReportTimestamps
from .tombstones import Tombstones from .tombstones import Tombstones
BUGREPORT_MODULES = [ BUGREPORT_MODULES = [
DumpsysAccessibility, Accessibility,
DumpsysActivities, Activities,
DumpsysAppops, Appops,
DumpsysBatteryDaily, BatteryDaily,
DumpsysBatteryHistory, BatteryHistory,
DumpsysDBInfo, DBInfo,
DumpsysGetProp, Getprop,
DumpsysPackages, Packages,
DumpsysPlatformCompat, PlatformCompat,
DumpsysReceivers, Receivers,
DumpsysADBState, DumpsysADBState,
BugReportTimestamps, BugReportTimestamps,
Tombstones, Tombstones,

View File

@@ -7,12 +7,11 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule): class Accessibility(DumpsysAccessibilityArtifact, BugReportModule):
"""This module extracts stats on accessibility.""" """This module extracts stats on accessibility."""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -9,12 +9,11 @@ from typing import Optional
from mvt.android.artifacts.dumpsys_package_activities import ( from mvt.android.artifacts.dumpsys_package_activities import (
DumpsysPackageActivitiesArtifact, DumpsysPackageActivitiesArtifact,
) )
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule): class Activities(DumpsysPackageActivitiesArtifact, BugReportModule):
"""This module extracts details on receivers for risky activities.""" """This module extracts details on receivers for risky activities."""
def __init__( def __init__(
@@ -24,7 +23,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -7,7 +7,6 @@ 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 .base import BugReportModule from .base import BugReportModule
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -7,12 +7,11 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.dumpsys_appops import DumpsysAppopsArtifact from mvt.android.artifacts.dumpsys_appops import DumpsysAppopsArtifact
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule): class Appops(DumpsysAppopsArtifact, BugReportModule):
"""This module extracts information on package from App-Ops Manager.""" """This module extracts information on package from App-Ops Manager."""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -6,10 +6,11 @@ import datetime
import fnmatch import fnmatch
import logging import logging
import os import os
from typing import List, Optional from typing import List, Optional
from zipfile import ZipFile from zipfile import ZipFile
from mvt.common.module import ModuleResults, MVTModule from mvt.common.module import MVTModule
class BugReportModule(MVTModule): class BugReportModule(MVTModule):
@@ -22,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -38,7 +39,9 @@ class BugReportModule(MVTModule):
self.extract_files: List[str] = [] self.extract_files: List[str] = []
self.zip_files: List[str] = [] self.zip_files: List[str] = []
def from_dir(self, extract_path: str, extract_files: List[str]) -> None: def from_folder(
self, extract_path: Optional[str], extract_files: List[str]
) -> None:
self.extract_path = extract_path self.extract_path = extract_path
self.extract_files = extract_files self.extract_files = extract_files
@@ -68,8 +71,6 @@ class BugReportModule(MVTModule):
if self.zip_archive: if self.zip_archive:
handle = self.zip_archive.open(file_path) handle = self.zip_archive.open(file_path)
else: else:
if not self.extract_path:
raise ValueError("extract_path is not set")
handle = open(os.path.join(self.extract_path, file_path), "rb") handle = open(os.path.join(self.extract_path, file_path), "rb")
data = handle.read() data = handle.read()
@@ -77,7 +78,7 @@ class BugReportModule(MVTModule):
return data return data
def _get_dumpstate_file(self) -> Optional[bytes]: def _get_dumpstate_file(self) -> bytes:
main = self._get_files_by_pattern("main_entry.txt") main = self._get_files_by_pattern("main_entry.txt")
if main: if main:
main_content = self._get_file_content(main[0]) main_content = self._get_file_content(main[0])
@@ -85,23 +86,17 @@ class BugReportModule(MVTModule):
return self._get_file_content(main_content.decode().strip()) return self._get_file_content(main_content.decode().strip())
except KeyError: except KeyError:
return None return None
else:
dumpstate_logs = self._get_files_by_pattern("dumpState_*.log")
if not dumpstate_logs:
return None
dumpstate_logs = self._get_files_by_pattern("dumpState_*.log")
if dumpstate_logs:
return self._get_file_content(dumpstate_logs[0]) return self._get_file_content(dumpstate_logs[0])
dumpsys_files = self._get_files_by_pattern("*/dumpsys.txt") def _get_file_modification_time(self, file_path: str) -> dict:
if dumpsys_files:
return self._get_file_content(dumpsys_files[0])
return None
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
if self.zip_archive: if self.zip_archive:
file_timetuple = self.zip_archive.getinfo(file_path).date_time file_timetuple = self.zip_archive.getinfo(file_path).date_time
return datetime.datetime(*file_timetuple) return datetime.datetime(*file_timetuple)
else: else:
if not self.extract_path:
raise ValueError("extract_path is not set")
file_stat = os.stat(os.path.join(self.extract_path, file_path)) file_stat = os.stat(os.path.join(self.extract_path, file_path))
return datetime.datetime.fromtimestamp(file_stat.st_mtime) return datetime.datetime.fromtimestamp(file_stat.st_mtime)

View File

@@ -7,12 +7,11 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.dumpsys_battery_daily import DumpsysBatteryDailyArtifact from mvt.android.artifacts.dumpsys_battery_daily import DumpsysBatteryDailyArtifact
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule): class BatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule):
"""This module extracts records from battery daily updates.""" """This module extracts records from battery daily updates."""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -7,12 +7,11 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.dumpsys_battery_history import DumpsysBatteryHistoryArtifact from mvt.android.artifacts.dumpsys_battery_history import DumpsysBatteryHistoryArtifact
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule): class BatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule):
"""This module extracts records from battery daily updates.""" """This module extracts records from battery daily updates."""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -7,12 +7,11 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.dumpsys_dbinfo import DumpsysDBInfoArtifact from mvt.android.artifacts.dumpsys_dbinfo import DumpsysDBInfoArtifact
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule): class DBInfo(DumpsysDBInfoArtifact, BugReportModule):
"""This module extracts records from battery daily updates.""" """This module extracts records from battery daily updates."""
slug = "dbinfo" slug = "dbinfo"
@@ -24,7 +23,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -1,71 +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.artifacts.dumpsys_receivers import DumpsysReceiversArtifact
from mvt.common.module_types import ModuleResults
from .base import BugReportModule
class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
"""This module extracts details on receivers for risky activities."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: ModuleResults = [],
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results = 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.results[result][0]["matched_indicator"] = ioc_match.ioc
self.alertstore.critical(
ioc_match.message,
"",
self.results[result][0],
matched_indicator=ioc_match.ioc,
)
continue
def run(self) -> None:
content = self._get_dumpstate_file()
if not content:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
dumpsys_section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
)
self.parse(dumpsys_section)
self.log.info("Extracted receivers for %d intents", len(self.results))

View File

@@ -8,7 +8,6 @@ from typing import Optional
from mvt.common.utils import convert_datetime_to_iso from mvt.common.utils import convert_datetime_to_iso
from .base import BugReportModule from .base import BugReportModule
from mvt.common.module_types import ModuleResults
from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact from mvt.android.artifacts.file_timestamps import FileTimestampsArtifact
@@ -24,7 +23,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -7,12 +7,11 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.getprop import GetProp as GetPropArtifact from mvt.android.artifacts.getprop import GetProp as GetPropArtifact
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
class DumpsysGetProp(GetPropArtifact, BugReportModule): class Getprop(GetPropArtifact, BugReportModule):
"""This module extracts device properties from getprop command.""" """This module extracts device properties from getprop command."""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -8,12 +8,11 @@ from typing import Optional
from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact from mvt.android.artifacts.dumpsys_packages import DumpsysPackagesArtifact
from mvt.android.utils import DANGEROUS_PERMISSIONS, DANGEROUS_PERMISSIONS_THRESHOLD from mvt.android.utils import DANGEROUS_PERMISSIONS, DANGEROUS_PERMISSIONS_THRESHOLD
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule): class Packages(DumpsysPackagesArtifact, BugReportModule):
"""This module extracts details on receivers for risky activities.""" """This module extracts details on receivers for risky activities."""
def __init__( def __init__(
@@ -23,7 +22,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,
@@ -43,9 +42,8 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
) )
return return
content = self.extract_dumpsys_section( data = data.decode("utf-8", errors="replace")
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:" content = self.extract_dumpsys_section(data, "DUMP OF SERVICE package:")
)
self.parse(content) self.parse(content)
for result in self.results: for result in self.results:

View File

@@ -9,10 +9,9 @@ from typing import Optional
from mvt.android.artifacts.dumpsys_platform_compat import DumpsysPlatformCompatArtifact from mvt.android.artifacts.dumpsys_platform_compat import DumpsysPlatformCompatArtifact
from mvt.android.modules.bugreport.base import BugReportModule from mvt.android.modules.bugreport.base import BugReportModule
from mvt.common.module_types import ModuleResults
class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule): class PlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
"""This module extracts details on uninstalled apps.""" """This module extracts details on uninstalled apps."""
def __init__( def __init__(
@@ -22,7 +21,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -0,0 +1,51 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_receivers import DumpsysReceiversArtifact
from .base import BugReportModule
class Receivers(DumpsysReceiversArtifact, BugReportModule):
"""This module extracts details on receivers for risky activities."""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results = results if results else {}
def run(self) -> None:
content = self._get_dumpstate_file()
if not content:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
dumpsys_section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
)
self.parse(dumpsys_section)
self.log.info("Extracted receivers for %d intents", len(self.results))

View File

@@ -7,7 +7,6 @@ import logging
from typing import Optional from typing import Optional
from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact
from mvt.common.module_types import ModuleResults
from .base import BugReportModule from .base import BugReportModule
@@ -23,7 +22,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[list] = None,
) -> None: ) -> None:
super().__init__( super().__init__(
file_path=file_path, file_path=file_path,

View File

@@ -231,7 +231,6 @@ def parse_sms_file(data):
entry.pop("mms_body") entry.pop("mms_body")
body = entry.get("body", None) body = entry.get("body", None)
message_links = None
if body: if body:
message_links = check_for_links(entry["body"]) message_links = check_for_links(entry["body"])

View File

@@ -6,15 +6,16 @@ from datetime import datetime, timedelta
from typing import List from typing import List
def warn_android_patch_level(patch_level: str, log) -> str | bool: def warn_android_patch_level(patch_level: str, log) -> bool:
"""Alert if Android patch level out-of-date""" """Alert if Android patch level out-of-date"""
patch_date = datetime.strptime(patch_level, "%Y-%m-%d") patch_date = datetime.strptime(patch_level, "%Y-%m-%d")
if (datetime.now() - patch_date) > timedelta(days=6 * 31): if (datetime.now() - patch_date) > timedelta(days=6 * 31):
warning_message = ( log.warning(
f"This phone has not received security updates " "This phone has not received security updates "
f"for more than six months (last update: {patch_level})." "for more than six months (last update: %s)",
patch_level,
) )
return warning_message return True
return False return False

View File

@@ -1,243 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2025 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 csv
import inspect
import logging
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Any, Dict, List, Optional
from .log import CRITICAL_ALERT, HIGH_ALERT, INFO_ALERT, LOW_ALERT, MEDIUM_ALERT
from .module_types import ModuleAtomicResult
class AlertLevel(Enum):
INFORMATIONAL = 0
LOW = 10
MEDIUM = 20
HIGH = 30
CRITICAL = 40
@dataclass
class Alert:
level: AlertLevel
module: str
message: str
event_time: str
event: ModuleAtomicResult
matched_indicator: Optional[Any] = None
class AlertStore:
def __init__(self, log: Optional[logging.Logger] = None) -> None:
self.__alerts: List[Alert] = []
self.__log = log
def _get_calling_module(self) -> str:
"""
Automatically detect the calling MVT module and return its slug.
Walks up the call stack to find the first frame that belongs to an MVT module
(artifact or extraction module) and extracts its slug.
:return: Module slug string
"""
frame = inspect.currentframe()
try:
# Walk up the call stack
while frame is not None:
frame = frame.f_back
if frame is None:
break
# Get the 'self' object from the frame's local variables
frame_locals = frame.f_locals
if "self" in frame_locals:
obj = frame_locals["self"]
# Check if it has a get_slug method (MVT modules have this)
if hasattr(obj, "get_slug") and callable(obj.get_slug):
try:
return str(obj.get_slug())
except Exception:
pass
# Fallback: return "unknown" if we can't find the module
return "unknown"
finally:
del frame
@property
def alerts(self) -> List[Alert]:
return self.__alerts
def add(self, alert: Alert) -> None:
self.__alerts.append(alert)
self.log(alert)
def extend(self, alerts: List[Alert]) -> None:
for alert in alerts:
self.add(alert)
def info(
self,
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
):
self.add(
Alert(
level=AlertLevel.INFORMATIONAL,
module=self._get_calling_module(),
message=message,
event_time=event_time,
event=event,
matched_indicator=matched_indicator,
)
)
def low(
self,
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
):
self.add(
Alert(
level=AlertLevel.LOW,
module=self._get_calling_module(),
message=message,
event_time=event_time,
event=event,
matched_indicator=matched_indicator,
)
)
def medium(
self,
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
):
self.add(
Alert(
level=AlertLevel.MEDIUM,
module=self._get_calling_module(),
message=message,
event_time=event_time,
event=event,
matched_indicator=matched_indicator,
)
)
def high(
self,
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
):
self.add(
Alert(
level=AlertLevel.HIGH,
module=self._get_calling_module(),
message=message,
event_time=event_time,
event=event,
matched_indicator=matched_indicator,
)
)
def critical(
self,
message: str,
event_time: str,
event: ModuleAtomicResult,
matched_indicator: Optional[Any] = None,
):
self.add(
Alert(
level=AlertLevel.CRITICAL,
module=self._get_calling_module(),
message=message,
event_time=event_time,
event=event,
matched_indicator=matched_indicator,
)
)
def log(self, alert: Alert) -> None:
if not self.__log:
return
if not alert.message:
return
if alert.level == AlertLevel.INFORMATIONAL:
self.__log.log(INFO_ALERT, alert.message)
elif alert.level == AlertLevel.LOW:
self.__log.log(LOW_ALERT, alert.message)
elif alert.level == AlertLevel.MEDIUM:
self.__log.log(MEDIUM_ALERT, alert.message)
elif alert.level == AlertLevel.HIGH:
self.__log.log(HIGH_ALERT, alert.message)
elif alert.level == AlertLevel.CRITICAL:
self.__log.log(CRITICAL_ALERT, alert.message)
def log_latest(self) -> None:
self.log(self.__alerts[-1])
def count(self, level: AlertLevel) -> int:
count = 0
for alert in self.__alerts:
if alert.level == level:
count += 1
return count
def as_json(self) -> List[Dict[str, Any]]:
alerts = []
for alert in self.__alerts:
alert_dict = asdict(alert)
# This is required because an Enum is not JSON serializable.
alert_dict["level"] = alert.level.name
alerts.append(alert_dict)
return alerts
def save_timeline(self, timeline_path: str) -> None:
with open(timeline_path, "a+", encoding="utf-8") as handle:
csvoutput = csv.writer(
handle,
delimiter=",",
quotechar='"',
quoting=csv.QUOTE_ALL,
escapechar="\\",
)
csvoutput.writerow(["Event Time", "Module", "Message", "Event"])
timed_alerts = []
for alert in self.alerts:
if not alert.event_time:
continue
timed_alerts.append(asdict(alert))
for event in sorted(
timed_alerts,
key=lambda x: x["event_time"] if x["event_time"] is not None else "",
):
csvoutput.writerow(
[
event.get("event_time"),
event.get("module"),
event.get("message"),
event.get("event"),
]
)

View File

@@ -2,11 +2,27 @@
# Copyright (c) 2021-2023 The MVT Authors. # Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
from .module import MVTModule
class Artifact(MVTModule): class Artifact:
"""Base class for artifacts.
XXX: Inheriting from MVTModule to have the same signature as other modules. Not sure if this is a good idea.
""" """
Main artifact class
"""
def __init__(self, *args, **kwargs):
self.results = []
self.detected = []
self.indicators = None
super().__init__(*args, **kwargs)
def parse(self, entry: str):
"""
Parse the artifact, adds the parsed information to self.results
"""
raise NotImplementedError
def check_indicators(self) -> None:
"""Check the results of this module against a provided list of
indicators coming from self.indicators
"""
raise NotImplementedError

Some files were not shown because too many files have changed in this diff Show More