Compare commits

..
34 changed files with 813 additions and 2283 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
name: Add issue to project name: Add issue to project
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/add-to-project@v2 - uses: actions/add-to-project@v0.5.0
with: with:
# You can target a project in a different organization # You can target a project in a different organization
# to the issue # to the issue
+3 -3
View File
@@ -11,13 +11,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v7 uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.10"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v10.0.0 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
- name: Install Dependencies - name: Install Dependencies
+6 -9
View File
@@ -36,10 +36,10 @@ jobs:
tag-suffix: "-android" tag-suffix: "-android"
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7 uses: actions/checkout@v6
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -47,7 +47,7 @@ jobs:
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: | flavor: |
@@ -59,24 +59,21 @@ jobs:
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }} type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
type=sha,suffix=${{ matrix.platform.tag-suffix }} type=sha,suffix=${{ matrix.platform.tag-suffix }}
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }} type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
# Register emulators so Buildx can build the ARM64 images on the AMD64 runner.
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
# This step sets up some additional capabilities to generate the provenance and sbom attestations # This step sets up some additional capabilities to generate the provenance and sbom attestations
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image - name: Build and push Docker image
id: push id: push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
file: ${{ matrix.platform.dockerfile }} file: ${{ matrix.platform.dockerfile }}
context: . context: .
platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max provenance: mode=max
sbom: true sbom: true
+3 -3
View File
@@ -12,13 +12,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: Setup Python - name: Setup Python
uses: actions/setup-python@v7 uses: actions/setup-python@v6
with: with:
python-version: "3.10" python-version: "3.10"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v10.0.0 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
- name: Install Dependencies - name: Install Dependencies
+3 -3
View File
@@ -15,13 +15,13 @@ jobs:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v10.0.0 uses: astral-sh/setup-uv@v7
with: with:
enable-cache: true enable-cache: true
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v7 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install Python dependencies - name: Install Python dependencies
+3 -5
View File
@@ -11,12 +11,10 @@ jobs:
update-ios-version: update-ios-version:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v3
- uses: actions/setup-python@v7 - uses: actions/setup-python@v4
- name: Install script dependencies
run: python -m pip install "packaging==26.3"
- name: Run script to fetch latest iOS releases from Apple RSS feed. - name: Run script to fetch latest iOS releases from Apple RSS feed.
run: python .github/workflows/scripts/update-ios-releases.py run: python3 .github/workflows/scripts/update-ios-releases.py
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v8 uses: peter-evans/create-pull-request@v8
with: with:
+2 -13
View File
@@ -119,23 +119,12 @@ ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y \ && apt-get install -y \
adb \ adb \
binutils \
default-jre-headless \ default-jre-headless \
file \
jq \
less \
libcurl4 \ libcurl4 \
libimage-exiftool-perl \
libssl3 \ libssl3 \
libusb-1.0-0 \ libusb-1.0-0 \
moreutils \
p7zip-full \
python3 \ python3 \
ripgrep \ sqlite3
sqlite3 \
tree \
unzip \
xxd
COPY --from=build-libplist /build / COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build / COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libtatsu /build / COPY --from=build-libtatsu /build /
@@ -155,7 +144,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2
RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc
# Generate adb key folder # Generate adb key folder
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc
RUN mkdir /root/.android RUN mkdir /root/.android
# Setup investigations environment # Setup investigations environment
+1 -1
View File
@@ -31,7 +31,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2
RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc
# Generate adb key folder # Generate adb key folder
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc
RUN mkdir /root/.android RUN mkdir /root/.android
ENTRYPOINT [ "/usr/local/bin/mvt-android" ] ENTRYPOINT [ "/usr/local/bin/mvt-android" ]
+1 -15
View File
@@ -31,18 +31,4 @@ Test if the image was created successfully:
docker run -it mvt docker run -it mvt
``` ```
If a prompt is spawned successfully, you can close it with `exit`. If a prompt is spawned successfully, you can close it with `exit`.
## Access an iOS device from Docker
On the Linux host, install and start [usbmuxd](https://github.com/libimobiledevice/usbmuxd), then connect and unlock the iOS device. The daemon exposes the device through the `/var/run/usbmuxd` socket.
Bind that socket into the container to let MVT communicate with the device:
```bash
docker run -it \
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
ghcr.io/mvt-project/mvt
```
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
+1 -15
View File
@@ -417,21 +417,7 @@ If indicators are provided through the command-line, they are checked against th
Backup: :material-check: Backup: :material-check:
Full filesystem dump: :material-check: Full filesystem dump: :material-check:
This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*, along with one record per chat session (marked with `"record_type": "chat_session"`) containing the first and last interaction dates of each conversation. Chat sessions produce `chat_first_message` and `chat_last_message` timeline events, and group chats additionally produce a `group_created` event. A chat session's last-message date can postdate its newest stored message when the most recent messages in the chat were deleted. This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*.
Recent WhatsApp versions key 1:1 chat sessions by an opaque LID identifier rather than the contact's phone number. The module resolves these using the `ZWAPHONENUMBERLIDPAIR` table from the *LID.sqlite* database in the same app group (or from *ChatStorage.sqlite* itself in versions that store it there), populating `partner_resolved_phone_number` on chat session records and using the phone number in timeline events. Each LID-phone number pair is also extracted as a record (`"record_type": "lid_phone_number_pair"`) and produces a `lid_pair_recorded` timeline event marking when WhatsApp learned the association.
If indicators are provided through the command-line, they are checked against the extracted HTTP links. Any matches are stored in *whatsapp_detected.json*. If indicators are provided through the command-line, they are checked against the extracted HTTP links. Any matches are stored in *whatsapp_detected.json*.
---
### `whatsapp_contacts.json`
!!! info "Availability"
Backup: :material-check:
Full filesystem dump: :material-check:
This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extracts WhatsApp contact records from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ContactsV2.sqlite*, including each contact's phone number, WhatsApp and LID identifiers, and the per-contact disappearing messages timer, which is not recorded in *ChatStorage.sqlite*. Each timestamp stored on a contact record produces a timeline event: `disappearing_mode_set` (when the disappearing messages timer was last changed), `about_changed` (when the contact last changed their "about" text), `about_expiration` (when a timed "about" is scheduled to expire) and `contact_last_updated` (when the contact record was last updated).
This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup.
+2 -3
View File
@@ -31,6 +31,7 @@ nav:
- Introduction: "introduction.md" - Introduction: "introduction.md"
- Installation: "install.md" - Installation: "install.md"
- Command Completion: "command_completion.md" - Command Completion: "command_completion.md"
- Custom CLI Commands: "custom_commands.md"
- Using Docker: "docker.md" - Using Docker: "docker.md"
- MVT for iOS: - MVT for iOS:
- iOS Forensic Methodology: "ios/methodology.md" - iOS Forensic Methodology: "ios/methodology.md"
@@ -49,7 +50,5 @@ nav:
- Check an Android Backup (SMS messages): "android/backup.md" - Check an Android Backup (SMS messages): "android/backup.md"
- Check Android Intrusion Logs: "android/intrusion_logs.md" - Check Android Intrusion Logs: "android/intrusion_logs.md"
- Indicators of Compromise: "iocs.md" - Indicators of Compromise: "iocs.md"
- Development: - Development: "development.md"
- Development Instructions: "development.md"
- Custom CLI Commands: "custom_commands.md"
- License: "license.md" - License: "license.md"
+13 -13
View File
@@ -17,25 +17,25 @@ classifiers = [
"Programming Language :: Python", "Programming Language :: Python",
] ]
dependencies = [ dependencies = [
"click==8.4.2", "click==8.3.3",
"rich==15.0.0", "rich==14.3.3",
"tld==0.13.2", "tld==0.13.2",
"requests==2.34.2", "requests==2.33.1",
"simplejson==4.1.1", "simplejson==3.20.2",
"packaging==26.3", "packaging==26.2",
"appdirs==1.4.4", "appdirs==1.4.4",
"iOSbackup==0.9.925", "iOSbackup==0.9.925",
"adb-shell[usb]==0.4.4", "adb-shell[usb]==0.4.4",
"libusb1==3.4.0", "libusb1==3.3.1",
"cryptography==50.0.0", "cryptography==47.0.0",
"PyYAML>=6.0.2", "PyYAML>=6.0.2",
"pyahocorasick==2.3.1", "pyahocorasick==2.3.1",
"betterproto2==0.10.0", "betterproto2==0.9.1",
"pydantic==2.13.4", "pydantic==2.13.3",
"pydantic-settings==2.15.0", "pydantic-settings==2.14.0",
"NSKeyedUnArchiver==1.5.2", "NSKeyedUnArchiver==1.5.2",
"python-dateutil==2.9.0.post0", "python-dateutil==2.9.0.post0",
"tzdata==2026.3", "tzdata==2026.2",
] ]
requires-python = ">= 3.10" requires-python = ">= 3.10"
@@ -65,9 +65,9 @@ dev = [
docs = [ docs = [
"mkdocs==1.6.1", "mkdocs==1.6.1",
"mkdocs-autorefs==1.4.4", "mkdocs-autorefs==1.4.4",
"mkdocs-material==9.7.7", "mkdocs-material==9.7.6",
"mkdocs-material-extensions==1.3.1", "mkdocs-material-extensions==1.3.1",
"mkdocstrings==1.0.6", "mkdocstrings==1.0.4",
] ]
[build-system] [build-system]
+3 -29
View File
@@ -4,7 +4,7 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import re import re
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
from mvt.android.utils import ROOT_PACKAGES from mvt.android.utils import ROOT_PACKAGES
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
@@ -71,8 +71,6 @@ class DumpsysPackagesArtifact(AndroidArtifact):
"timestamp": "", "timestamp": "",
"first_install_time": "", "first_install_time": "",
"last_update_time": "", "last_update_time": "",
"installer": "",
"system": False,
"permissions": list(), "permissions": list(),
"requested_permissions": list(), "requested_permissions": list(),
} }
@@ -80,14 +78,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
in_runtime_permissions = False in_runtime_permissions = False
in_declared_permissions = False in_declared_permissions = False
in_requested_permissions = True in_requested_permissions = True
current_user: Optional[int] = None
first_install_times: Dict[Optional[int], str] = {}
runtime_permissions: Dict[Optional[int], List[Dict[str, Any]]] = {}
for line in output.splitlines(): for line in output.splitlines():
user_match = re.match(r"User (\d+):", line.strip())
if user_match:
current_user = int(user_match.group(1))
if in_install_permissions: if in_install_permissions:
if line.startswith(" " * 4) and not line.startswith(" " * 6): if line.startswith(" " * 4) and not line.startswith(" " * 6):
in_install_permissions = False in_install_permissions = False
@@ -111,7 +102,7 @@ class DumpsysPackagesArtifact(AndroidArtifact):
if "granted=" in lineinfo[1]: if "granted=" in lineinfo[1]:
granted = "granted=true" in lineinfo[1] granted = "granted=true" in lineinfo[1]
runtime_permissions.setdefault(current_user, []).append( details["permissions"].append(
{"name": permission, "granted": granted, "type": "runtime"} {"name": permission, "granted": granted, "type": "runtime"}
) )
if in_declared_permissions: if in_declared_permissions:
@@ -135,12 +126,8 @@ class DumpsysPackagesArtifact(AndroidArtifact):
details["version_code"] = line.split("=", 1)[1].strip() details["version_code"] = line.split("=", 1)[1].strip()
elif line.strip().startswith("timeStamp="): elif line.strip().startswith("timeStamp="):
details["timestamp"] = line.split("=")[1].strip() details["timestamp"] = line.split("=")[1].strip()
elif line.strip().startswith("installerPackageName="):
details["installer"] = line.split("=", 1)[1].strip()
elif line.strip().startswith("pkgFlags="):
details["system"] = "SYSTEM" in line.split("=", 1)[1].split()
elif line.strip().startswith("firstInstallTime="): elif line.strip().startswith("firstInstallTime="):
first_install_times[current_user] = line.split("=", 1)[1].strip() details["first_install_time"] = line.split("=")[1].strip()
elif line.strip().startswith("lastUpdateTime="): elif line.strip().startswith("lastUpdateTime="):
details["last_update_time"] = line.split("=")[1].strip() details["last_update_time"] = line.split("=")[1].strip()
elif line.strip() == "install permissions:": elif line.strip() == "install permissions:":
@@ -152,19 +139,6 @@ class DumpsysPackagesArtifact(AndroidArtifact):
elif line.strip() == "requested permissions:": elif line.strip() == "requested permissions:":
in_requested_permissions = True in_requested_permissions = True
if 0 in first_install_times:
details["first_install_time"] = first_install_times[0]
elif None in first_install_times:
details["first_install_time"] = first_install_times[None]
elif first_install_times:
details["first_install_time"] = next(iter(first_install_times.values()))
if 0 in runtime_permissions:
details["permissions"].extend(runtime_permissions[0])
elif None in runtime_permissions:
details["permissions"].extend(runtime_permissions[None])
elif runtime_permissions:
details["permissions"].extend(next(iter(runtime_permissions.values())))
return details return details
def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]: def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]:
@@ -34,7 +34,6 @@ TOMBSTONE_TEXT_KEY_MAPPINGS = {
"signal": "signal_info", "signal": "signal_info",
"code": "code", "code": "code",
"Cause": "cause", "Cause": "cause",
"Abort message": "abort_message",
} }
@@ -68,8 +67,6 @@ class TombstoneCrashResult(pydantic.BaseModel):
uid: int uid: int
signal_info: SignalInfo signal_info: SignalInfo
cause: Optional[str] = None cause: Optional[str] = None
causes: Optional[List[dict]] = None
abort_message: Optional[str] = None
extra: Optional[str] = None extra: Optional[str] = None
+12 -66
View File
@@ -6,10 +6,8 @@
import json import json
import logging import logging
import os import os
import shutil
import tarfile import tarfile
from pathlib import Path, PurePosixPath from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Optional from typing import Any, Optional
from mvt.common.command import Command from mvt.common.command import Command
@@ -56,8 +54,6 @@ class CmdIOSCheckSysdiagnose(Command):
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
self.sysdiagnose_files: list[str] = [] self.sysdiagnose_files: list[str] = []
self.ips_files: list[dict[str, Any]] = [] self.ips_files: list[dict[str, Any]] = []
self.temp_sysdiagnose_dir: Optional[TemporaryDirectory[str]] = None
self.extracted_sysdiagnose_path: Optional[str] = None
@staticmethod @staticmethod
def _parse_bugtype_header(data: bytes) -> Optional[int]: def _parse_bugtype_header(data: bytes) -> Optional[int]:
@@ -95,68 +91,21 @@ class CmdIOSCheckSysdiagnose(Command):
self.log.info("Parsing sysdiagnose archive. This might take a while...") self.log.info("Parsing sysdiagnose archive. This might take a while...")
self.sysdiagnose_format = "tar" self.sysdiagnose_format = "tar"
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz") self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
self._extract_sysdiagnose_archive() for member in self.sysdiagnose_archive:
self.sysdiagnose_files.append(member.name)
def _extract_sysdiagnose_archive(self) -> None: if member.isfile() and member.name.endswith(".ips"):
archive = self.sysdiagnose_archive archive_handle = self.sysdiagnose_archive.extractfile(member)
if archive is None: if archive_handle is not None:
raise RuntimeError("Sysdiagnose archive has not been initialized") with archive_handle:
self._add_ips_file(member.name, archive_handle.read())
self.temp_sysdiagnose_dir = TemporaryDirectory()
extraction_root = Path(self.temp_sysdiagnose_dir.name).resolve()
archive_roots = set()
for member in archive:
member_path = PurePosixPath(member.name.replace("\\", "/"))
if member_path.is_absolute() or ".." in member_path.parts:
self.log.warning("Skipping unsafe sysdiagnose path %r", member.name)
continue
destination = extraction_root.joinpath(*member_path.parts).resolve()
if not destination.is_relative_to(extraction_root):
self.log.warning("Skipping unsafe sysdiagnose path %r", member.name)
continue
if not member_path.parts:
continue
archive_roots.add(member_path.parts[0])
if member.isdir():
destination.mkdir(parents=True, exist_ok=True)
continue
# Modules only need directories and regular files. Do not materialize
# links or device nodes from an untrusted sysdiagnose archive.
if not member.isfile():
self.log.warning("Skipping unsafe sysdiagnose member %r", member.name)
continue
normalized_name = member_path.as_posix()
self.sysdiagnose_files.append(normalized_name)
source = archive.extractfile(member)
if source is None:
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
if normalized_name.endswith(".ips"):
self._add_ips_file(str(destination), destination.read_bytes())
if len(archive_roots) != 1:
raise ValueError("Sysdiagnose archive must contain one top-level directory")
self.extracted_sysdiagnose_path = str(extraction_root / archive_roots.pop())
def module_init(self, module) -> None: def module_init(self, module) -> None:
module.ips_files = self.ips_files module.ips_files = self.ips_files
if self.sysdiagnose_format == "tar": if self.sysdiagnose_format == "tar":
if self.extracted_sysdiagnose_path is None: if self.sysdiagnose_archive is None:
raise RuntimeError("Sysdiagnose archive has not been extracted") raise RuntimeError("Sysdiagnose archive has not been initialized")
module.from_sysdiagnose_folder( module.from_sysdiagnose_tar(
self.extracted_sysdiagnose_path, self.sysdiagnose_files self.sysdiagnose_archive, self.sysdiagnose_files
) )
return return
if self.sysdiagnose_format == "dir" and self.target_path: if self.sysdiagnose_format == "dir" and self.target_path:
@@ -168,6 +117,3 @@ class CmdIOSCheckSysdiagnose(Command):
if self.sysdiagnose_archive is not None: if self.sysdiagnose_archive is not None:
self.sysdiagnose_archive.close() self.sysdiagnose_archive.close()
self.sysdiagnose_archive = None self.sysdiagnose_archive = None
if self.temp_sysdiagnose_dir is not None:
self.temp_sysdiagnose_dir.cleanup()
self.temp_sysdiagnose_dir = None
-16
View File
@@ -1216,14 +1216,6 @@
"version": "18.7.9", "version": "18.7.9",
"build": "22H355" "build": "22H355"
}, },
{
"version": "18.7.10",
"build": "22H373"
},
{
"version": "18.7.10",
"build": "22H374"
},
{ {
"version": "26", "version": "26",
"build": "23A341" "build": "23A341"
@@ -1263,13 +1255,5 @@
{ {
"version": "26.5", "version": "26.5",
"build": "23F77" "build": "23F77"
},
{
"version": "26.6.1",
"build": "23G82"
},
{
"version": "26.6.1",
"build": "23G83"
} }
] ]
-2
View File
@@ -26,7 +26,6 @@ from .tcc import TCC
from .webkit_resource_load_statistics import WebkitResourceLoadStatistics from .webkit_resource_load_statistics import WebkitResourceLoadStatistics
from .webkit_session_resource_log import WebkitSessionResourceLog from .webkit_session_resource_log import WebkitSessionResourceLog
from .whatsapp import Whatsapp from .whatsapp import Whatsapp
from .whatsapp_contacts import WhatsappContacts
MIXED_MODULES = [ MIXED_MODULES = [
Calls, Calls,
@@ -48,7 +47,6 @@ MIXED_MODULES = [
WebkitResourceLoadStatistics, WebkitResourceLoadStatistics,
WebkitSessionResourceLog, WebkitSessionResourceLog,
Whatsapp, Whatsapp,
WhatsappContacts,
Shortcuts, Shortcuts,
Applications, Applications,
Calendar, Calendar,
+1 -3
View File
@@ -73,9 +73,7 @@ class Calls(IOSExtraction):
"duration": row[1], "duration": row[1],
"location": row[2], "location": row[2],
"number": ( "number": (
row[3].decode("utf-8") row[3].decode("utf-8") if row[3] and row[3] is bytes else row[3]
if isinstance(row[3], bytes)
else row[3]
), ),
"provider": row[4], "provider": row[4],
} }
+9 -231
View File
@@ -3,11 +3,9 @@
# Use of this software is governed by the MVT License 1.1 that can be found at # Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import datetime
import logging import logging
import re
import sqlite3 import sqlite3
from typing import Optional, Tuple from typing import Optional
from mvt.common.module_types import ( from mvt.common.module_types import (
ModuleAtomicResult, ModuleAtomicResult,
@@ -17,7 +15,6 @@ from mvt.common.module_types import (
from mvt.common.utils import convert_mactime_to_iso from mvt.common.utils import convert_mactime_to_iso
from ..base import IOSExtraction from ..base import IOSExtraction
from .whatsapp_contacts import WhatsappContacts
INTERACTIONC_BACKUP_IDS = [ INTERACTIONC_BACKUP_IDS = [
"1f5a521220a3ad80ebfdc196978df8e7a2e49dee", "1f5a521220a3ad80ebfdc196978df8e7a2e49dee",
@@ -25,49 +22,6 @@ INTERACTIONC_BACKUP_IDS = [
INTERACTIONC_ROOT_PATHS = [ INTERACTIONC_ROOT_PATHS = [
"private/var/mobile/Library/CoreDuet/People/interactionC.db", "private/var/mobile/Library/CoreDuet/People/interactionC.db",
] ]
# The interaction record's creation date normally trails its start date by
# milliseconds: emitting it as a timeline event only duplicates the start
# date event. A large divergence, however, indicates the record was
# backfilled (sync, restore, or tampering) and is worth surfacing.
CREATION_DATE_DIVERGENCE_THRESHOLD = 3600.0
# Per-contact aggregate dates from ZCONTACTS are repeated on every
# interaction row of the same contact. They are serialized with a
# contact-centric data string so that timeline de-duplication collapses
# them into one event per contact.
CONTACT_EVENT_TEMPLATES = {
"contacts_creation_date": "Contact {party} first recorded in interactionC",
"first_incoming_sender_date": "First incoming interaction from {party}",
"last_incoming_sender_date": "Last incoming interaction from {party}",
"first_incoming_recipient_date": (
"First incoming interaction where {party} was a recipient"
),
"last_incoming_recipient_date": (
"Last incoming interaction where {party} was a recipient"
),
"first_outgoing_recipient_date": (
"First outgoing interaction to {party}"
),
"last_outgoing_recipient_date": (
"Last outgoing interaction to {party}"
),
}
def _parse_iso(timestamp) -> Optional[datetime.datetime]:
try:
return datetime.datetime.strptime(
timestamp, "%Y-%m-%d %H:%M:%S.%f"
)
except (TypeError, ValueError):
return None
def _describe_delta(seconds: float) -> str:
if seconds >= 86400:
return f"{seconds / 86400:.0f} days"
return f"{seconds / 3600:.0f} hours"
# Taken from APOLLO # Taken from APOLLO
# https://github.com/mac4n6/APOLLO/blob/master/modules/interaction_contact_interactions.txt # https://github.com/mac4n6/APOLLO/blob/master/modules/interaction_contact_interactions.txt
QUERIES = [ QUERIES = [
@@ -80,7 +34,7 @@ QUERIES = [
CASE ZINTERACTIONS.ZDIRECTION CASE ZINTERACTIONS.ZDIRECTION
WHEN '0' THEN 'INCOMING' WHEN '0' THEN 'INCOMING'
WHEN '1' THEN 'OUTGOING' WHEN '1' THEN 'OUTGOING'
END AS "direction", END 'DIRECTION' AS "direction",
ZCONTACTS.ZDISPLAYNAME AS "sender_display_name", ZCONTACTS.ZDISPLAYNAME AS "sender_display_name",
ZCONTACTS.ZIDENTIFIER AS "sender_identifier", ZCONTACTS.ZIDENTIFIER AS "sender_identifier",
ZCONTACTS.ZPERSONID AS "sender_personid", ZCONTACTS.ZPERSONID AS "sender_personid",
@@ -135,7 +89,7 @@ QUERIES = [
CASE ZINTERACTIONS.ZDIRECTION CASE ZINTERACTIONS.ZDIRECTION
WHEN '0' THEN 'INCOMING' WHEN '0' THEN 'INCOMING'
WHEN '1' THEN 'OUTGOING' WHEN '1' THEN 'OUTGOING'
END AS "direction", END 'DIRECTION' AS "direction",
ZCONTACTS.ZDISPLAYNAME AS "sender_display_name", ZCONTACTS.ZDISPLAYNAME AS "sender_display_name",
ZCONTACTS.ZIDENTIFIER AS "sender_identifier", ZCONTACTS.ZIDENTIFIER AS "sender_identifier",
ZCONTACTS.ZPERSONID AS "sender_personid", ZCONTACTS.ZPERSONID AS "sender_personid",
@@ -163,7 +117,7 @@ QUERIES = [
CASE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE CASE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE
WHEN '0' THEN '0' WHEN '0' THEN '0'
ELSE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE ELSE ZCONTACTS.ZLASTINCOMINGRECIPIENTDATE
END AS "last_incoming_recipient_date", END 'LAST INCOMING RECIPIENT DATE' AS "last_incoming_recipient_date",
ZCONTACTS.ZLASTOUTGOINGRECIPIENTDATE AS "last_outgoing_recipient_date", ZCONTACTS.ZLASTOUTGOINGRECIPIENTDATE AS "last_outgoing_recipient_date",
ZCONTACTS.ZCUSTOMIDENTIFIER AS "custom_id", ZCONTACTS.ZCUSTOMIDENTIFIER AS "custom_id",
ZINTERACTIONS.ZCONTENTURL AS "interaction_content_url", ZINTERACTIONS.ZCONTENTURL AS "interaction_content_url",
@@ -264,16 +218,9 @@ QUERIES = [
] ]
WHATSAPP_BUNDLE_ID = "net.whatsapp.WhatsApp"
class InteractionC(IOSExtraction): class InteractionC(IOSExtraction):
"""This module extracts data from InteractionC db.""" """This module extracts data from InteractionC db."""
# WhatsApp identifies chat peers by LID in interactionC.db, which only the
# WhatsApp contacts database can map back to a phone number and name.
dependencies = [WhatsappContacts]
def __init__( def __init__(
self, self,
file_path: Optional[str] = None, file_path: Optional[str] = None,
@@ -305,51 +252,10 @@ class InteractionC(IOSExtraction):
"last_outgoing_recipient_date", "last_outgoing_recipient_date",
] ]
@staticmethod
def _describe_party(record: ModuleAtomicResult, prefix: str) -> Optional[str]:
name = record.get(f"{prefix}_display_name") or record.get(
f"{prefix}_resolved_name"
)
identifier = record.get(f"{prefix}_resolved_phone_number") or record.get(
f"{prefix}_identifier"
)
if name and identifier:
# A display name that is just a formatted copy of the phone
# number adds no information.
name_digits = re.sub(r"\D", "", name)
if name_digits and name_digits == re.sub(r"\D", "", identifier):
return identifier
return f"{name} ({identifier})"
return name or identifier or None
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
sender = self._describe_party(record, "sender")
# The chat peer from the domain identifier stands in when the
# recipient was not recorded (or the recipient join is unavailable).
recipient = self._describe_party(record, "recipient") or self._describe_party(
record, "domain"
)
direction = record.get("direction")
if not sender and direction == "OUTGOING":
sender = "local user"
if not recipient and direction == "INCOMING":
recipient = "local user"
header = f"[{record['bundle_id']}]"
if record.get("account"):
header += f" {record['account']}"
if direction:
header += f" {direction}"
data = f"{header} from {sender or 'unknown'} to {recipient or 'unknown'}"
if record.get("group_name"):
data += f" (group: {record['group_name']})"
if record.get("content"):
data += f": {record['content']}"
records = [] records = []
processed = [] processed = []
for timestamp in ("start_date", "end_date"): for timestamp in self.timestamps:
# Check if the record has the current timestamp. # Check if the record has the current timestamp.
if timestamp not in record or not record[timestamp]: if timestamp not in record or not record[timestamp]:
continue continue
@@ -363,142 +269,16 @@ class InteractionC(IOSExtraction):
"timestamp": record[timestamp], "timestamp": record[timestamp],
"module": self.__class__.__name__, "module": self.__class__.__name__,
"event": timestamp, "event": timestamp,
"data": data, "data": f"[{record['bundle_id']}] {record['account']} - "
f"from {record['sender_display_name']} ({record['sender_identifier']}) "
f"to {record.get('recipient_display_name', '')} ({record.get('recipient_identifier', '')}):"
f" {record.get('content', '')}",
} }
) )
processed.append(record[timestamp]) processed.append(record[timestamp])
creation_event = self._serialize_creation_date(record, data)
if creation_event:
records.append(creation_event)
# Contact-level aggregates describe the sender's contact record.
party = self._describe_party(record, "sender")
if party:
for field, template in CONTACT_EVENT_TEMPLATES.items():
if not record.get(field):
continue
records.append(
{
"timestamp": record[field],
"module": self.__class__.__name__,
"event": field,
"data": template.format(party=party),
}
)
return records return records
def _serialize_creation_date(
self, record: ModuleAtomicResult, data: str
) -> Optional[dict]:
"""Serialize the interaction record's creation date only when it
diverges from the start date enough to indicate the record was
backfilled."""
creation = record.get("interactions_creation_date")
if not creation:
return None
event = {
"timestamp": creation,
"module": self.__class__.__name__,
"event": "interactions_creation_date",
"data": data,
}
start = _parse_iso(record.get("start_date"))
creation_parsed = _parse_iso(creation)
if not start or not creation_parsed:
# Without a start date the creation date is the only anchor.
return event
delta = (creation_parsed - start).total_seconds()
if abs(delta) < CREATION_DATE_DIVERGENCE_THRESHOLD:
return None
direction = "after" if delta > 0 else "before"
event["data"] = (
f"Interaction record created {_describe_delta(abs(delta))} "
f"{direction} the event: {data}"
)
return event
def _whatsapp_contact_maps(self) -> Tuple[dict, dict]:
"""Build LID and phone-digit lookup maps from the WhatsappContacts
module results, when available."""
by_lid: dict = {}
by_phone: dict = {}
contacts_module = self.dependency_modules.get(WhatsappContacts)
if not contacts_module:
return by_lid, by_phone
for contact in contacts_module.results:
name = contact.get("full_name") or contact.get("given_name")
phone = contact.get("phone_number")
entry = (phone, name)
if contact.get("lid"):
by_lid[contact["lid"]] = entry
if phone:
by_phone[re.sub(r"\D", "", phone)] = entry
whatsapp_id = contact.get("whatsapp_id")
if whatsapp_id and "@" in whatsapp_id:
by_phone.setdefault(whatsapp_id.split("@")[0], entry)
return by_lid, by_phone
@staticmethod
def _resolve_whatsapp_identifier(
value, by_lid: dict, by_phone: dict
) -> Tuple[Optional[str], Optional[str]]:
"""Resolve a WhatsApp identifier (LID, JID or phone number) to a
(phone_number, contact_name) tuple."""
if not value:
return None, None
value = str(value)
if value.endswith("@lid"):
return by_lid.get(value, (None, None))
if value.endswith("@g.us"):
return None, None
if value.endswith("@s.whatsapp.net"):
digits = value.split("@")[0]
phone, name = by_phone.get(digits, (None, None))
return phone or f"+{digits}", name
if value.startswith("+"):
_, name = by_phone.get(re.sub(r"\D", "", value), (None, None))
return None, name
return None, None
def _postprocess_results(self) -> None:
by_lid, by_phone = self._whatsapp_contact_maps()
for entry in self.results:
# The fallback queries return ZDIRECTION raw instead of labelled.
if entry.get("direction") in (0, "0"):
entry["direction"] = "INCOMING"
elif entry.get("direction") in (1, "1"):
entry["direction"] = "OUTGOING"
if entry.get("bundle_id") != WHATSAPP_BUNDLE_ID:
continue
candidates = {
"sender": [entry.get("sender_identifier"), entry.get("custom_id")],
"recipient": [entry.get("recipient_identifier")],
"domain": [entry.get("domain_identifier")],
}
for prefix, values in candidates.items():
phone = name = None
for value in values:
phone, name = self._resolve_whatsapp_identifier(
value, by_lid, by_phone
)
if phone or name:
break
entry[f"{prefix}_resolved_phone_number"] = phone
entry[f"{prefix}_resolved_name"] = name
def run(self) -> None: def run(self) -> None:
self._find_ios_database( self._find_ios_database(
backup_ids=INTERACTIONC_BACKUP_IDS, root_paths=INTERACTIONC_ROOT_PATHS backup_ids=INTERACTIONC_BACKUP_IDS, root_paths=INTERACTIONC_ROOT_PATHS
@@ -545,6 +325,4 @@ class InteractionC(IOSExtraction):
cur.close() cur.close()
conn.close() conn.close()
self._postprocess_results()
self.log.info("Extracted a total of %d InteractionC events", len(self.results)) self.log.info("Extracted a total of %d InteractionC events", len(self.results))
+3 -244
View File
@@ -4,9 +4,7 @@
# https://license.mvt.re/1.1/ # https://license.mvt.re/1.1/
import logging import logging
import os from typing import Optional
import sqlite3
from typing import Dict, Optional
from mvt.common.module_types import ( from mvt.common.module_types import (
ModuleAtomicResult, ModuleAtomicResult,
@@ -24,71 +22,9 @@ WHATSAPP_ROOT_PATHS = [
"private/var/mobile/Containers/Shared/AppGroup/*/ChatStorage.sqlite", "private/var/mobile/Containers/Shared/AppGroup/*/ChatStorage.sqlite",
] ]
WHATSAPP_LID_BACKUP_IDS = [
# SHA-1 of "AppDomainGroup-group.net.whatsapp.WhatsApp.shared-LID.sqlite"
"e794f6ffcc3c222535f47684a63d5178da3c4500",
]
WHATSAPP_LID_ROOT_PATHS = [
"private/var/mobile/Containers/Shared/AppGroup/*/LID.sqlite",
]
# WhatsApp records the mapping between a contact's LID and phone number
# identifiers in the ZWAPHONENUMBERLIDPAIR table. Depending on the WhatsApp
# version this lives in a dedicated LID.sqlite database or in
# ChatStorage.sqlite itself.
LID_PAIRS_QUERY = """
SELECT
ZLID AS "lid",
ZPHONENUMBER AS "phone_number",
ZTIMESTAMP AS "pair_timestamp"
FROM ZWAPHONENUMBERLIDPAIR;
"""
CHAT_SESSIONS_QUERY = """
SELECT
ZWACHATSESSION.Z_PK AS "session_pk",
ZWACHATSESSION.ZCONTACTJID AS "contact_jid",
ZWACHATSESSION.ZPARTNERNAME AS "partner_name",
ZWACHATSESSION.ZSESSIONTYPE AS "session_type",
ZWACHATSESSION.ZARCHIVED AS "archived",
ZWACHATSESSION.ZREMOVED AS "removed",
ZWACHATSESSION.ZMESSAGECOUNTER AS "message_counter",
ZWACHATSESSION.ZLASTMESSAGEDATE AS "last_message_date",
ZWAGROUPINFO.ZCREATIONDATE AS "group_creation_date",
MIN(ZWAMESSAGE.ZMESSAGEDATE) AS "first_stored_message_date",
MAX(ZWAMESSAGE.ZMESSAGEDATE) AS "last_stored_message_date",
COUNT(ZWAMESSAGE.Z_PK) AS "stored_message_count"
FROM ZWACHATSESSION
LEFT JOIN ZWAGROUPINFO
ON ZWACHATSESSION.ZGROUPINFO = ZWAGROUPINFO.Z_PK
LEFT JOIN ZWAMESSAGE
ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK
GROUP BY ZWACHATSESSION.Z_PK;
"""
CHAT_SESSION_DATE_FIELDS = [
"last_message_date",
"group_creation_date",
"first_stored_message_date",
"last_stored_message_date",
]
def _describe_chat(record: ModuleAtomicResult) -> str:
jid = record.get("contact_jid") or "unknown"
name = record.get("partner_name")
identifier = record.get("partner_resolved_phone_number") or jid
label = f"'{name}' ({identifier})" if name else identifier
is_group = jid.endswith("@g.us") or record.get("group_creation_date")
if is_group:
return f"WhatsApp group chat {label}"
return f"WhatsApp chat with {label}"
class Whatsapp(IOSExtraction): class Whatsapp(IOSExtraction):
"""This module extracts all WhatsApp messages containing links, as well """This module extracts all WhatsApp messages containing links."""
as per-chat records with the first and last interaction dates of each
conversation."""
def __init__( def __init__(
self, self,
@@ -109,21 +45,6 @@ class Whatsapp(IOSExtraction):
) )
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
if record.get("record_type") == "chat_session":
return self._serialize_chat_session(record)
if record.get("record_type") == "lid_phone_number_pair":
if not record.get("pair_timestamp"):
return []
return {
"timestamp": record["pair_timestamp"],
"module": self.__class__.__name__,
"event": "lid_pair_recorded",
"data": (
f"WhatsApp associated LID {record.get('lid')} with "
f"phone number {record.get('phone_number')}"
),
}
text = record.get("ZTEXT", "").replace("\n", "\\n") text = record.get("ZTEXT", "").replace("\n", "\\n")
links_text = "" links_text = ""
if record.get("links"): if record.get("links"):
@@ -136,49 +57,6 @@ class Whatsapp(IOSExtraction):
"data": f"'{text}' from {record.get('ZFROMJID', 'Unknown')}{links_text}", "data": f"'{text}' from {record.get('ZFROMJID', 'Unknown')}{links_text}",
} }
def _serialize_chat_session(
self, record: ModuleAtomicResult
) -> ModuleSerializedResult:
records = []
chat = _describe_chat(record)
if record.get("group_creation_date"):
records.append(
{
"timestamp": record["group_creation_date"],
"module": self.__class__.__name__,
"event": "group_created",
"data": f"{chat} was created",
}
)
if record.get("first_stored_message_date"):
records.append(
{
"timestamp": record["first_stored_message_date"],
"module": self.__class__.__name__,
"event": "chat_first_message",
"data": f"First stored message in {chat}",
}
)
# The chat session's own last-message date is authoritative: it can
# postdate the newest stored message if that message was deleted.
last_message_date = record.get("last_message_date") or record.get(
"last_stored_message_date"
)
if last_message_date:
records.append(
{
"timestamp": last_message_date,
"module": self.__class__.__name__,
"event": "chat_last_message",
"data": f"Last message in {chat}",
}
)
return records
def check_indicators(self) -> None: def check_indicators(self) -> None:
if not self.indicators: if not self.indicators:
return return
@@ -268,126 +146,7 @@ class Whatsapp(IOSExtraction):
message["links"] = list(set(filtered_links)) message["links"] = list(set(filtered_links))
self.results.append(message) self.results.append(message)
total_messages = len(self.results)
lid_map = self._extract_lid_pairs(cur)
total_sessions = self._extract_chat_sessions(cur, lid_map)
cur.close() cur.close()
conn.close() conn.close()
self.log.info( self.log.info("Extracted a total of %d WhatsApp messages", len(self.results))
"Extracted a total of %d WhatsApp messages, %d chat sessions "
"and %d LID-phone number pairs",
total_messages,
total_sessions,
len(lid_map),
)
def _find_lid_db_path(self) -> Optional[str]:
for backup_id in WHATSAPP_LID_BACKUP_IDS:
file_path = self._get_backup_file_from_id(backup_id)
if file_path and os.path.exists(file_path):
return file_path
for found_path in self._get_fs_files_from_patterns(
WHATSAPP_LID_ROOT_PATHS
):
return found_path
return None
def _extract_lid_pairs(self, chat_cur: sqlite3.Cursor) -> Dict[str, str]:
"""Extract the LID to phone number mapping from the dedicated
LID.sqlite database, falling back to the same table in
ChatStorage.sqlite. Returns a map of LID digits to phone number
digits."""
rows = []
lid_db_path = self._find_lid_db_path()
if lid_db_path:
self.log.info(
"Found WhatsApp LID database at path: %s", lid_db_path
)
lid_conn = self._open_sqlite_db(lid_db_path)
try:
lid_cur = lid_conn.cursor()
lid_cur.execute(LID_PAIRS_QUERY)
rows = lid_cur.fetchall()
lid_cur.close()
except sqlite3.DatabaseError as exc:
self.log.warning(
"Unable to extract WhatsApp LID-phone number pairs: %s",
exc,
)
finally:
lid_conn.close()
else:
try:
chat_cur.execute(LID_PAIRS_QUERY)
rows = chat_cur.fetchall()
except sqlite3.OperationalError:
self.log.info(
"No WhatsApp LID database found in this backup or "
"filesystem dump: LID chat identifiers cannot be "
"resolved to phone numbers"
)
lid_map: Dict[str, str] = {}
for lid, phone_number, pair_timestamp in rows:
record = {
"record_type": "lid_phone_number_pair",
"lid": lid,
"phone_number": phone_number,
"pair_timestamp": (
convert_mactime_to_iso(pair_timestamp) or None
)
if pair_timestamp
else None,
}
self.results.append(record)
if lid and phone_number:
lid_digits = str(lid).split("@")[0]
phone_digits = str(phone_number).split("@")[0].lstrip("+")
lid_map[lid_digits] = phone_digits
return lid_map
def _extract_chat_sessions(
self, cur: sqlite3.Cursor, lid_map: Dict[str, str]
) -> int:
"""Extract one record per chat session with the first and last
interaction dates of each conversation."""
try:
cur.execute(CHAT_SESSIONS_QUERY)
except sqlite3.OperationalError as exc:
self.log.warning(
"Unable to extract WhatsApp chat sessions: %s", exc
)
return 0
names = [description[0] for description in cur.description]
total_sessions = 0
for row in cur.fetchall():
session = dict(zip(names, row))
session["record_type"] = "chat_session"
session["partner_resolved_phone_number"] = None
jid = session.get("contact_jid") or ""
if jid.endswith("@lid"):
phone_digits = lid_map.get(jid.split("@")[0])
if phone_digits:
session["partner_resolved_phone_number"] = (
f"+{phone_digits}"
)
for field in CHAT_SESSION_DATE_FIELDS:
if session.get(field):
session[field] = (
convert_mactime_to_iso(session[field]) or None
)
else:
session[field] = None
self.results.append(session)
total_sessions += 1
return total_sessions
@@ -1,308 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
import sqlite3
from typing import Optional
from mvt.common.module import DatabaseNotFoundError
from mvt.common.module_types import (
ModuleAtomicResult,
ModuleResults,
ModuleSerializedResult,
)
from mvt.common.utils import convert_mactime_to_iso
from ..base import IOSExtraction
WHATSAPP_CONTACTS_BACKUP_IDS = [
# SHA-1 of "AppDomainGroup-group.net.whatsapp.WhatsApp.shared-ContactsV2.sqlite"
"b8548dc30aa1030df0ce18ef08b882cf7ab5212f",
]
WHATSAPP_CONTACTS_ROOT_PATHS = [
"private/var/mobile/Containers/Shared/AppGroup/*/ContactsV2.sqlite",
]
# WhatsApp's standard disappearing-messages timer values, in seconds.
DISAPPEARING_DURATION_LABELS = {
86400: "24 hours",
604800: "7 days",
1209600: "14 days",
2592000: "30 days",
7776000: "90 days",
}
# Output field -> candidate columns in ZWAADDRESSBOOKCONTACT, in order of
# preference. WhatsApp renames columns across versions, so the query is built
# from the columns actually present in the database.
COLUMN_CANDIDATES = {
"whatsapp_id": ["ZWHATSAPPID"],
"lid": ["ZLID"],
"phone_number": ["ZPHONENUMBER"],
"localized_phone_number": ["ZLOCALIZEDPHONENUMBER"],
"full_name": ["ZFULLNAME"],
"given_name": ["ZGIVENNAME"],
"last_name": ["ZLASTNAME"],
"user_name": ["ZUSERNAME"],
"business_name": ["ZBUSINESSNAME"],
"about_text": ["ZABOUTTEXT"],
"about_emoji": ["ZABOUTEMOJI"],
"notes": ["ZNOTES"],
"disappearing_mode_duration": ["ZDISAPPEARINGMODEDURATION"],
"disappearing_mode_timestamp": ["ZDISAPPEARINGMODETIMESTAMP"],
"about_timestamp": ["ZABOUTTIMESTAMP"],
"about_expiration_timestamp": ["ZABOUTEXPIRATIONTIMESTAMP"],
"last_updated": ["ZLASTUPDATED"],
"phone_status": ["ZPHONESTATUS", "ZPHONENUMBERSTATUS"],
"sync_policy": ["ZSYNCPOLICY"],
}
STRING_FIELDS = [
"whatsapp_id",
"lid",
"phone_number",
"localized_phone_number",
"full_name",
"given_name",
"last_name",
"user_name",
"business_name",
"about_text",
"about_emoji",
"notes",
]
DATE_FIELDS = [
"disappearing_mode_timestamp",
"about_timestamp",
"about_expiration_timestamp",
"last_updated",
]
def _decode_string(value) -> Optional[str]:
# CoreData stores string attributes as UTF-8 blobs in some WhatsApp
# versions, so values can arrive as either bytes or str.
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", "replace")
return str(value)
def _label_duration(duration) -> str:
if not duration:
return "off"
return DISAPPEARING_DURATION_LABELS.get(
int(duration), f"{int(duration)} seconds"
)
def _describe_contact(record: ModuleAtomicResult) -> str:
contact = (
record.get("whatsapp_id")
or record.get("lid")
or record.get("phone_number")
or "unknown"
)
full_name = record.get("full_name")
if full_name:
contact = f"{contact} ({full_name})"
return contact
class WhatsappContacts(IOSExtraction):
"""This module extracts WhatsApp contact records and per-contact
disappearing-messages settings from ContactsV2.sqlite.
ChatStorage.sqlite does not record the disappearing-messages state of 1:1
chats: the authoritative timer is stored on each contact record in this
database, alongside the mapping between a contact's LID and phone number
identifiers.
"""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
records = []
contact = _describe_contact(record)
if record.get("disappearing_mode_timestamp"):
records.append(
{
"timestamp": record["disappearing_mode_timestamp"],
"module": self.__class__.__name__,
"event": "disappearing_mode_set",
"data": (
f"WhatsApp disappearing messages timer set to "
f"'{record.get('disappearing_mode_label')}' "
f"for {contact}"
),
}
)
if record.get("about_timestamp"):
data = f"WhatsApp about text of {contact} changed"
about_text = record.get("about_text")
if about_text:
data += f' to "{about_text}"'
records.append(
{
"timestamp": record["about_timestamp"],
"module": self.__class__.__name__,
"event": "about_changed",
"data": data,
}
)
if record.get("about_expiration_timestamp"):
records.append(
{
"timestamp": record["about_expiration_timestamp"],
"module": self.__class__.__name__,
"event": "about_expiration",
"data": (
f"WhatsApp about text of {contact} scheduled "
f"to expire"
),
}
)
if record.get("last_updated"):
records.append(
{
"timestamp": record["last_updated"],
"module": self.__class__.__name__,
"event": "contact_last_updated",
"data": f"WhatsApp contact record for {contact} updated",
}
)
return records
def run(self) -> None:
try:
self._find_ios_database(
backup_ids=WHATSAPP_CONTACTS_BACKUP_IDS,
root_paths=WHATSAPP_CONTACTS_ROOT_PATHS,
)
except DatabaseNotFoundError:
self.log.warning(
"Unable to find the WhatsApp ContactsV2.sqlite database in "
"this backup or filesystem dump. WhatsApp disappearing "
"messages settings and contact records cannot be extracted. "
"This database is often missing from incremental backups."
)
return
self.log.info(
"Found WhatsApp contacts database at path: %s", self.file_path
)
assert self.file_path is not None
conn = self._open_sqlite_db(self.file_path)
cur = conn.cursor()
try:
try:
cur.execute("PRAGMA table_info(ZWAADDRESSBOOKCONTACT)")
available_columns = {row[1] for row in cur.fetchall()}
except sqlite3.DatabaseError as exc:
self.log.error(
"Unable to read the ZWAADDRESSBOOKCONTACT table schema: %s",
exc,
)
return
if not available_columns:
self.log.warning(
"The WhatsApp contacts database does not contain a "
"ZWAADDRESSBOOKCONTACT table"
)
return
selected = {}
for field, candidates in COLUMN_CANDIDATES.items():
for candidate in candidates:
if candidate in available_columns:
selected[field] = candidate
break
# A record with no duration column is "unknown", not "off": the
# timer state cannot be determined from this database version.
has_duration = "disappearing_mode_duration" in selected
if not has_duration:
self.log.warning(
"The ZDISAPPEARINGMODEDURATION column is not present in "
"this WhatsApp contacts database: disappearing messages "
"state is unknown"
)
columns = ["Z_PK"] + list(selected.values())
cur.execute(
f"SELECT {', '.join(columns)} FROM ZWAADDRESSBOOKCONTACT;"
)
fields = ["row_pk"] + list(selected.keys())
for row in cur:
record = dict(zip(fields, row))
for field in STRING_FIELDS:
if field in record:
record[field] = _decode_string(record[field])
else:
record[field] = None
for field in DATE_FIELDS:
if record.get(field) is not None:
record[field] = (
convert_mactime_to_iso(record[field]) or None
)
else:
record[field] = None
duration = record.get("disappearing_mode_duration")
if has_duration:
record["disappearing_mode_is_on"] = bool(duration)
record["disappearing_mode_label"] = _label_duration(
duration
)
else:
record["disappearing_mode_duration"] = None
record["disappearing_mode_is_on"] = None
record["disappearing_mode_label"] = None
record.setdefault("phone_status", None)
record.setdefault("sync_policy", None)
self.results.append(record)
finally:
cur.close()
conn.close()
total_ephemeral = sum(
1 for record in self.results if record["disappearing_mode_is_on"]
)
self.log.info(
"Extracted a total of %d WhatsApp contacts (%d with disappearing "
"messages enabled)",
len(self.results),
total_ephemeral,
)
@@ -25,25 +25,6 @@ class TestDumpsysPackagesArtifact:
== "com.samsung.android.provider.filterprovider" == "com.samsung.android.provider.filterprovider"
) )
assert dpa.results[0]["version_name"] == "5.0.07" assert dpa.results[0]["version_name"] == "5.0.07"
assert dpa.results[0]["first_install_time"] == "2008-12-31 16:00:00"
assert dpa.results[0]["system"] is True
def test_parsing_system_flag(self):
system_details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
" pkgFlags=[ SYSTEM HAS_CODE ALLOW_CLEAR_USER_DATA ]"
)
third_party_details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
" pkgFlags=[ HAS_CODE ALLOW_BACKUP ]"
)
missing_flag_details = (
DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
" versionName=1.0"
)
)
assert system_details["system"] is True
assert third_party_details["system"] is False
assert missing_flag_details["system"] is False
def test_ioc_check(self, indicator_file): def test_ioc_check(self, indicator_file):
dpa = DumpsysPackagesArtifact() dpa = DumpsysPackagesArtifact()
@@ -59,43 +40,3 @@ class TestDumpsysPackagesArtifact:
assert len(dpa.alertstore.alerts) == 0 assert len(dpa.alertstore.alerts) == 0
dpa.check_indicators() dpa.check_indicators()
assert len(dpa.alertstore.alerts) == 1 assert len(dpa.alertstore.alerts) == 1
def test_per_user_fields_use_primary_user(self):
details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
""" User 0: installed=true
firstInstallTime=2024-01-10 09:19:39
runtime permissions:
android.permission.CAMERA: granted=true
User 95: installed=false
firstInstallTime=1970-01-01 01:00:00
runtime permissions:
android.permission.CAMERA: granted=false
android.permission.RECORD_AUDIO: granted=false
"""
)
assert details["first_install_time"] == "2024-01-10 09:19:39"
runtime_permissions = [
permission
for permission in details["permissions"]
if permission["type"] == "runtime"
]
assert runtime_permissions == [
{
"name": "android.permission.CAMERA",
"granted": True,
"type": "runtime",
}
]
def test_per_user_fields_fall_back_when_user_zero_is_missing(self):
details = DumpsysPackagesArtifact.parse_dumpsys_package_for_details(
""" User 10: installed=true
firstInstallTime=2024-02-10 09:19:39
runtime permissions:
android.permission.CAMERA: granted=true
"""
)
assert details["first_install_time"] == "2024-02-10 09:19:39"
assert details["permissions"][-1]["name"] == "android.permission.CAMERA"
-42
View File
@@ -8,7 +8,6 @@ import datetime
import pytest import pytest
from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact from mvt.android.artifacts.tombstone_crashes import TombstoneCrashArtifact
from mvt.android.parsers.proto.tombstone import Tombstone
from ..utils import get_artifact from ..utils import get_artifact
@@ -43,47 +42,6 @@ class TestTombstoneCrashArtifact:
assert len(tombstone_artifact.results) == 1 assert len(tombstone_artifact.results) == 1
self.validate_tombstone_result(tombstone_artifact.results[0]) self.validate_tombstone_result(tombstone_artifact.results[0])
def test_text_tombstone_preserves_abort_message(self):
tombstone_artifact = TombstoneCrashArtifact()
artifact_path = "android_data/bugreport/FS/data/tombstones/tombstone_00"
file = get_artifact(artifact_path)
with open(file, "rb") as f:
data = f.read()
tombstone_artifact.parse(
os.path.basename(artifact_path),
datetime.datetime(2021, 9, 29, 17, 43, 49),
data,
)
assert tombstone_artifact.results[0]["abort_message"] == (
"Check failed: payload.size() <= bytes_left "
"(payload.size()=99, bytes_left=51) "
)
def test_protobuf_tombstone_preserves_abort_message_and_causes(self):
tombstone_artifact = TombstoneCrashArtifact()
artifact_path = "android_data/tombstone_process.pb"
file = get_artifact(artifact_path)
with open(file, "rb") as f:
tombstone = Tombstone().parse(f.read())
tombstone.abort_message = "synthetic abort reason"
tombstone_artifact.parse_protobuf(
os.path.basename(artifact_path),
datetime.datetime(2023, 4, 12, 12, 32, 40, 518290),
bytes(tombstone),
)
result = tombstone_artifact.results[0]
assert result["abort_message"] == "synthetic abort reason"
assert result["causes"] == [
{
"human_readable": "null pointer dereference",
"memory_error": None,
}
]
def test_text_tombstone_keeps_crashing_thread(self): def test_text_tombstone_keeps_crashing_thread(self):
tombstone_artifact = TombstoneCrashArtifact() tombstone_artifact = TombstoneCrashArtifact()
artifact_path = "android_data/tombstone_process.txt" artifact_path = "android_data/tombstone_process.txt"
@@ -54,8 +54,6 @@ class TestBugreportAnalysis:
== "com.samsung.android.provider.filterprovider" == "com.samsung.android.provider.filterprovider"
) )
assert m.results[1]["package_name"] == "com.instagram.android" assert m.results[1]["package_name"] == "com.instagram.android"
assert m.results[0]["installer"] == ""
assert m.results[1]["installer"] == "com.android.vending"
assert len(m.results[0]["permissions"]) == 4 assert len(m.results[0]["permissions"]) == 4
assert len(m.results[1]["permissions"]) == 32 assert len(m.results[1]["permissions"]) == 32
-105
View File
@@ -1,105 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import run_module
from mvt.ios.modules.mixed.interactionc import InteractionC
from mvt.ios.modules.mixed.whatsapp_contacts import WhatsappContacts
from ..utils import get_ios_backup_folder
class TestInteractionCModule:
def test_extraction_with_whatsapp_contacts(self):
contacts = WhatsappContacts(target_path=get_ios_backup_folder())
run_module(contacts)
m = InteractionC(target_path=get_ios_backup_folder())
m.dependency_modules = {WhatsappContacts: contacts}
run_module(m)
assert len(m.results) == 3
incoming = next(
r for r in m.results if r["sender_identifier"] == "100000000000001@lid"
)
assert incoming["direction"] == "INCOMING"
assert incoming["sender_resolved_phone_number"] == "+14155550100"
assert incoming["sender_resolved_name"] == "Alice Example"
outgoing = next(
r for r in m.results if r["direction"] == "OUTGOING"
)
assert outgoing["recipient_identifier"] == "+14155550100"
assert outgoing["recipient_resolved_name"] == "Alice Example"
assert outgoing["domain_resolved_phone_number"] == "+14155550100"
assert outgoing["domain_resolved_name"] == "Alice Example"
sms = next(
r for r in m.results if r["bundle_id"] == "com.apple.MobileSMS"
)
assert sms.get("sender_resolved_name") is None
assert sms["sender_display_name"] == "Bob Example"
events = [entry["data"] for entry in m.timeline]
assert (
"[net.whatsapp.WhatsApp] INCOMING from "
"Alice Example (+14155550100) to local user" in events
)
assert (
"[net.whatsapp.WhatsApp] OUTGOING from local user to "
"Alice Example (+14155550100)" in events
)
assert (
"[com.apple.MobileSMS] INCOMING from "
"Bob Example (+14155550101) to local user" in events
)
# The creation date is only serialized when it diverges from the
# start date; the SMS record was created 90 days after the event.
creation_events = [
entry
for entry in m.timeline
if entry["event"] == "interactions_creation_date"
]
assert len(creation_events) == 1
assert creation_events[0]["timestamp"] == "2025-12-09 12:26:40.000000"
assert creation_events[0]["data"] == (
"Interaction record created 90 days after the event: "
"[com.apple.MobileSMS] INCOMING from "
"Bob Example (+14155550101) to local user"
)
# Per-contact aggregate dates use contact-centric data strings.
first_seen = [
entry
for entry in m.timeline
if entry["event"] == "first_incoming_sender_date"
]
assert len(first_seen) == 1
assert first_seen[0]["timestamp"] == "2025-09-03 13:46:40.000000"
assert first_seen[0]["data"] == (
"First incoming interaction from Bob Example (+14155550101)"
)
assert (
"Last incoming interaction from Bob Example (+14155550101)"
in events
)
def test_extraction_without_whatsapp_contacts(self):
# Without the WhatsappContacts dependency the module still runs, and
# unresolvable LIDs are shown as-is.
m = InteractionC(target_path=get_ios_backup_folder())
run_module(m)
assert len(m.results) == 3
events = [entry["data"] for entry in m.timeline]
assert (
"[net.whatsapp.WhatsApp] INCOMING from "
"100000000000001@lid to local user" in events
)
assert (
"[net.whatsapp.WhatsApp] OUTGOING from local user to "
"+14155550100" in events
)
-71
View File
@@ -6,79 +6,8 @@
import logging import logging
from mvt.common.indicators import Indicators from mvt.common.indicators import Indicators
from mvt.common.module import run_module
from mvt.ios.modules.mixed.whatsapp import Whatsapp from mvt.ios.modules.mixed.whatsapp import Whatsapp
from ..utils import get_ios_backup_folder
def test_extraction():
m = Whatsapp(target_path=get_ios_backup_folder())
run_module(m)
messages = [r for r in m.results if "ZTEXT" in r]
sessions = [r for r in m.results if r.get("record_type") == "chat_session"]
pairs = [
r for r in m.results
if r.get("record_type") == "lid_phone_number_pair"
]
assert len(messages) == 3
assert len(sessions) == 2
assert len(pairs) == 1
assert pairs[0]["lid"] == "100000000000001"
assert pairs[0]["phone_number"] == "14155550100"
assert pairs[0]["pair_timestamp"] == "2025-08-25 07:33:20.000000"
linked = next(r for r in messages if r.get("links"))
assert linked["links"] == ["https://example.org/news"]
alice = next(s for s in sessions if s["partner_name"] == "Alice Example")
assert alice["contact_jid"] == "100000000000001@lid"
assert alice["partner_resolved_phone_number"] == "+14155550100"
assert alice["first_stored_message_date"] == "2025-08-27 15:06:40.000000"
assert alice["last_message_date"] == "2025-08-28 18:53:20.000000"
assert alice["group_creation_date"] is None
assert alice["stored_message_count"] == 2
group = next(s for s in sessions if s["partner_name"] == "Example Group")
assert group["group_creation_date"] == "2025-08-21 20:13:20.000000"
assert group["first_stored_message_date"] == "2025-08-29 22:40:00.000000"
# The last stored message predates the session's own last-message date:
# the newest message in this chat was deleted.
assert group["last_stored_message_date"] == "2025-08-29 22:40:00.000000"
assert group["last_message_date"] == "2025-08-31 02:26:40.000000"
# 3 message events, first/last per chat, the group creation and the
# LID-phone number pair.
assert len(m.timeline) == 9
events = {
(entry["event"], entry["timestamp"]): entry["data"]
for entry in m.timeline
}
# Alice's session is keyed by LID but labelled with the phone number
# resolved through LID.sqlite.
assert events[("chat_first_message", "2025-08-27 15:06:40.000000")] == (
"First stored message in WhatsApp chat with "
"'Alice Example' (+14155550100)"
)
assert events[("chat_last_message", "2025-08-28 18:53:20.000000")] == (
"Last message in WhatsApp chat with "
"'Alice Example' (+14155550100)"
)
assert events[("lid_pair_recorded", "2025-08-25 07:33:20.000000")] == (
"WhatsApp associated LID 100000000000001 with "
"phone number 14155550100"
)
assert events[("group_created", "2025-08-21 20:13:20.000000")] == (
"WhatsApp group chat 'Example Group' "
"(120000000000000001@g.us) was created"
)
assert ("chat_first_message", "2025-08-29 22:40:00.000000") in events
assert ("chat_last_message", "2025-08-31 02:26:40.000000") in events
assert len(m.alertstore.alerts) == 0
def test_collect_url_results_includes_expansion(): def test_collect_url_results_includes_expansion():
module = Whatsapp( module = Whatsapp(
@@ -1,83 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import run_module
from mvt.ios.modules.mixed.whatsapp_contacts import WhatsappContacts
from ..utils import get_ios_backup_folder
class TestWhatsappContactsModule:
def test_extraction(self):
m = WhatsappContacts(target_path=get_ios_backup_folder())
run_module(m)
assert len(m.results) == 2
alice = next(r for r in m.results if r["given_name"] == "Alice")
assert alice["full_name"] == "Alice Example"
assert alice["phone_number"] == "+14155550100"
assert alice["whatsapp_id"] == "14155550100@s.whatsapp.net"
assert alice["lid"] == "100000000000001@lid"
assert alice["user_name"] == "alice.example"
assert alice["disappearing_mode_duration"] == 86400.0
assert alice["disappearing_mode_is_on"] is True
assert alice["disappearing_mode_label"] == "24 hours"
assert alice["disappearing_mode_timestamp"] == "2025-07-23 21:46:40.000000"
assert alice["about_timestamp"] == "2025-07-12 08:00:00.000000"
assert alice["about_expiration_timestamp"] == "2025-08-16 01:20:00.000000"
assert alice["last_updated"] == "2025-08-04 11:33:20.000000"
bob = next(r for r in m.results if r["given_name"] == "Bob")
assert bob["lid"] is None
assert bob["disappearing_mode_duration"] is None
assert bob["disappearing_mode_is_on"] is False
assert bob["disappearing_mode_label"] == "off"
assert bob["disappearing_mode_timestamp"] is None
# Alice: disappearing_mode_set, about_changed, about_expiration and
# contact_last_updated. Bob: contact_last_updated only.
assert len(m.timeline) == 5
events = {
(entry["event"], entry["timestamp"]): entry["data"]
for entry in m.timeline
}
assert (
"24 hours"
in events[("disappearing_mode_set", "2025-07-23 21:46:40.000000")]
)
assert (
"14155550100@s.whatsapp.net (Alice Example)"
in events[("disappearing_mode_set", "2025-07-23 21:46:40.000000")]
)
assert (
'changed to "Hey there! I am using WhatsApp."'
in events[("about_changed", "2025-07-12 08:00:00.000000")]
)
assert (
"scheduled to expire"
in events[("about_expiration", "2025-08-16 01:20:00.000000")]
)
updated = [
entry["data"]
for entry in m.timeline
if entry["event"] == "contact_last_updated"
]
assert len(updated) == 2
assert all(
entry["timestamp"] == "2025-08-04 11:33:20.000000"
for entry in m.timeline
if entry["event"] == "contact_last_updated"
)
assert any("14155550101@s.whatsapp.net (Bob Example)" in d for d in updated)
assert len(m.alertstore.alerts) == 0
def test_missing_database(self, tmp_path):
m = WhatsappContacts(target_path=str(tmp_path))
run_module(m)
assert m.results == []
assert len(m.alertstore.alerts) == 0
+4 -4
View File
@@ -15,8 +15,8 @@ class TestFilesystem:
def test_filesystem(self): def test_filesystem(self):
m = Filesystem(target_path=get_ios_backup_folder()) m = Filesystem(target_path=get_ios_backup_folder())
run_module(m) run_module(m)
assert len(m.results) == 23 assert len(m.results) == 15
assert len(m.timeline) == 23 assert len(m.timeline) == 15
assert len(m.alertstore.alerts) == 0 assert len(m.alertstore.alerts) == 0
def test_detection(self, indicator_file): def test_detection(self, indicator_file):
@@ -29,6 +29,6 @@ class TestFilesystem:
) )
m.indicators = ind m.indicators = ind
run_module(m) run_module(m)
assert len(m.results) == 23 assert len(m.results) == 15
assert len(m.timeline) == 23 assert len(m.timeline) == 15
assert len(m.alertstore.alerts) == 1 assert len(m.alertstore.alerts) == 1
+1 -43
View File
@@ -1,7 +1,5 @@
import io
import tarfile import tarfile
from datetime import timedelta from datetime import timedelta
from pathlib import Path
from mvt.ios.cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose from mvt.ios.cmd_check_sysdiagnose import CmdIOSCheckSysdiagnose
from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction from mvt.ios.modules.sysdiagnose import SysdiagnoseExtraction
@@ -72,46 +70,6 @@ def test_check_sysdiagnose_from_archive_closes_archive(tmp_path):
{"content": "artifact", "timezone_offset": timedelta(hours=2).seconds} {"content": "artifact", "timezone_offset": timedelta(hours=2).seconds}
] ]
assert command.executed[0].ips_files == [ assert command.executed[0].ips_files == [
{ {"file_path": "sysdiagnose/report.ips", "bug_type": 210}
"file_path": str(
Path(command.extracted_sysdiagnose_path) / "report.ips"
),
"bug_type": 210,
}
] ]
assert command.sysdiagnose_archive is None assert command.sysdiagnose_archive is None
def test_archive_is_extracted_once_and_unsafe_members_are_skipped(tmp_path):
archive_path = tmp_path / "sysdiagnose.tar.gz"
escaped_path = tmp_path / "escaped.txt"
content = b"test content"
member = tarfile.TarInfo("sysdiagnose/artifact.txt")
member.size = len(content)
with tarfile.open(archive_path, "w:gz") as archive:
archive.addfile(member, io.BytesIO(content))
escaped = tarfile.TarInfo(f"sysdiagnose/../../{escaped_path.name}")
escaped.size = len(content)
archive.addfile(escaped, io.BytesIO(content))
link = tarfile.TarInfo("sysdiagnose/link")
link.type = tarfile.SYMTYPE
link.linkname = "/etc/hostname"
archive.addfile(link)
command = CmdIOSCheckSysdiagnose(target_path=str(archive_path))
try:
command.init()
extracted_path = Path(command.extracted_sysdiagnose_path)
assert (extracted_path / "artifact.txt").read_bytes() == content
assert not escaped_path.exists()
assert not (extracted_path / "link").exists()
module = SysdiagnoseExtraction()
command.module_init(module)
assert module.tar is None
assert module.parent_path == str(extracted_path.parent)
finally:
command.finish()
assert not extracted_path.exists()
Generated
+741 -888
View File
File diff suppressed because it is too large. Load diff