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