mirror of
https://github.com/mvt-project/mvt.git
synced 2026-10-22 23:28:23 +02:00
Compare commits
120
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58abbb568 | ||
|
|
d52e9fcc74 | ||
|
|
59c3f5d60c | ||
|
|
b3aa443819 | ||
|
|
aac41ec8c2 | ||
|
|
1e05f438d7 | ||
|
|
10f739d669 | ||
|
|
94e9cd3712 | ||
|
|
300cc26112 | ||
|
|
3c12e1a019 | ||
|
|
c86bc93852 | ||
|
|
3a462e1e2c | ||
|
|
c95eeed825 | ||
|
|
60d1b414fe | ||
|
|
563fe44bf3 | ||
|
|
85fca1c825 | ||
|
|
3704bf26c0 | ||
|
|
a80b0fff2e | ||
|
|
f98f4f6cd2 | ||
|
|
3e99412c32 | ||
|
|
fd37c4d8d9 | ||
|
|
c615d5e304 | ||
|
|
9bdc25f57d | ||
|
|
8238d80035 | ||
|
|
22d97f0a88 | ||
|
|
d224db5a78 | ||
|
|
658c7aa327 | ||
|
|
9316ac7fb0 | ||
|
|
6ddb2c9671 | ||
|
|
165d87b97f | ||
|
|
d2e992e40e | ||
|
|
aa3ae511ad | ||
|
|
7700749cfa | ||
|
|
6eb1a53873 | ||
|
|
02aa23cb81 | ||
|
|
9c3db579ee | ||
|
|
983e970443 | ||
|
|
cfaeb99bf7 | ||
|
|
00a5f11124 | ||
|
|
b1b9958f4d | ||
|
|
ad6caa155f | ||
|
|
c2030e6e84 | ||
|
|
99b3b5f014 | ||
|
|
9ca4254649 | ||
|
|
f1e52b297a | ||
|
|
8c5278f540 | ||
|
|
df3b130ca8 | ||
|
|
3bb652f2d2 | ||
|
|
c399608345 | ||
|
|
7f19d96297 | ||
|
|
ff5ebf73cc | ||
|
|
bdbc07a3b3 | ||
|
|
6f65eee85a | ||
|
|
dbbcdd52df | ||
|
|
777db67291 | ||
|
|
a0bc233eec | ||
|
|
1db5d239d0 | ||
|
|
2426178baa | ||
|
|
e0697a58e9 | ||
|
|
d0fc0379b9 | ||
|
|
7973e90589 | ||
|
|
935747519f | ||
|
|
178ac52aab | ||
|
|
f20fd14075 | ||
|
|
a9d787c446 | ||
|
|
518becb825 | ||
|
|
d0cf303aa2 | ||
|
|
86b217a926 | ||
|
|
a3d289c709 | ||
|
|
b4b0a06470 | ||
|
|
eceafdc785 | ||
|
|
ffae240355 | ||
|
|
3623e430f4 | ||
|
|
60e652bafc | ||
|
|
92f780df5a | ||
|
|
2eb40b85cf | ||
|
|
b9055e365c | ||
|
|
fc217e9608 | ||
|
|
d7148c03b4 | ||
|
|
0ddcab904d | ||
|
|
858496e60b | ||
|
|
e5fced2a08 | ||
|
|
fe505c63a6 | ||
|
|
25a571c462 | ||
|
|
03012d2c2d | ||
|
|
0e231eefaf | ||
|
|
a463e8509c | ||
|
|
ca063a2ddc | ||
|
|
ece44a7e7f | ||
|
|
27cfe7f1e7 | ||
|
|
531d63ab07 | ||
|
|
b18afb4a48 | ||
|
|
32c88822a0 | ||
|
|
23565af4c1 | ||
|
|
24c65859ee | ||
|
|
65df483258 | ||
|
|
fd27fe3378 | ||
|
|
7ef26779bf | ||
|
|
3a43f6fcc7 | ||
|
|
45fdf85790 | ||
|
|
627ab32d42 | ||
|
|
8b85972cc3 | ||
|
|
f747751b09 | ||
|
|
832e46604d | ||
|
|
a3c8b56102 | ||
|
|
e9bf197ec6 | ||
|
|
288b313649 | ||
|
|
87a27f6e37 | ||
|
|
1bf2f9b35a | ||
|
|
b904414bb0 | ||
|
|
008844b440 | ||
|
|
ab879bb23b | ||
|
|
dc0650bf76 | ||
|
|
545ac19158 | ||
|
|
2de8312349 | ||
|
|
2fe7f4e261 | ||
|
|
361bc72a4e | ||
|
|
9d71661f55 | ||
|
|
8b195d2696 | ||
|
|
39a26d0f0b |
No files matched your search
@@ -37,6 +37,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # the package version comes from the git tags
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1" # Mondays, 09:00 UTC
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
if: github.ref_type != 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: |
|
||||
last=$(git describe --tags --abbrev=0 --match 'v*')
|
||||
if [ "$(git rev-list "$last..HEAD" --count)" = 0 ]; then
|
||||
echo "Nothing merged since $last"; exit 0
|
||||
fi
|
||||
tag=v$(date -u +%Y.%-m.%-d)
|
||||
gh release create "$tag" --target "$GITHUB_SHA" --generate-notes
|
||||
# Events made with GITHUB_TOKEN don't start other workflows; run them by hand.
|
||||
gh workflow run release.yml --ref "$tag"
|
||||
gh workflow run publish-release-docker.yml --ref "$tag"
|
||||
|
||||
publish:
|
||||
if: github.ref_type == 'tag'
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi # only tag refs may deploy; the PyPI trusted publisher requires it
|
||||
permissions:
|
||||
id-token: write # PyPI trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: astral-sh/setup-uv@v10.0.0
|
||||
- run: uv build && uv publish
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # coverage comment
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Run Python Tests
|
||||
@@ -13,6 +17,10 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
env:
|
||||
# Takes precedence over .python-version, which otherwise makes `uv run`
|
||||
# rebuild the venv with 3.10 and test every matrix entry on 3.10.
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -26,16 +34,23 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
uv sync --locked --group dev --python ${{ matrix.python-version }}
|
||||
uv sync --locked --group dev
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
set -o pipefail
|
||||
make test-ci | tee pytest-coverage.txt
|
||||
- name: Coverage job summary
|
||||
run: uv run coverage report --format=markdown --show-missing --skip-covered >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Pytest coverage comment
|
||||
continue-on-error: true # Workflows running on a fork can't post comments
|
||||
uses: MishaKav/pytest-coverage-comment@main
|
||||
if: github.event_name == 'pull_request'
|
||||
# One comment per PR, not one per matrix entry. PRs from forks get a
|
||||
# read-only token and can't post; the job summary above still works.
|
||||
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
|
||||
continue-on-error: true
|
||||
uses: MishaKav/pytest-coverage-comment@v1.12.2
|
||||
with:
|
||||
pytest-coverage-path: ./pytest-coverage.txt
|
||||
junitxml-path: ./pytest.xml
|
||||
# The full table with per-line links exceeds GitHub's 65536-char comment limit.
|
||||
report-only-changed-files: true
|
||||
remove-links-to-lines: true
|
||||
+14
-116
@@ -1,107 +1,3 @@
|
||||
# Base image for building libraries
|
||||
# ---------------------------------
|
||||
FROM ubuntu:22.04 AS build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install build tools and dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool-bin \
|
||||
pkg-config \
|
||||
libcurl4-openssl-dev \
|
||||
libusb-1.0-0-dev \
|
||||
libssl-dev \
|
||||
udev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# libplist
|
||||
# --------
|
||||
FROM build-base AS build-libplist
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libplist
|
||||
|
||||
|
||||
# libimobiledevice-glue
|
||||
# ---------------------
|
||||
FROM build-base AS build-libimobiledevice-glue
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libimobiledevice-glue
|
||||
|
||||
|
||||
# libtatsu
|
||||
# --------
|
||||
FROM build-base AS build-libtatsu
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libtatsu
|
||||
|
||||
|
||||
# libusbmuxd
|
||||
# ----------
|
||||
FROM build-base AS build-libusbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libusbmuxd
|
||||
|
||||
|
||||
# libimobiledevice
|
||||
# ----------------
|
||||
FROM build-base AS build-libimobiledevice
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \
|
||||
&& ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libimobiledevice
|
||||
|
||||
|
||||
# usbmuxd
|
||||
# -------
|
||||
FROM build-base AS build-usbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
COPY --from=build-libimobiledevice /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
|
||||
&& ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib
|
||||
|
||||
|
||||
# Create main image
|
||||
FROM ubuntu:24.04 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
@@ -112,7 +8,7 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
|
||||
LABEL org.opencontainers.image.title="Mobile Verification Toolkit"
|
||||
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
|
||||
LABEL org.opencontainers.image.licenses="MVT License 1.1"
|
||||
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:22.04
|
||||
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04
|
||||
|
||||
# Install runtime dependencies
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
@@ -122,9 +18,9 @@ RUN apt-get update \
|
||||
binutils \
|
||||
default-jre-headless \
|
||||
file \
|
||||
git \
|
||||
jq \
|
||||
less \
|
||||
libcurl4 \
|
||||
libimage-exiftool-perl \
|
||||
libssl3 \
|
||||
libusb-1.0-0 \
|
||||
@@ -135,18 +31,20 @@ RUN apt-get update \
|
||||
sqlite3 \
|
||||
tree \
|
||||
unzip \
|
||||
xxd
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
COPY --from=build-libimobiledevice /build /
|
||||
COPY --from=build-usbmuxd /build /
|
||||
xxd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install mvt using the locally checked out source
|
||||
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
|
||||
ARG PYMOBILEDEVICE3_VERSION=11.12.4
|
||||
COPY . mvt/
|
||||
RUN uv pip install --system --break-system-packages --no-cache ./mvt \
|
||||
&& rm -rf mvt
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \
|
||||
&& uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \
|
||||
&& apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \
|
||||
&& rm -rf mvt /var/lib/apt/lists/* \
|
||||
&& pymobiledevice3 --help > /dev/null \
|
||||
&& pymobiledevice3 backup2 --help > /dev/null \
|
||||
&& mvt-ios --help > /dev/null
|
||||
|
||||
# Installing ABE
|
||||
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
|
||||
|
||||
+22
-122
@@ -1,110 +1,6 @@
|
||||
# Base image for building libraries
|
||||
# ---------------------------------
|
||||
FROM ubuntu:22.04 AS build-base
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install build tools and dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool-bin \
|
||||
pkg-config \
|
||||
libcurl4-openssl-dev \
|
||||
libusb-1.0-0-dev \
|
||||
libssl-dev \
|
||||
udev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# libplist
|
||||
# --------
|
||||
FROM build-base AS build-libplist
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libplist
|
||||
|
||||
|
||||
# libimobiledevice-glue
|
||||
# ---------------------
|
||||
FROM build-base AS build-libimobiledevice-glue
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libimobiledevice-glue
|
||||
|
||||
|
||||
# libtatsu
|
||||
# --------
|
||||
FROM build-base AS build-libtatsu
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libtatsu
|
||||
|
||||
|
||||
# libusbmuxd
|
||||
# ----------
|
||||
FROM build-base AS build-libusbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
|
||||
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libusbmuxd
|
||||
|
||||
|
||||
# libimobiledevice
|
||||
# ----------------
|
||||
FROM build-base AS build-libimobiledevice
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \
|
||||
&& ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf libimobiledevice
|
||||
|
||||
|
||||
# usbmuxd
|
||||
# -------
|
||||
FROM build-base AS build-usbmuxd
|
||||
|
||||
# Install dependencies
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
COPY --from=build-libimobiledevice /build /
|
||||
|
||||
# Build
|
||||
RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
|
||||
&& ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \
|
||||
&& cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib
|
||||
|
||||
|
||||
# Main image
|
||||
# ----------
|
||||
FROM python:3.10.14-alpine3.20 AS main
|
||||
FROM ubuntu:24.04 AS main
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
|
||||
|
||||
LABEL org.opencontainers.image.url="https://mvt.re"
|
||||
@@ -113,26 +9,30 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
|
||||
LABEL org.opencontainers.image.title="Mobile Verification Toolkit (iOS)"
|
||||
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
|
||||
LABEL org.opencontainers.image.licenses="MVT License 1.1"
|
||||
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.10.14-alpine3.20
|
||||
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
gcompat \
|
||||
libcurl \
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
git \
|
||||
libssl3 \
|
||||
libusb \
|
||||
sqlite
|
||||
COPY --from=build-libplist /build /
|
||||
COPY --from=build-libimobiledevice-glue /build /
|
||||
COPY --from=build-libtatsu /build /
|
||||
COPY --from=build-libusbmuxd /build /
|
||||
COPY --from=build-libimobiledevice /build /
|
||||
COPY --from=build-usbmuxd /build /
|
||||
libusb-1.0-0 \
|
||||
python3 \
|
||||
sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install mvt using the locally checked out source
|
||||
COPY ./ mvt
|
||||
RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \
|
||||
&& uv pip install --system --no-cache ./mvt \
|
||||
&& apk del .build-deps git gcc musl-dev && rm -rf ./mvt
|
||||
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
|
||||
ARG PYMOBILEDEVICE3_VERSION=11.12.4
|
||||
COPY . mvt/
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \
|
||||
&& uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \
|
||||
&& apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \
|
||||
&& rm -rf mvt /var/lib/apt/lists/* \
|
||||
&& pymobiledevice3 --help > /dev/null \
|
||||
&& pymobiledevice3 backup2 --help > /dev/null \
|
||||
&& mvt-ios --help > /dev/null
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
|
||||
@@ -0,0 +1,4 @@
|
||||
prune tests
|
||||
prune docs
|
||||
prune .github
|
||||
exclude uv.lock Dockerfile* mkdocs.yml .readthedocs.yaml .safety-policy.yml .python-version .gitignore Makefile
|
||||
@@ -31,9 +31,3 @@ clean:
|
||||
|
||||
dist:
|
||||
$(UV) build
|
||||
|
||||
upload:
|
||||
$(UV) tool run twine upload dist/*
|
||||
|
||||
test-upload:
|
||||
$(UV) tool run twine upload --repository testpypi dist/*
|
||||
@@ -86,6 +86,11 @@ writing and installing them, and the
|
||||
[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
|
||||
for the entry points a package registers commands in.
|
||||
|
||||
Extraction commands run independent modules concurrently with four workers by
|
||||
default. Use `--jobs 1` for sequential execution or another positive value to
|
||||
set the worker limit. Dependencies retain their declared ordering, and parallel
|
||||
console logs are printed as grouped module blocks.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -46,6 +46,33 @@ on it, and runs the rest of the analysis. Selecting such a module with
|
||||
A cycle in the dependency graph is a programming error rather than a
|
||||
configuration problem: the command logs a warning and runs no modules at all.
|
||||
|
||||
## Parallel module execution
|
||||
|
||||
Extraction modules run concurrently, using four worker threads by default. Use
|
||||
`--jobs INTEGER` to change the worker limit, or `--jobs 1` for sequential
|
||||
execution and live per-line logging:
|
||||
|
||||
```bash
|
||||
mvt-ios check-backup --jobs 8 --output ./out ./backup
|
||||
```
|
||||
|
||||
The scheduler starts a module only after all of its declared dependencies have
|
||||
completed. Results, alerts, and timeline entries are still aggregated in stable
|
||||
topological order. During parallel execution, console records from each module
|
||||
are buffered and printed together as a labeled block when that module finishes;
|
||||
`command.log` is written immediately and retains the complete log stream.
|
||||
|
||||
Parallel-safe modules must log through `self.log`, must not write directly to
|
||||
stdout or stderr, and must not mutate shared global state. A module that uses a
|
||||
non-thread-safe resource or direct terminal output can opt out:
|
||||
|
||||
```python
|
||||
class TerminalModule(MVTModule):
|
||||
parallel_safe = False
|
||||
```
|
||||
|
||||
Such modules run synchronously and exclusively after active workers finish.
|
||||
|
||||
## Custom modules
|
||||
|
||||
MVT's module-running `check-*` commands can run forensic modules which are not
|
||||
@@ -382,7 +409,8 @@ module came from:
|
||||
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
|
||||
take to avoid inefficient code paths as we add new modules.
|
||||
|
||||
MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_PROFILE` environment variable.
|
||||
MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_PROFILE` environment variable. Profiling forces sequential execution even
|
||||
when `--jobs` is greater than one.
|
||||
|
||||
```bash
|
||||
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
|
||||
|
||||
+25
-4
@@ -1,4 +1,6 @@
|
||||
Using Docker simplifies having all the required dependencies and tools (including most recent versions of [libimobiledevice](https://libimobiledevice.org)) readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container).
|
||||
Using Docker simplifies running MVT with its dependencies readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container).
|
||||
|
||||
The main and iOS Docker images include pymobiledevice3 for [creating iOS backups](ios/backup/pymobiledevice3.md). You do not need to install it separately on the host. Older image tags may still include libimobiledevice instead; build from the updated source if pymobiledevice3 is unavailable.
|
||||
|
||||
Install Docker following the [official documentation](https://docs.docker.com/get-docker/).
|
||||
|
||||
@@ -37,12 +39,31 @@ If a prompt is spawned successfully, you can close it with `exit`.
|
||||
|
||||
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:
|
||||
Bind that socket into the container to let pymobiledevice3 communicate with the device. Also mount a local directory so acquired backups persist after the container exits:
|
||||
|
||||
```bash
|
||||
docker run -it \
|
||||
mkdir -p "$PWD/cases"
|
||||
docker run --rm -it \
|
||||
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
|
||||
--mount type=bind,source="$PWD/cases",target=/home/cases \
|
||||
ghcr.io/mvt-project/mvt
|
||||
```
|
||||
|
||||
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
|
||||
Inside the container, verify connectivity:
|
||||
|
||||
```bash
|
||||
pymobiledevice3 lockdown info
|
||||
```
|
||||
|
||||
Accept the trust prompt on the unlocked device if requested. Then follow the [backup instructions](ios/backup/pymobiledevice3.md), using a destination under `/home/cases`, such as `/home/cases/backup`.
|
||||
|
||||
The iOS-only image defaults to running `mvt-ios`. To use pymobiledevice3 instead, override its entrypoint:
|
||||
|
||||
```bash
|
||||
docker run --rm -it \
|
||||
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
|
||||
--entrypoint pymobiledevice3 \
|
||||
ghcr.io/mvt-project/mvt:latest-ios lockdown info
|
||||
```
|
||||
|
||||
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Installation
|
||||
|
||||
Before proceeding, please note that MVT requires Python 3.6+ to run. While it should be available on most operating systems, please make sure of that before proceeding.
|
||||
Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
|
||||
|
||||
## Dependencies on Linux
|
||||
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
# Backup with libimobiledevice
|
||||
|
||||
If you have correctly [installed libimobiledevice](../install.md) you can easily generate an iTunes backup using the `idevicebackup2` tool included in the suite. First, you might want to ensure that backup encryption is enabled (**note: encrypted backup contain more data than unencrypted backups**):
|
||||
|
||||
```bash
|
||||
idevicebackup2 -i encryption on
|
||||
```
|
||||
|
||||
Note that if a backup password was previously set on this device, you might need to use the same or change it. You can try changing password using `idevicebackup2 -i changepw`, or by turning off encryption (`idevicebackup2 -i encryption off`) and turning it back on again.
|
||||
|
||||
If you are not able to recover or change the password, you should try to disable encryption and obtain an unencrypted backup.
|
||||
|
||||
If all else fails, as a *last resort* you can try resetting the password by [resetting all the settings through the iPhone's Settings app](https://support.apple.com/en-us/HT205220), via `Settings » General » Reset » Reset All Settings`. Note that resetting the settings through the iPhone's Settings app will wipe some of the files that contain useful forensic traces, so try the options explained above first.
|
||||
|
||||
Once ready, you can proceed performing the backup:
|
||||
|
||||
```bash
|
||||
idevicebackup2 backup --full /path/to/backup/
|
||||
```
|
||||
We now recommend pymobiledevice3 for iOS backups. See [Backup with pymobiledevice3](pymobiledevice3.md) for the current instructions.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Backup with pymobiledevice3
|
||||
|
||||
After [installing pymobiledevice3](../install.md) and pairing your device, you can generate an iTunes-compatible backup using its `backup2` commands.
|
||||
|
||||
## Enable backup encryption
|
||||
|
||||
We recommend encrypted backups because they contain more data than unencrypted backups. If encryption is not already enabled, enable it with:
|
||||
|
||||
```bash
|
||||
pymobiledevice3 backup2 encryption on 'YOUR_BACKUP_PASSWORD'
|
||||
```
|
||||
|
||||
Replace `YOUR_BACKUP_PASSWORD` with a strong password and keep it safe: you will need it to decrypt the backup for analysis. Passwords supplied on the command line may be saved in shell history or visible to other processes.
|
||||
|
||||
If a backup password was previously set, use that password. To change a known password:
|
||||
|
||||
```bash
|
||||
pymobiledevice3 backup2 change-password 'CURRENT_PASSWORD' 'NEW_PASSWORD'
|
||||
```
|
||||
|
||||
You can also disable encryption with `pymobiledevice3 backup2 encryption off 'CURRENT_PASSWORD'` and then enable it again. Disabling encryption requires the current password; it is not a way to bypass an unknown password.
|
||||
|
||||
!!! warning
|
||||
If you cannot recover the password, resetting it through [Reset All Settings in the iPhone's Settings app](https://support.apple.com/en-us/HT205220) should be a last resort. Resetting settings can remove files containing useful forensic traces.
|
||||
|
||||
## Create a backup
|
||||
|
||||
Choose a new destination directory to avoid overwriting an earlier acquisition:
|
||||
|
||||
```bash
|
||||
mkdir -p /path/to/backup/
|
||||
pymobiledevice3 backup2 backup --full /path/to/backup/
|
||||
```
|
||||
|
||||
The backup is saved in a subdirectory named after the device's UDID, such as `/path/to/backup/udid/`. Follow [Check a Backup with mvt-ios](check.md) to decrypt and analyze that directory.
|
||||
|
||||
For additional options, see the [pymobiledevice3 backup2 reference](https://doronz88.github.io/pymobiledevice3/cli/backup2/).
|
||||
+20
-31
@@ -1,55 +1,44 @@
|
||||
# Install libimobiledevice
|
||||
# Install pymobiledevice3
|
||||
|
||||
Before proceeding with doing any acquisition of iOS devices we recommend installing [libimobiledevice](https://libimobiledevice.org/) utilities. These utilities will become useful when extracting crash logs and generating iTunes backups. Because the utilities and its libraries are subject to frequent changes in response to new versions of iOS, you might want to consider compiling libimobiledevice utilities from sources. Otherwise, if available, you can try installing packages available in your distribution:
|
||||
Before acquiring data from an iOS device, we recommend installing [pymobiledevice3](https://github.com/doronz88/pymobiledevice3). It provides command-line tools for generating iTunes-compatible backups and extracting crash logs.
|
||||
|
||||
## Installation
|
||||
|
||||
Install `pipx` following the [MVT installation instructions](../install.md), then run:
|
||||
|
||||
```bash
|
||||
sudo apt install libimobiledevice-utils
|
||||
pipx install pymobiledevice3
|
||||
```
|
||||
|
||||
On Mac, you can try installing it from brew:
|
||||
To update an existing installation:
|
||||
|
||||
```bash
|
||||
brew install --HEAD libimobiledevice
|
||||
pipx upgrade pymobiledevice3
|
||||
```
|
||||
|
||||
If you have a reasonably recent version of libimobiledevice in your package manager, it might work straight out of the box. Try connecting your iOS device to your computer via USB and run:
|
||||
On Linux, you also need `usbmuxd` to communicate with devices over USB. On Debian and Ubuntu, install it with:
|
||||
|
||||
```bash
|
||||
ideviceinfo
|
||||
sudo apt install usbmuxd
|
||||
```
|
||||
|
||||
If you encounter unexpected issues, uninstall the packages and try compiling libimobiledevcice from sources.
|
||||
macOS includes the required USB device service. For other platforms and additional requirements, see the [pymobiledevice3 installation documentation](https://doronz88.github.io/pymobiledevice3/installation/).
|
||||
|
||||
## Compile libimobiledevice from sources
|
||||
## Verify connectivity
|
||||
|
||||
!!! warning
|
||||
The following instructions are a best effort. The installation from source requires several steps, and it is likely some have been forgotten here and that won't work for you. You will likely need to fiddle around a bit before getting this right.
|
||||
|
||||
Make sure you have uninstalled all the libimobiledevice tools from your package manage:
|
||||
Connect the iOS device to your computer with a USB cable and unlock it. Accept the **Trust This Computer** prompt and enter the device passcode if requested. Then run:
|
||||
|
||||
```bash
|
||||
sudo apt remove --purge libimobiledevice-utils libimobiledevice-dev libimobiledevice6 libplist-dev libplist3 libusbmuxd-dev libusbmuxd-tools libusbmuxd4 libusbmuxd6 usbmuxd
|
||||
pymobiledevice3 usbmux list
|
||||
pymobiledevice3 lockdown info
|
||||
```
|
||||
|
||||
Firstly you need to install [libplist](https://github.com/libimobiledevice/libplist). Then you can install [libusbmuxd](https://github.com/libimobiledevice/libusbmuxd).
|
||||
|
||||
Now you should be able to to download and install the actual suite of tools at [https://github.com/libimobiledevice/libimobiledevice](https://github.com/libimobiledevice/libimobiledevice).
|
||||
|
||||
You can now also build and install [usbmuxd](https://github.com/libimobiledevice/usbmuxd).
|
||||
|
||||
## Making sure everything works fine.
|
||||
|
||||
Once the idevice tools are available you can check if everything works fine by connecting your iOS device and running:
|
||||
These commands should list the connected device and display its details. If pairing is needed, run:
|
||||
|
||||
```bash
|
||||
ideviceinfo
|
||||
pymobiledevice3 lockdown pair
|
||||
```
|
||||
|
||||
This should show many details on the connected iOS device. If you are connecting the device to your laptop for the first time, it will require to unlock and enter the PIN code on the mobile device. If it complains that no device is connected and the mobile device is indeed plugged in through the USB cable, you might need to do this first, although typically the pairing is automatically done when connecting the device:
|
||||
If no device is found, check the USB connection, make sure the device is unlocked, and, on Linux, check that `usbmuxd` is running. See the [upstream troubleshooting guide](https://doronz88.github.io/pymobiledevice3/guides/troubleshooting/) for further help.
|
||||
|
||||
```bash
|
||||
sudo usbmuxd -f -v
|
||||
idevicepair pair
|
||||
```
|
||||
|
||||
Again, it will ask to unlock the phone and enter the PIN code.
|
||||
Once connected, follow the [backup instructions](backup/pymobiledevice3.md).
|
||||
@@ -204,6 +204,10 @@ This JSON file is created by mvt-ios' `Manifest` module. The module extracts rec
|
||||
|
||||
If indicators are provided through the command-line, they are checked against the original relative path in case. In some cases, there might be records of files created containing a domain name in their name, for example in the case of browser cache folders. Any matches are stored in *manifest_detected.json*.
|
||||
|
||||
An incomplete backup still lists in its manifest the files it failed to acquire. Those records carry a `"missing": true` field, and the module reports how many of them it found. Use it to tell a module which returned nothing because the artifact was not acquired apart from one which found nothing on a device that never had it.
|
||||
|
||||
Files must be stored at their expected paths inside the backup folder. If the module cannot finish listing the backup files, it logs a warning and skips the missing-file check; the manifest metadata is still extracted.
|
||||
|
||||
---
|
||||
|
||||
### `os_analytics_ad_daily.json`
|
||||
@@ -435,3 +439,11 @@ This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extr
|
||||
|
||||
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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Records extracted by `check-sysdiagnose`
|
||||
|
||||
### `sysdiagnose_info.json`
|
||||
|
||||
This JSON file is created by mvt-ios' `SysdiagnoseInfo` module. The module extracts details about the device and the sysdiagnose itself: the product type and model, iOS version and build, serial number, IMEI, MEID and UDID from the remotectl dump state and the mobile activation request, the Apple account name and email from the App Store daemon database (no longer part of a sysdiagnose on newer iOS versions, still read from older archives), and the original file name and creation time of the archive from *sysdiagnose.log*.
|
||||
@@ -1,10 +1,13 @@
|
||||
# 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. The
|
||||
command runs the modules of the installed
|
||||
`mvt-ios check-sysdiagnose` analyzes an iOS sysdiagnose archive. MVT's own
|
||||
`SysdiagnoseInfo` module extracts details about the device and the archive
|
||||
(see [`sysdiagnose_info.json`](records.md#sysdiagnose_infojson)); the checks
|
||||
come from the modules of the installed
|
||||
[plugin packages](../development/index.md#installed-module-packages) which
|
||||
declare support for it. Install at least one such package first.
|
||||
declare support for the command. Without any such module the command still
|
||||
records the device details, and warns that no forensic sysdiagnose modules
|
||||
have been loaded so that the run cannot pass for a clean analysis.
|
||||
|
||||
The command accepts either an extracted sysdiagnose directory or the original
|
||||
gzip-compressed tar archive.
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ copyright: Copyright © 2021-2023 MVT Project Developers
|
||||
site_description: Mobile Verification Toolkit Documentation
|
||||
not_in_nav: |
|
||||
android/adb.md
|
||||
ios/backup/libimobiledevice.md
|
||||
markdown_extensions:
|
||||
- attr_list
|
||||
- admonition
|
||||
@@ -34,10 +35,10 @@ nav:
|
||||
- Using Docker: "docker.md"
|
||||
- MVT for iOS:
|
||||
- iOS Forensic Methodology: "ios/methodology.md"
|
||||
- Install libimobiledevice: "ios/install.md"
|
||||
- Install pymobiledevice3: "ios/install.md"
|
||||
- Check an iTunes Backup:
|
||||
- Backup with iTunes app: "ios/backup/itunes.md"
|
||||
- Backup with libimobiledevice: "ios/backup/libimobiledevice.md"
|
||||
- Backup with pymobiledevice3: "ios/backup/pymobiledevice3.md"
|
||||
- Check a Backup with mvt-ios: "ios/backup/check.md"
|
||||
- Check a Filesystem Dump:
|
||||
- Dumping the filesystem: "ios/filesystem/dump.md"
|
||||
|
||||
+6
-6
@@ -24,9 +24,8 @@ dependencies = [
|
||||
"simplejson==4.1.1",
|
||||
"packaging==26.3",
|
||||
"appdirs==1.4.4",
|
||||
"iOSbackup==0.9.925",
|
||||
"adb-shell[usb]==0.4.4",
|
||||
"libusb1==3.4.0",
|
||||
"iphone_backup_decrypt==0.10.0",
|
||||
"pycryptodome>=3.20.0",
|
||||
"cryptography==50.0.0",
|
||||
"PyYAML>=6.0.2",
|
||||
"pyahocorasick==2.3.1",
|
||||
@@ -72,7 +71,7 @@ docs = [
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.coverage.run]
|
||||
@@ -121,5 +120,6 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
mvt = ["ios/data/*.json"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "mvt.common.version.MVT_VERSION" }
|
||||
[tool.setuptools_scm]
|
||||
# The version is the latest v* tag; ignore the archive/* tags.
|
||||
git_describe_command = "git describe --dirty --tags --long --match 'v*'"
|
||||
@@ -4,13 +4,51 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
# One list for both record shapes — a service record and a count-only
|
||||
# record must stay the same shape.
|
||||
_FIELDS = (
|
||||
"user_id",
|
||||
"component",
|
||||
"package_name",
|
||||
"service_name",
|
||||
"installed",
|
||||
"enabled",
|
||||
"binding",
|
||||
"bound",
|
||||
"crashed",
|
||||
"accessibility_tool",
|
||||
"installed_service_count",
|
||||
"unnamed_service_count",
|
||||
)
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for result in self.results:
|
||||
# A stated count the dump does not back with component names is a
|
||||
# coverage statement, not a service: low, but not silent.
|
||||
if not result.get("component"):
|
||||
stated = result["installed_service_count"]
|
||||
unnamed = result["unnamed_service_count"]
|
||||
if unnamed == stated:
|
||||
detail = "does not list their component names"
|
||||
else:
|
||||
detail = (
|
||||
f"lists the component names of only {stated - unnamed} "
|
||||
f"of them ({unnamed} unnamed)"
|
||||
)
|
||||
self.alertstore.low(
|
||||
f"The accessibility dump states {stated} installed "
|
||||
f"service(s) for user {result['user_id']} but {detail}",
|
||||
"",
|
||||
result,
|
||||
)
|
||||
continue
|
||||
|
||||
if self.indicators:
|
||||
ioc_match = self.indicators.check_app_id(result["package_name"])
|
||||
if ioc_match:
|
||||
@@ -19,11 +57,22 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
)
|
||||
continue
|
||||
|
||||
self.alertstore.medium(
|
||||
f'Found accessibility service: "{result["service"]}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
# Installed is not enabled. A service can sit installed for years
|
||||
# without ever being switched on, and that is the difference this
|
||||
# channel is read for — an alert that says only "found" makes every
|
||||
# device look equally exposed. A service the dump says is switched
|
||||
# OFF is reported LOW, so it still reaches the analyst without
|
||||
# competing with one that is actually running; a dump that does not
|
||||
# state the enabled state stays MEDIUM, because "not stated" is not
|
||||
# "not enabled".
|
||||
message = (
|
||||
f'Found accessibility service: "{result["component"]}" '
|
||||
f"({self._describe_state(result)})"
|
||||
)
|
||||
if result.get("enabled") is False and not result.get("bound"):
|
||||
self.alertstore.low(message, "", result)
|
||||
else:
|
||||
self.alertstore.medium(message, "", result)
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
"""
|
||||
@@ -33,41 +82,122 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
|
||||
:param content: content of the accessibility section (string)
|
||||
"""
|
||||
|
||||
# "Old" syntax
|
||||
in_services = False
|
||||
self.results: list[dict[str, Any]] = []
|
||||
services: dict[tuple[int | None, str], dict] = {}
|
||||
# Which state sections the dump printed, per user: one user's printed
|
||||
# `enabled services` says nothing about another user's.
|
||||
seen_states: dict[int | None, set[str]] = {}
|
||||
# `installedServiceCount=N` from the user's `attributes:{…}` line is on
|
||||
# most builds the only statement about installed services in the dump:
|
||||
# few print the `installed services: {…}` block.
|
||||
installed_counts: dict[int | None, int] = {}
|
||||
user_id: int | None = None
|
||||
state: str | None = None
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("installed services:"):
|
||||
in_services = True
|
||||
continue
|
||||
user_match = re.search(r"attributes:\{id=(\d+)", line)
|
||||
if user_match:
|
||||
user_id = int(user_match.group(1))
|
||||
|
||||
if not in_services:
|
||||
continue
|
||||
count_match = re.search(r"installedServiceCount=(\d+)", line)
|
||||
if count_match:
|
||||
installed_counts[user_id] = int(count_match.group(1))
|
||||
|
||||
if line.strip() == "}":
|
||||
# At end of installed services
|
||||
break
|
||||
|
||||
service = line.split(":")[1].strip()
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"package_name": service.split("/")[0],
|
||||
"service": service,
|
||||
}
|
||||
stripped = line.strip()
|
||||
state_match = re.match(
|
||||
r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)",
|
||||
stripped,
|
||||
)
|
||||
|
||||
# "New" syntax - AOSP >= 14 (?)
|
||||
# Looks like:
|
||||
# Enabled services:{{com.azure.authenticator/com.microsoft.brooklyn.module.accessibility.BrooklynAccessibilityService}, {com.agilebits.onepassword/com.agilebits.onepassword.filling.accessibility.FillingAccessibilityService}}
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("Enabled services:"):
|
||||
matches = re.finditer(r"{([^{]+?)}", line)
|
||||
|
||||
for match in matches:
|
||||
# Each match is in format: <package_name>/<service>
|
||||
package_name, _, service = match.group(1).partition("/")
|
||||
|
||||
self.results.append(
|
||||
{"package_name": package_name, "service": service}
|
||||
if state_match:
|
||||
state = state_match.group(1).lower()
|
||||
seen_states.setdefault(user_id, set()).add(self._state_field(state))
|
||||
inline = state_match.group(2)
|
||||
for component in re.findall(
|
||||
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
|
||||
):
|
||||
service = services.setdefault(
|
||||
(user_id, component), self._new_service(component, user_id)
|
||||
)
|
||||
service[self._state_field(state)] = True
|
||||
service["accessibility_tool"] = "(A11yTool)" in inline
|
||||
continue
|
||||
|
||||
if not state:
|
||||
continue
|
||||
if stripped == "}" or stripped.startswith("AccessibilityInputFilter"):
|
||||
state = None
|
||||
continue
|
||||
component_match = re.search(
|
||||
r"(?:\d+\s*:\s*)?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?",
|
||||
stripped,
|
||||
)
|
||||
if component_match:
|
||||
component = component_match.group(1)
|
||||
service = services.setdefault(
|
||||
(user_id, component), self._new_service(component, user_id)
|
||||
)
|
||||
service[self._state_field(state)] = True
|
||||
service["accessibility_tool"] = "(A11yTool)" in stripped
|
||||
|
||||
# A section that was never printed is NOT the same as one printed
|
||||
# empty: the first says nothing, the second says nothing is enabled.
|
||||
# Defaulting every flag to False would turn "not stated" into "not
|
||||
# enabled". Flags for sections this dump never printed stay None.
|
||||
named: dict[int | None, int] = {}
|
||||
for (service_user, _component), service in services.items():
|
||||
printed = seen_states.get(service_user, set())
|
||||
for state in ("installed", "enabled", "binding", "bound", "crashed"):
|
||||
if self._state_field(state) not in printed:
|
||||
service[self._state_field(state)] = None
|
||||
service["installed_service_count"] = installed_counts.get(service_user)
|
||||
named[service_user] = named.get(service_user, 0) + 1
|
||||
|
||||
self.results.extend(services.values())
|
||||
|
||||
# A stated count the named services do not add up to would leave no
|
||||
# trace of the rest: the module would log "a total of 0" about a dump
|
||||
# that said five, or "a total of 1" about one that said two.
|
||||
for count_user, count in installed_counts.items():
|
||||
unnamed = count - named.get(count_user, 0)
|
||||
if unnamed > 0:
|
||||
self.results.append(self._new_unlisted(count_user, count, unnamed))
|
||||
|
||||
@staticmethod
|
||||
def _describe_state(result: dict) -> str:
|
||||
if result.get("bound"):
|
||||
return "enabled and bound"
|
||||
if result.get("enabled"):
|
||||
return "enabled"
|
||||
if result.get("enabled") is None:
|
||||
return "installed, enabled state not stated"
|
||||
return "installed, not enabled"
|
||||
|
||||
@staticmethod
|
||||
def _state_field(state: str) -> str:
|
||||
return {"binding": "binding", "bound": "bound"}.get(state, state)
|
||||
|
||||
@staticmethod
|
||||
def _new_unlisted(user_id: int | None, count: int, unnamed: int) -> dict:
|
||||
"""The dump's own count for a user, and how many of those services it
|
||||
did not name.
|
||||
|
||||
Every other field stays unknown: the dump named no service to carry it.
|
||||
"""
|
||||
record: dict[str, Any] = dict.fromkeys(DumpsysAccessibilityArtifact._FIELDS)
|
||||
record["user_id"] = user_id
|
||||
record["installed_service_count"] = count
|
||||
record["unnamed_service_count"] = unnamed
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def _new_service(component: str, user_id: int | None) -> dict:
|
||||
record: dict[str, Any] = dict.fromkeys(
|
||||
DumpsysAccessibilityArtifact._FIELDS, False
|
||||
)
|
||||
record["user_id"] = user_id
|
||||
record["component"] = component
|
||||
record["package_name"], record["service_name"] = component.split("/", 1)
|
||||
# Filled in after parsing: the count is per user.
|
||||
record["installed_service_count"] = None
|
||||
record["unnamed_service_count"] = None
|
||||
return record
|
||||
@@ -6,6 +6,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
@@ -28,11 +29,26 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
stack = [res]
|
||||
cur_indent = 0
|
||||
in_multiline = False
|
||||
for line in dump_data.strip(b"\n").split(b"\n"):
|
||||
for line in dump_data.strip(b"\r\n").split(b"\n"):
|
||||
line = line.removesuffix(b"\r")
|
||||
# Track the level of indentation
|
||||
indent = len(line) - len(line.lstrip())
|
||||
if indent < cur_indent:
|
||||
# A closing brace is dedented from the block it closes, and the
|
||||
# brace handler below pops that block. Popping on the dedent as
|
||||
# well would close two levels for one brace. A blank line has no
|
||||
# indentation to compare: it ends a multiline value (handled
|
||||
# below) and is otherwise skipped.
|
||||
if indent < cur_indent and line.strip() not in (b"", b"}"):
|
||||
# If the current line is less indented than the previous one, back out
|
||||
if len(stack) <= 1:
|
||||
# Dedenting below the outermost level means this is not the
|
||||
# well-formed block the parser expects. Stop here rather than
|
||||
# raise IndexError out of the module on the next line.
|
||||
self.log.error(
|
||||
"Unexpected indentation in ADB manager state, "
|
||||
"stopping the parse of this section"
|
||||
)
|
||||
break
|
||||
stack.pop()
|
||||
cur_indent = indent
|
||||
else:
|
||||
@@ -61,7 +77,16 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
stack.pop()
|
||||
current_dict = stack[-1]
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
if key == "}":
|
||||
if len(stack) <= 1:
|
||||
self.log.error(
|
||||
"Unbalanced closing brace in ADB manager state, "
|
||||
"stopping the parse of this section"
|
||||
)
|
||||
break
|
||||
stack.pop()
|
||||
continue
|
||||
|
||||
@@ -98,6 +123,34 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
|
||||
return keystore
|
||||
|
||||
def parse_binary_xml(self, data: bytes) -> list[dict]:
|
||||
"""Recover ADB key records from Android binary XML (ABX).
|
||||
|
||||
Some dumpstate implementations embed ABX in a text stream and replace
|
||||
binary token bytes. The public key remains intact, while unavailable
|
||||
numeric metadata is represented as ``None`` rather than corrupt text.
|
||||
"""
|
||||
keystore = []
|
||||
seen = set()
|
||||
for match in re.finditer(
|
||||
rb"(?<![A-Za-z0-9+/])([A-Za-z0-9+/]{300,}={0,2})"
|
||||
rb"(?: ([A-Za-z0-9_.@-]+))?",
|
||||
data,
|
||||
):
|
||||
key = match.group(1)
|
||||
try:
|
||||
base64.b64decode(key, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
continue
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
full_key = key + (b" " + match.group(2) if match.group(2) else b"")
|
||||
key_info = self.calculate_key_info(full_key)
|
||||
key_info["last_connected"] = None
|
||||
keystore.append(key_info)
|
||||
return keystore
|
||||
|
||||
@staticmethod
|
||||
def calculate_key_info(user_key: bytes) -> dict:
|
||||
if b" " in user_key:
|
||||
@@ -118,7 +171,7 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
return {
|
||||
"user": user.decode("utf-8"),
|
||||
"fingerprint": key_fingerprint_colon,
|
||||
"key": key_base64,
|
||||
"key": key_base64.decode("ascii", errors="replace"),
|
||||
}
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
@@ -132,6 +185,26 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
f"'{user_key['fingerprint']}'"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _find_state_end(content: bytes, open_brace: int) -> int:
|
||||
"""Find the unindented line closing the ADB manager state, or -1.
|
||||
|
||||
Braces inside key comments or embedded keystore data are values, while
|
||||
nested structural closing braces are indented.
|
||||
"""
|
||||
line_start = open_brace
|
||||
while line_start < len(content):
|
||||
line_end = content.find(b"\n", line_start)
|
||||
if line_end == -1:
|
||||
line_end = len(content)
|
||||
line = content[line_start:line_end].removesuffix(b"\r")
|
||||
if line_start > open_brace and line == b"}":
|
||||
return line_start
|
||||
if line.startswith((b"---------", b"DUMP OF SERVICE ")):
|
||||
break
|
||||
line_start = line_end + 1
|
||||
return -1
|
||||
|
||||
def parse(self, content: bytes) -> None:
|
||||
"""
|
||||
Parse the Dumpsys ADB section
|
||||
@@ -151,12 +224,16 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
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")
|
||||
end_of_json = self._find_state_end(content, start_of_json + 1)
|
||||
if end_of_json == -1:
|
||||
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()
|
||||
# The brace that opens the state and the one that closes it are not part
|
||||
# of the indented body.
|
||||
json_content = content[start_of_json + 2 : end_of_json].rstrip()
|
||||
|
||||
parsed = self.indented_dump_parser(json_content)
|
||||
if parsed.get("debugging_manager") is None:
|
||||
@@ -175,12 +252,23 @@ class DumpsysADBArtifact(AndroidArtifact):
|
||||
# Keystore is in XML format on some devices and we need to parse it
|
||||
if keystore_data and keystore_data.startswith(b"<?xml"):
|
||||
parsed["debugging_manager"]["keystore"] = self.parse_xml(keystore_data)
|
||||
elif keystore_data and keystore_data.startswith(b"ABX\x00"):
|
||||
parsed["debugging_manager"]["keystore"] = self.parse_binary_xml(
|
||||
keystore_data
|
||||
)
|
||||
else:
|
||||
# Keystore is not XML format
|
||||
parsed["debugging_manager"]["keystore"] = keystore_data
|
||||
|
||||
parsed = parsed["debugging_manager"]
|
||||
|
||||
for key, value in list(parsed.items()):
|
||||
if isinstance(value, bytes):
|
||||
decoded = value.decode("utf-8", errors="replace")
|
||||
parsed[key] = (
|
||||
decoded == "true" if decoded in ("true", "false") else decoded
|
||||
)
|
||||
|
||||
# Calculate key fingerprints for better readability
|
||||
key_info = []
|
||||
for user_key in parsed.get("user_keys", []):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
@@ -27,14 +28,14 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
for entry in perm["entries"]:
|
||||
if "timestamp" in entry:
|
||||
if entry.get("timestamp"):
|
||||
records.append(
|
||||
{
|
||||
"timestamp": entry["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": entry["access"],
|
||||
"event": entry["event"],
|
||||
"data": f"{result['package_name']} access to "
|
||||
f"{perm['name']}: {entry['access']}",
|
||||
f"{perm['name']}: {entry['event']}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -51,7 +52,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
# We use a placeholder entry to create a basic alert even without permission entries.
|
||||
placeholder_entry = {"access": "Unknown", "timestamp": ""}
|
||||
placeholder_entry = {"event": "unknown", "timestamp": ""}
|
||||
|
||||
for perm in result["permissions"]:
|
||||
if (
|
||||
@@ -60,12 +61,12 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
):
|
||||
for entry in sorted(
|
||||
perm["entries"] or [placeholder_entry],
|
||||
key=lambda x: x["timestamp"],
|
||||
key=lambda x: x.get("timestamp") or "",
|
||||
):
|
||||
cleaned_result = result.copy()
|
||||
cleaned_result["permissions"] = [perm]
|
||||
self.alertstore.medium(
|
||||
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
|
||||
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['event']}' at {entry['timestamp']}",
|
||||
entry["timestamp"],
|
||||
cleaned_result,
|
||||
)
|
||||
@@ -73,111 +74,151 @@ class DumpsysAppopsArtifact(AndroidArtifact):
|
||||
elif result["package_name"] in RISKY_PACKAGES:
|
||||
for entry in sorted(
|
||||
perm["entries"] or [placeholder_entry],
|
||||
key=lambda x: x["timestamp"],
|
||||
key=lambda x: x.get("timestamp") or "",
|
||||
):
|
||||
cleaned_result = result.copy()
|
||||
cleaned_result["permissions"] = [perm]
|
||||
self.alertstore.medium(
|
||||
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
|
||||
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['event']}' at {entry['timestamp']}",
|
||||
entry["timestamp"],
|
||||
cleaned_result,
|
||||
)
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
# self.results: List[Dict[str, Any]] = []
|
||||
perm: dict[str, Any] = {}
|
||||
package: dict[str, Any] = {}
|
||||
entry: dict[str, Any] = {}
|
||||
uid = None
|
||||
self.results: list[dict[str, Any]] = []
|
||||
permission: dict[str, Any] | None = None
|
||||
package: dict[str, Any] | None = None
|
||||
uid: str | None = None
|
||||
uid_details: dict[str, Any] = {}
|
||||
attribution: str | None = None
|
||||
in_packages = False
|
||||
|
||||
def finish_permission() -> None:
|
||||
nonlocal permission
|
||||
if package is not None and permission is not None:
|
||||
package["permissions"].append(permission)
|
||||
permission = None
|
||||
|
||||
def finish_package() -> None:
|
||||
nonlocal package
|
||||
finish_permission()
|
||||
if package is not None:
|
||||
self.results.append(package)
|
||||
package = None
|
||||
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" Uid 0:"):
|
||||
uid_match = re.match(r"^ Uid ([^:]+):$", line)
|
||||
if uid_match:
|
||||
in_packages = True
|
||||
finish_package()
|
||||
uid = uid_match.group(1)
|
||||
uid_details = {
|
||||
"uid_state": None,
|
||||
"capability": None,
|
||||
"app_widget_visible": None,
|
||||
"default_modes": {},
|
||||
}
|
||||
continue
|
||||
|
||||
if not in_packages:
|
||||
continue
|
||||
|
||||
if line.startswith(" Uid "):
|
||||
uid = line[6:-1]
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
if package:
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
uid_property = re.match(
|
||||
r"^ (state|capability|appWidgetVisible)=(.*)$", line
|
||||
)
|
||||
if uid_property:
|
||||
key = {
|
||||
"state": "uid_state",
|
||||
"appWidgetVisible": "app_widget_visible",
|
||||
}.get(uid_property.group(1), uid_property.group(1))
|
||||
value: Any = uid_property.group(2)
|
||||
if value in ("true", "false"):
|
||||
value = value == "true"
|
||||
uid_details[key] = value
|
||||
continue
|
||||
|
||||
perm = {}
|
||||
self.results.append(package)
|
||||
package = {}
|
||||
default_mode = re.match(r"^ ([A-Z0-9_]+): mode=([^\s]+)", line)
|
||||
if default_mode and package is None:
|
||||
uid_details["default_modes"][default_mode.group(1)] = (
|
||||
default_mode.group(2)
|
||||
)
|
||||
continue
|
||||
|
||||
if line.startswith(" Package "):
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
|
||||
if package:
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
|
||||
perm = {}
|
||||
self.results.append(package)
|
||||
|
||||
finish_package()
|
||||
package = {
|
||||
"package_name": line[12:-1],
|
||||
"permissions": [],
|
||||
"uid": uid,
|
||||
**uid_details,
|
||||
}
|
||||
continue
|
||||
|
||||
if package and line.startswith(" ") and line[6] != " ":
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
perm = {}
|
||||
|
||||
perm["name"] = line.split()[0]
|
||||
perm["entries"] = []
|
||||
if len(line.split()) > 1:
|
||||
perm["access"] = line.split()[1][1:-2]
|
||||
|
||||
operation_match = re.match(
|
||||
r"^ ([A-Z0-9_]+)(?: \(([^)]+)\))?:\s*$", line
|
||||
)
|
||||
if package is not None and operation_match:
|
||||
finish_permission()
|
||||
permission = {
|
||||
"name": operation_match.group(1),
|
||||
"mode": operation_match.group(2),
|
||||
"entries": [],
|
||||
}
|
||||
attribution = None
|
||||
continue
|
||||
|
||||
if line.startswith(" "):
|
||||
# Permission entry like:
|
||||
# Reject: [fg-s]2021-05-19 22:02:52.054 (-314d1h25m2s33ms)
|
||||
access_type = line.split(":")[0].strip()
|
||||
if access_type not in ["Access", "Reject"]:
|
||||
# Skipping invalid access type. Some entries are not in the format we expect
|
||||
continue
|
||||
attribution_match = re.match(r"^\s{8,}([^=]+)=\[$", line)
|
||||
if attribution_match:
|
||||
attribution = attribution_match.group(1).strip()
|
||||
continue
|
||||
if line.strip() == "]":
|
||||
attribution = None
|
||||
continue
|
||||
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
entry = {}
|
||||
|
||||
entry["access"] = access_type
|
||||
entry["type"] = line[line.find("[") + 1 : line.find("]")]
|
||||
|
||||
try:
|
||||
entry["timestamp"] = convert_datetime_to_iso(
|
||||
datetime.strptime(
|
||||
line[line.find("]") + 1 : line.find("(")].strip(),
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
)
|
||||
if permission is None:
|
||||
continue
|
||||
event_match = re.match(
|
||||
r"^\s*(Access|Reject):\s*\[([^]]+)\]\s*"
|
||||
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s*"
|
||||
r"(\([^)]*\))?(?:\s+duration=([^\s]+))?",
|
||||
line,
|
||||
)
|
||||
running_match = re.match(
|
||||
r"^\s*Running start at:\s*(\S+(?: \S+)?)",
|
||||
line,
|
||||
)
|
||||
if event_match:
|
||||
entry = {
|
||||
"event": event_match.group(1).lower(),
|
||||
"access": event_match.group(1),
|
||||
"uid_state": event_match.group(2),
|
||||
"timestamp": convert_datetime_to_iso(
|
||||
datetime.strptime(event_match.group(3), "%Y-%m-%d %H:%M:%S.%f")
|
||||
),
|
||||
"relative_time": event_match.group(4),
|
||||
"duration": event_match.group(5),
|
||||
"attribution": attribution,
|
||||
}
|
||||
permission["entries"].append(entry)
|
||||
elif running_match:
|
||||
raw_start = running_match.group(1)
|
||||
timestamp = None
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", raw_start):
|
||||
timestamp = convert_datetime_to_iso(
|
||||
datetime.strptime(raw_start, "%Y-%m-%d %H:%M:%S.%f")
|
||||
)
|
||||
except ValueError:
|
||||
# Invalid date format
|
||||
pass
|
||||
permission["entries"].append(
|
||||
{
|
||||
"event": "running",
|
||||
"access": "Running",
|
||||
"uid_state": None,
|
||||
"timestamp": timestamp,
|
||||
"relative_time": raw_start
|
||||
if raw_start.startswith("+")
|
||||
else None,
|
||||
"duration": None,
|
||||
"attribution": attribution,
|
||||
}
|
||||
)
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
if entry:
|
||||
perm["entries"].append(entry)
|
||||
if perm:
|
||||
package["permissions"].append(perm)
|
||||
if package:
|
||||
self.results.append(package)
|
||||
finish_package()
|
||||
@@ -18,18 +18,18 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
action = record.get("action", "update")
|
||||
package_name = record["package_name"]
|
||||
vers = record["vers"]
|
||||
vers = record["version_code"]
|
||||
|
||||
if vers == "0":
|
||||
if vers == 0:
|
||||
data = f"Recorded uninstall of package {package_name} (vers 0)"
|
||||
elif action == "downgrade":
|
||||
prev_vers = record.get("previous_vers", "unknown")
|
||||
prev_vers = record.get("previous_version_code", "unknown")
|
||||
data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}"
|
||||
else:
|
||||
data = f"Recorded update of package {package_name} with vers {vers}"
|
||||
|
||||
return {
|
||||
"timestamp": record["from"],
|
||||
"timestamp": record["period_start"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "battery_daily",
|
||||
"data": data,
|
||||
@@ -48,6 +48,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
self.results = []
|
||||
daily = None
|
||||
daily_updates: list[dict[str, Any]] = []
|
||||
records: list[dict[str, Any]] = []
|
||||
@@ -59,7 +60,10 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
|
||||
timeframe = line[13:].strip()
|
||||
date_from, date_to = timeframe.strip(":").split(" to ", 1)
|
||||
daily = {"from": date_from[0:10], "to": date_to[0:10]}
|
||||
daily = {
|
||||
"period_start": self._format_daily_timestamp(date_from),
|
||||
"period_end": self._format_daily_timestamp(date_to),
|
||||
}
|
||||
continue
|
||||
|
||||
if not daily:
|
||||
@@ -69,22 +73,35 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
line = line.strip().replace("Update ", "")
|
||||
package_name, vers = line.split(" ", 1)
|
||||
vers_nr = vers.split("=", 1)[1]
|
||||
# A truncated or vendor-specific line must not abort the parse and
|
||||
# lose every record after it.
|
||||
package_name, _, vers = line.partition(" ")
|
||||
vers_raw = vers.partition("=")[2]
|
||||
if not package_name or not vers_raw:
|
||||
continue
|
||||
try:
|
||||
version_code: int | str = int(vers_raw)
|
||||
except ValueError:
|
||||
version_code = vers_raw
|
||||
|
||||
already_seen = False
|
||||
for update in daily_updates:
|
||||
if package_name == update["package_name"] and vers_nr == update["vers"]:
|
||||
if (
|
||||
package_name == update["package_name"]
|
||||
and version_code == update["version_code"]
|
||||
):
|
||||
update["occurrences"] += 1
|
||||
already_seen = True
|
||||
break
|
||||
|
||||
if not already_seen:
|
||||
update_record: dict[str, Any] = {
|
||||
"action": "update",
|
||||
"from": daily["from"],
|
||||
"to": daily["to"],
|
||||
"period_start": daily["period_start"],
|
||||
"period_end": daily["period_end"],
|
||||
"package_name": package_name,
|
||||
"vers": vers_nr,
|
||||
"version_code": version_code,
|
||||
"occurrences": 1,
|
||||
}
|
||||
|
||||
daily_updates.append(update_record)
|
||||
@@ -95,26 +112,31 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
self._detect_uninstalls_and_downgrades(records)
|
||||
self.results.extend(records)
|
||||
|
||||
def _detect_uninstalls_and_downgrades(
|
||||
self, records: list[dict[str, Any]]
|
||||
) -> None:
|
||||
@staticmethod
|
||||
def _format_daily_timestamp(value: str) -> str:
|
||||
if len(value) >= 19 and value[10] == "-":
|
||||
return f"{value[:10]} {value[11:].replace('-', ':')}"
|
||||
return value
|
||||
|
||||
def _detect_uninstalls_and_downgrades(self, records: list[dict[str, Any]]) -> None:
|
||||
package_versions: dict[str, int] = {}
|
||||
|
||||
for record in sorted(
|
||||
records,
|
||||
key=lambda record: (
|
||||
record["from"],
|
||||
record["to"],
|
||||
record["period_start"],
|
||||
record["period_end"],
|
||||
record["package_name"],
|
||||
),
|
||||
):
|
||||
package_name = record["package_name"]
|
||||
vers_nr = record["vers"]
|
||||
vers_nr = record["version_code"]
|
||||
|
||||
if vers_nr == "0":
|
||||
if vers_nr == 0:
|
||||
record["action"] = "uninstall"
|
||||
self.alertstore.medium(
|
||||
f"Detected uninstall of package {package_name} (vers 0)",
|
||||
record["from"],
|
||||
record["period_start"],
|
||||
record,
|
||||
)
|
||||
package_versions.pop(package_name, None)
|
||||
@@ -128,11 +150,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
|
||||
previous_vers = package_versions.get(package_name)
|
||||
if previous_vers is not None and current_vers < previous_vers:
|
||||
record["action"] = "downgrade"
|
||||
record["previous_vers"] = str(previous_vers)
|
||||
record["previous_version_code"] = previous_vers
|
||||
self.alertstore.medium(
|
||||
f"Detected downgrade of package {package_name} "
|
||||
f"from vers {previous_vers} to vers {current_vers}",
|
||||
record["from"],
|
||||
record["period_start"],
|
||||
record,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,96 +1,177 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
class DumpsysBatteryHistoryArtifact(AndroidArtifact):
|
||||
"""
|
||||
Parser for dumpsys dattery history events.
|
||||
"""
|
||||
"""Parser for package-related events in dumpsys batterystats history."""
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
ioc_match = self.indicators.check_app_id(result["package_name"])
|
||||
package_name = result.get("package_name")
|
||||
if not package_name:
|
||||
continue
|
||||
ioc_match = self.indicators.check_app_id(package_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def _parse_wall_time(value: str) -> datetime.datetime | None:
|
||||
if re.fullmatch(r"\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", value):
|
||||
value = f"1900-{value}"
|
||||
for date_format in (
|
||||
"%Y-%m-%d-%H-%M-%S-%f",
|
||||
"%Y-%m-%d-%H-%M-%S",
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
):
|
||||
try:
|
||||
return datetime.datetime.strptime(value, date_format)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _elapsed_seconds(value: str) -> float | None:
|
||||
if not value.startswith("+"):
|
||||
return None
|
||||
units = {"d": 86400, "h": 3600, "m": 60, "s": 1, "ms": 0.001}
|
||||
total = 0.0
|
||||
for number, unit in re.findall(r"(\d+)(ms|d|h|m|s)", value):
|
||||
total += int(number) * units[unit]
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
def _package_from_name(name: str) -> str | None:
|
||||
clean = name.removeprefix("*walarm*:").removeprefix("*alarm*:")
|
||||
slash_parts = clean.split("/")
|
||||
if len(slash_parts) > 1:
|
||||
first = slash_parts[0].lstrip("@")
|
||||
if first.startswith(("com.", "org.", "net.")):
|
||||
return first
|
||||
for part in reversed(slash_parts[1:]):
|
||||
candidate = part.lstrip("@").split(":", 1)[0]
|
||||
if candidate.startswith(("com.", "org.", "net.")):
|
||||
return candidate
|
||||
parts = clean.split(".")
|
||||
package_parts = []
|
||||
for part in parts:
|
||||
if part and part[0].islower():
|
||||
package_parts.append(part)
|
||||
else:
|
||||
break
|
||||
return ".".join(package_parts) if len(package_parts) >= 2 else None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_service(service: str) -> str:
|
||||
# WorkManager decorates jobs with one or more scheduler prefixes.
|
||||
if "@" in service:
|
||||
candidates = [part for part in service.split("@") if "/" in part]
|
||||
if candidates:
|
||||
return candidates[-1]
|
||||
return service
|
||||
|
||||
def parse(self, data: str) -> None:
|
||||
self.results: list[dict[str, str | None]] = []
|
||||
anchor_time: datetime.datetime | None = None
|
||||
anchor_elapsed = 0.0
|
||||
has_history_heading = any(
|
||||
line.startswith("Battery History") for line in data.splitlines()
|
||||
)
|
||||
in_history = not has_history_heading
|
||||
|
||||
for line in data.splitlines():
|
||||
if line.startswith("Battery History "):
|
||||
stripped = line.strip()
|
||||
if line.startswith("Battery History"):
|
||||
if in_history:
|
||||
break
|
||||
in_history = True
|
||||
continue
|
||||
|
||||
if line.strip() == "":
|
||||
if not in_history:
|
||||
continue
|
||||
if has_history_heading and not stripped:
|
||||
break
|
||||
|
||||
time_parts = line.strip().split()
|
||||
time_elapsed = time_parts[0]
|
||||
if (
|
||||
len(time_parts) > 1
|
||||
and len(time_parts[0]) == 5
|
||||
and time_parts[0][2] == "-"
|
||||
and ":" in time_parts[1]
|
||||
):
|
||||
time_elapsed = " ".join(time_parts[:2])
|
||||
|
||||
event = ""
|
||||
if line.find("+job") > 0:
|
||||
event = "start_job"
|
||||
payload = line.split("+job=", 1)[1]
|
||||
uid, separator, service = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
service = service.strip().strip('"')
|
||||
package_name = service.split("/")[0]
|
||||
elif line.find("-job") > 0:
|
||||
event = "end_job"
|
||||
payload = line.split("-job=", 1)[1]
|
||||
uid, separator, service = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
service = service.strip().strip('"')
|
||||
package_name = service.split("/")[0]
|
||||
elif line.find("+running +wake_lock=") > 0:
|
||||
payload = line.split("+running +wake_lock=", 1)[1]
|
||||
uid, separator, _ = payload.partition(":")
|
||||
if not separator:
|
||||
continue
|
||||
event = "wake"
|
||||
service = (
|
||||
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
|
||||
)
|
||||
if service == "" or "/" not in service:
|
||||
continue
|
||||
|
||||
package_name = service.split("/")[0]
|
||||
elif (line.find("+top=") > 0) or (line.find("-top") > 0):
|
||||
if line.find("+top=") > 0:
|
||||
event = "start_top"
|
||||
top_pos = line.find("+top=")
|
||||
else:
|
||||
event = "end_top"
|
||||
top_pos = line.find("-top=")
|
||||
colon_pos = top_pos + line[top_pos:].find(":")
|
||||
uid = line[top_pos + 5 : colon_pos]
|
||||
service = ""
|
||||
package_name = line[colon_pos + 1 :].strip('"')
|
||||
else:
|
||||
reset_match = re.search(r"(?:RESET:)?TIME:\s*(\S+)", stripped)
|
||||
if reset_match:
|
||||
parsed_time = self._parse_wall_time(reset_match.group(1))
|
||||
if parsed_time is not None:
|
||||
elapsed_token = stripped.split()[0]
|
||||
anchor_elapsed = self._elapsed_seconds(elapsed_token) or 0.0
|
||||
anchor_time = parsed_time
|
||||
continue
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"time_elapsed": time_elapsed,
|
||||
"event": event,
|
||||
"uid": uid,
|
||||
"package_name": package_name,
|
||||
"service": service,
|
||||
}
|
||||
)
|
||||
fields = stripped.split()
|
||||
if not fields:
|
||||
continue
|
||||
if len(fields) > 1 and re.fullmatch(r"\d{2}-\d{2}", fields[0]):
|
||||
time_elapsed = " ".join(fields[:2])
|
||||
line_time = self._parse_wall_time(time_elapsed)
|
||||
elapsed = None
|
||||
else:
|
||||
time_elapsed = fields[0]
|
||||
elapsed = self._elapsed_seconds(time_elapsed)
|
||||
line_time = None
|
||||
|
||||
timestamp = line_time
|
||||
if timestamp is None and anchor_time is not None and elapsed is not None:
|
||||
timestamp = anchor_time + datetime.timedelta(
|
||||
seconds=elapsed - anchor_elapsed
|
||||
)
|
||||
|
||||
def add(
|
||||
event: str, uid: str, service: str, package_name: str | None
|
||||
) -> None:
|
||||
self.results.append(
|
||||
{
|
||||
"time_elapsed": time_elapsed,
|
||||
"timestamp": convert_datetime_to_iso(timestamp)
|
||||
if timestamp
|
||||
else None,
|
||||
"event": event,
|
||||
"uid": uid,
|
||||
"package_name": package_name,
|
||||
"service": service,
|
||||
}
|
||||
)
|
||||
|
||||
for sign, uid, raw_service in re.findall(
|
||||
r"([+-])job=([^:\s]+):\"([^\"]+)\"", line
|
||||
):
|
||||
service = self._normalize_service(raw_service)
|
||||
add(
|
||||
"start_job" if sign == "+" else "end_job",
|
||||
uid,
|
||||
service,
|
||||
self._package_from_name(service) or service.split("/", 1)[0],
|
||||
)
|
||||
|
||||
for sign, uid, package_name in re.findall(
|
||||
r"([+-])top=([^:\s]+):\"([^\"]+)\"", line
|
||||
):
|
||||
add(
|
||||
"start_top" if sign == "+" else "end_top",
|
||||
uid,
|
||||
"",
|
||||
package_name,
|
||||
)
|
||||
|
||||
wake_match = re.search(r"\+wake_lock=([^:\s]+):\"([^\"]+)\"", line)
|
||||
if wake_match:
|
||||
wake_name = wake_match.group(2)
|
||||
add(
|
||||
"wake",
|
||||
wake_match.group(1),
|
||||
wake_name,
|
||||
self._package_from_name(wake_name),
|
||||
)
|
||||
@@ -29,16 +29,27 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
rxp = re.compile(
|
||||
r".*\[((?:[0-9]{4}-)?[0-9]{2}-[0-9]{2} "
|
||||
r"[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\]\s*"
|
||||
r"(?:\[Pid:\((\d+)\)\])?([\w-]+).*?sql=\"(.+?)\""
|
||||
) # pylint: disable=line-too-long
|
||||
r"^\s*\d+:\s*\[((?:\d{4}-)?\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]\s*"
|
||||
r"(?:\[Pid:\((\d+)\)\])?\s*([\w-]+) took (\d+)ms - ([^,]+),"
|
||||
r"\s*sql=\"(.*)\"(?:, path=(.*))?$"
|
||||
)
|
||||
|
||||
pool = None
|
||||
pool: str | None = None
|
||||
connection_number: int | None = None
|
||||
is_primary: bool | None = None
|
||||
in_operations = False
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Connection pool for "):
|
||||
pool = line.replace("Connection pool for ", "").rstrip(":")
|
||||
in_operations = False
|
||||
|
||||
connection_match = re.match(r"\s+Connection #(\d+):", line)
|
||||
if connection_match:
|
||||
connection_number = int(connection_match.group(1))
|
||||
is_primary = None
|
||||
|
||||
if line.strip().startswith("isPrimaryConnection:"):
|
||||
is_primary = line.strip().split(":", 1)[1].strip() == "true"
|
||||
|
||||
if not pool:
|
||||
continue
|
||||
@@ -52,7 +63,6 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
|
||||
if not line.startswith(" "):
|
||||
in_operations = False
|
||||
pool = None
|
||||
continue
|
||||
|
||||
match = rxp.match(line)
|
||||
@@ -60,11 +70,15 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
|
||||
continue
|
||||
|
||||
result = {
|
||||
"isodate": match.group(1),
|
||||
"timestamp": match.group(1),
|
||||
"pid": int(match.group(2)) if match.group(2) else None,
|
||||
"action": match.group(3),
|
||||
"sql": match.group(4),
|
||||
"path": pool,
|
||||
"duration_ms": int(match.group(4)),
|
||||
"status": match.group(5),
|
||||
"sql": match.group(6),
|
||||
"path": match.group(7) or pool,
|
||||
"pool_path": pool,
|
||||
"connection_number": connection_number,
|
||||
"is_primary": is_primary,
|
||||
}
|
||||
if match.group(2):
|
||||
result["pid"] = match.group(2)
|
||||
self.results.append(result)
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
from .package_resolvers import parse_resolver_table
|
||||
|
||||
|
||||
class DumpsysPackageActivitiesArtifact(AndroidArtifact):
|
||||
@@ -19,67 +20,11 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
|
||||
)
|
||||
continue
|
||||
|
||||
def parse(self, content: str):
|
||||
def parse(self, content: str) -> None:
|
||||
"""
|
||||
Parse the Dumpsys Package section for activities
|
||||
Adds results to self.results
|
||||
|
||||
:param content: content of the package section (string)
|
||||
"""
|
||||
self.results = []
|
||||
|
||||
in_activity_resolver_table = False
|
||||
in_non_data_actions = False
|
||||
intent = None
|
||||
for line in content.splitlines():
|
||||
if line.startswith("Activity Resolver Table:"):
|
||||
in_activity_resolver_table = True
|
||||
continue
|
||||
|
||||
if not in_activity_resolver_table:
|
||||
continue
|
||||
|
||||
if line.startswith(" Non-Data Actions:"):
|
||||
in_non_data_actions = True
|
||||
continue
|
||||
|
||||
if not in_non_data_actions:
|
||||
continue
|
||||
|
||||
# If we hit an empty line, the Non-Data Actions section should be
|
||||
# finished.
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# We detect the action name.
|
||||
if (
|
||||
line.startswith(" " * 6)
|
||||
and not line.startswith(" " * 8)
|
||||
and ":" in line
|
||||
):
|
||||
intent = line.strip().replace(":", "")
|
||||
continue
|
||||
|
||||
# If we are not in an intent block yet, skip.
|
||||
if not intent:
|
||||
continue
|
||||
|
||||
# If we are in a block but the line does not start with 8 spaces
|
||||
# it means the block ended a new one started, so we reset and
|
||||
# continue.
|
||||
if not line.startswith(" " * 8):
|
||||
intent = None
|
||||
continue
|
||||
|
||||
# If we got this far, we are processing receivers for the
|
||||
# activities we are interested in.
|
||||
activity = line.strip().split(" ")[1]
|
||||
package_name = activity.split("/")[0]
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"intent": intent,
|
||||
"package_name": package_name,
|
||||
"activity": activity,
|
||||
}
|
||||
)
|
||||
self.results = parse_resolver_table(content, "Activity")
|
||||
@@ -1,10 +1,10 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
from mvt.android.utils import ROOT_PACKAGES
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
@@ -12,222 +12,244 @@ from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
def _value(raw: str) -> Any:
|
||||
if raw == "null":
|
||||
return None
|
||||
if raw in ("true", "false"):
|
||||
return raw == "true"
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return raw
|
||||
|
||||
|
||||
class DumpsysPackagesArtifact(AndroidArtifact):
|
||||
def check_indicators(self) -> None:
|
||||
alerted_root_packages = set()
|
||||
for result in self.results:
|
||||
if result["package_name"] in ROOT_PACKAGES:
|
||||
if result["package_name"] in alerted_root_packages:
|
||||
continue
|
||||
alerted_root_packages.add(result["package_name"])
|
||||
package_name = result["package_name"]
|
||||
if (
|
||||
package_name in ROOT_PACKAGES
|
||||
and package_name not in alerted_root_packages
|
||||
):
|
||||
alerted_root_packages.add(package_name)
|
||||
self.alertstore.medium(
|
||||
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
|
||||
f'Found an installed package related to rooting/jailbreaking: "{package_name}"',
|
||||
"",
|
||||
result,
|
||||
)
|
||||
continue
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(result.get("package_name", ""))
|
||||
ioc_match = self.indicators.check_app_id(package_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
|
||||
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
timestamps = [
|
||||
{"event": "package_install", "timestamp": record["timestamp"]},
|
||||
("package_install", record.get("timestamp")),
|
||||
("package_last_update", record.get("last_update_time")),
|
||||
]
|
||||
timestamps.extend(
|
||||
("package_first_install", user.get("first_install_time"))
|
||||
for user in record.get("users", [])
|
||||
)
|
||||
return [
|
||||
{
|
||||
"event": "package_first_install",
|
||||
"timestamp": record["first_install_time"],
|
||||
},
|
||||
{"event": "package_last_update", "timestamp": record["last_update_time"]},
|
||||
"timestamp": timestamp,
|
||||
"module": self.__class__.__name__,
|
||||
"event": event,
|
||||
"data": f"Install or update of package {record['package_name']}",
|
||||
}
|
||||
for event, timestamp in timestamps
|
||||
if timestamp
|
||||
]
|
||||
|
||||
for timestamp in timestamps:
|
||||
records.append(
|
||||
{
|
||||
"timestamp": timestamp["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": timestamp["event"],
|
||||
"data": f"Install or update of package {record['package_name']}",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
@staticmethod
|
||||
def parse_dumpsys_package_for_details(output: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse one entry of a dumpsys package information
|
||||
"""
|
||||
details: Dict[str, Any] = {
|
||||
"uid": "",
|
||||
"version_name": "",
|
||||
"version_code": "",
|
||||
"timestamp": "",
|
||||
"first_install_time": "",
|
||||
"last_update_time": "",
|
||||
"installer": "",
|
||||
"system": False,
|
||||
"permissions": list(),
|
||||
"requested_permissions": list(),
|
||||
def _permission(line: str, permission_type: str) -> dict:
|
||||
name, _, details = line.strip().partition(":")
|
||||
granted_match = re.search(r"granted=(true|false)", details)
|
||||
flags_match = re.search(r"flags=\[\s*([^]]*)\]", details)
|
||||
return {
|
||||
"name": name,
|
||||
"type": permission_type,
|
||||
"granted": granted_match.group(1) == "true" if granted_match else None,
|
||||
"flags": [
|
||||
flag.strip()
|
||||
for flag in (flags_match.group(1).split("|") if flags_match else [])
|
||||
if flag.strip()
|
||||
],
|
||||
}
|
||||
in_install_permissions = False
|
||||
in_runtime_permissions = False
|
||||
in_declared_permissions = False
|
||||
in_requested_permissions = True
|
||||
current_user: Optional[int] = None
|
||||
first_install_times: Dict[Optional[int], str] = {}
|
||||
runtime_permissions: Dict[Optional[int], List[Dict[str, Any]]] = {}
|
||||
|
||||
@classmethod
|
||||
def parse_dumpsys_package_for_details(cls, output: str) -> dict[str, Any]:
|
||||
details: dict[str, Any] = {
|
||||
"app_id": None,
|
||||
"version_name": None,
|
||||
"version_code": None,
|
||||
"min_sdk": None,
|
||||
"target_sdk": None,
|
||||
"timestamp": None,
|
||||
"last_update_time": None,
|
||||
"installer": None,
|
||||
"system": False,
|
||||
"permissions": [],
|
||||
"requested_permissions": [],
|
||||
"users": [],
|
||||
}
|
||||
permission_section: str | None = None
|
||||
current_user: dict[str, Any] | None = None
|
||||
legacy_first_install: str | None = None
|
||||
|
||||
for line in output.splitlines():
|
||||
user_match = re.match(r"User (\d+):", line.strip())
|
||||
stripped = line.strip()
|
||||
user_match = re.match(r"User (\d+):\s*(.*)", stripped)
|
||||
if user_match:
|
||||
current_user = int(user_match.group(1))
|
||||
current_user = {"user_id": int(user_match.group(1)), "permissions": []}
|
||||
for key, raw in re.findall(r"(\w+)=([^\s]+)", user_match.group(2)):
|
||||
clean_key = {
|
||||
"notLaunched": "not_launched",
|
||||
"installReason": "install_reason",
|
||||
"uninstallReason": "uninstall_reason",
|
||||
"dataDir": "data_dir",
|
||||
}.get(key, re.sub(r"(?<!^)(?=[A-Z])", "_", key).lower())
|
||||
current_user[clean_key] = _value(raw)
|
||||
details["users"].append(current_user)
|
||||
permission_section = None
|
||||
continue
|
||||
|
||||
if in_install_permissions:
|
||||
if line.startswith(" " * 4) and not line.startswith(" " * 6):
|
||||
in_install_permissions = False
|
||||
else:
|
||||
lineinfo = line.strip().split(":")
|
||||
permission = lineinfo[0]
|
||||
granted = None
|
||||
if "granted=" in lineinfo[1]:
|
||||
granted = "granted=true" in lineinfo[1]
|
||||
header = stripped.lower()
|
||||
if header in {
|
||||
"declared permissions:",
|
||||
"install permissions:",
|
||||
"requested permissions:",
|
||||
"runtime permissions:",
|
||||
}:
|
||||
permission_section = header.split()[0]
|
||||
continue
|
||||
|
||||
details["permissions"].append(
|
||||
{"name": permission, "granted": granted, "type": "install"}
|
||||
)
|
||||
if in_runtime_permissions:
|
||||
if not line.startswith(" " * 8):
|
||||
in_runtime_permissions = False
|
||||
else:
|
||||
lineinfo = line.strip().split(":")
|
||||
permission = lineinfo[0]
|
||||
granted = None
|
||||
if "granted=" in lineinfo[1]:
|
||||
granted = "granted=true" in lineinfo[1]
|
||||
|
||||
runtime_permissions.setdefault(current_user, []).append(
|
||||
{"name": permission, "granted": granted, "type": "runtime"}
|
||||
)
|
||||
if in_declared_permissions:
|
||||
if not line.startswith(" " * 6):
|
||||
in_declared_permissions = False
|
||||
else:
|
||||
permission = line.strip().split(":")[0]
|
||||
details["permissions"].append(
|
||||
{"name": permission, "type": "declared"}
|
||||
)
|
||||
if in_requested_permissions:
|
||||
if not line.startswith(" " * 6):
|
||||
in_requested_permissions = False
|
||||
else:
|
||||
details["requested_permissions"].append(line.strip())
|
||||
if line.strip().startswith("userId="):
|
||||
details["uid"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("versionName="):
|
||||
details["version_name"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("versionCode="):
|
||||
details["version_code"] = line.split("=", 1)[1].strip()
|
||||
elif line.strip().startswith("timeStamp="):
|
||||
details["timestamp"] = line.split("=")[1].strip()
|
||||
elif line.strip().startswith("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="):
|
||||
first_install_times[current_user] = line.split("=", 1)[1].strip()
|
||||
elif line.strip().startswith("lastUpdateTime="):
|
||||
details["last_update_time"] = line.split("=")[1].strip()
|
||||
elif line.strip() == "install permissions:":
|
||||
in_install_permissions = True
|
||||
elif line.strip() == "runtime permissions:":
|
||||
in_runtime_permissions = True
|
||||
elif line.strip() == "declared permissions:":
|
||||
in_declared_permissions = True
|
||||
elif line.strip() == "requested permissions:":
|
||||
in_requested_permissions = True
|
||||
|
||||
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
|
||||
|
||||
def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse the dumpsys package service data
|
||||
"""
|
||||
pkg_rxp = re.compile(r" Package \[(.+?)\].*")
|
||||
|
||||
results = []
|
||||
package_name = None
|
||||
package = {}
|
||||
lines: list[str] = []
|
||||
for line in output.splitlines():
|
||||
if line.startswith(" Package ["):
|
||||
if len(lines) > 0:
|
||||
details = self.parse_dumpsys_package_for_details("\n".join(lines))
|
||||
package.update(details)
|
||||
results.append(package)
|
||||
lines = []
|
||||
package = {}
|
||||
|
||||
matches = pkg_rxp.findall(line)
|
||||
if not matches:
|
||||
if current_user is not None:
|
||||
user_property = re.match(
|
||||
r"(installReason|uninstallReason|dataDir|firstInstallTime)=(.*)",
|
||||
stripped,
|
||||
)
|
||||
if user_property:
|
||||
key = {
|
||||
"installReason": "install_reason",
|
||||
"uninstallReason": "uninstall_reason",
|
||||
"dataDir": "data_dir",
|
||||
"firstInstallTime": "first_install_time",
|
||||
}[user_property.group(1)]
|
||||
current_user[key] = _value(user_property.group(2))
|
||||
continue
|
||||
|
||||
package_name = matches[0]
|
||||
package["package_name"] = package_name
|
||||
if permission_section == "requested" and line.startswith(" "):
|
||||
details["requested_permissions"].append(stripped)
|
||||
continue
|
||||
if permission_section in ("declared", "install") and line.startswith(
|
||||
" "
|
||||
):
|
||||
details["permissions"].append(
|
||||
cls._permission(stripped, permission_section)
|
||||
)
|
||||
continue
|
||||
if (
|
||||
permission_section == "runtime"
|
||||
and line.startswith(" ")
|
||||
and current_user is not None
|
||||
):
|
||||
current_user["permissions"].append(cls._permission(stripped, "runtime"))
|
||||
continue
|
||||
|
||||
if not package_name:
|
||||
simple_match = re.match(
|
||||
r"(appId|userId|versionName|timeStamp|lastUpdateTime|installerPackageName)=(.*)",
|
||||
stripped,
|
||||
)
|
||||
if simple_match:
|
||||
key = {
|
||||
"appId": "app_id",
|
||||
"userId": "app_id",
|
||||
"versionName": "version_name",
|
||||
"timeStamp": "timestamp",
|
||||
"lastUpdateTime": "last_update_time",
|
||||
"installerPackageName": "installer",
|
||||
}[simple_match.group(1)]
|
||||
raw_value = simple_match.group(2)
|
||||
details[key] = (
|
||||
_value(raw_value)
|
||||
if key == "app_id" or raw_value == "null"
|
||||
else raw_value
|
||||
)
|
||||
continue
|
||||
if stripped.startswith("pkgFlags="):
|
||||
details["system"] = "SYSTEM" in stripped.split("=", 1)[1].split()
|
||||
continue
|
||||
version_match = re.match(
|
||||
r"versionCode=([^\s]+)(?:\s+minSdk=([^\s]+))?(?:\s+targetSdk=([^\s]+))?",
|
||||
stripped,
|
||||
)
|
||||
if version_match:
|
||||
details["version_code"] = _value(version_match.group(1))
|
||||
details["min_sdk"] = (
|
||||
_value(version_match.group(2)) if version_match.group(2) else None
|
||||
)
|
||||
details["target_sdk"] = (
|
||||
_value(version_match.group(3)) if version_match.group(3) else None
|
||||
)
|
||||
elif stripped.startswith("firstInstallTime="):
|
||||
legacy_first_install = stripped.split("=", 1)[1]
|
||||
|
||||
lines.append(line)
|
||||
if legacy_first_install:
|
||||
user_zero = next(
|
||||
(user for user in details["users"] if user["user_id"] == 0), None
|
||||
)
|
||||
if user_zero is None:
|
||||
user_zero = {"user_id": 0, "permissions": []}
|
||||
details["users"].append(user_zero)
|
||||
user_zero.setdefault("first_install_time", legacy_first_install)
|
||||
return details
|
||||
|
||||
if len(lines) > 0:
|
||||
details = self.parse_dumpsys_package_for_details("\n".join(lines))
|
||||
package.update(details)
|
||||
results.append(package)
|
||||
def parse(self, content: str) -> None:
|
||||
self.results: list[dict[str, Any]] = []
|
||||
category: str | None = None
|
||||
package: dict[str, Any] | None = None
|
||||
block: list[str] = []
|
||||
|
||||
return results
|
||||
def finish() -> None:
|
||||
nonlocal package, block
|
||||
if package is not None:
|
||||
package.update(self.parse_dumpsys_package_for_details("\n".join(block)))
|
||||
self.results.append(package)
|
||||
package = None
|
||||
block = []
|
||||
|
||||
def parse(self, content: str):
|
||||
"""
|
||||
Parse the Dumpsys Package section for activities
|
||||
Adds results to self.results
|
||||
|
||||
:param content: content of the package section (string)
|
||||
"""
|
||||
self.results = []
|
||||
package = []
|
||||
|
||||
in_package_list = False
|
||||
for line in content.splitlines():
|
||||
if line.startswith("Packages:"):
|
||||
in_package_list = True
|
||||
if line == "Packages:":
|
||||
finish()
|
||||
category = "active"
|
||||
continue
|
||||
|
||||
if not in_package_list:
|
||||
if line == "Hidden system packages:":
|
||||
finish()
|
||||
category = "hidden_system"
|
||||
continue
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
package.append(line)
|
||||
|
||||
self.results = self.parse_dumpsys_packages("\n".join(package))
|
||||
package_match = re.match(r"^ Package \[([^]]+)\]", line)
|
||||
if package_match and category:
|
||||
finish()
|
||||
package = {
|
||||
"package_name": package_match.group(1),
|
||||
"package_type": category,
|
||||
}
|
||||
continue
|
||||
if (
|
||||
category
|
||||
and line
|
||||
and not line.startswith(" ")
|
||||
and not line.endswith(" overlay paths:")
|
||||
):
|
||||
finish()
|
||||
category = None
|
||||
continue
|
||||
if package is not None:
|
||||
block.append(line)
|
||||
finish()
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
@@ -25,19 +27,37 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
|
||||
|
||||
def parse(self, data: str) -> None:
|
||||
for line in data.splitlines():
|
||||
if not line.startswith("ChangeId(168419799; name=DOWNSCALED;"):
|
||||
match = re.match(r"ChangeId\((\d+);\s*(.*)\)$", line.strip())
|
||||
if not match or "rawOverrides={" not in line:
|
||||
continue
|
||||
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# Look for rawOverrides field
|
||||
if "rawOverrides={" in line:
|
||||
# Extract the content inside the braces for rawOverrides
|
||||
overrides_field = line.split("rawOverrides={", 1)[1].split("};", 1)[0]
|
||||
|
||||
for entry in overrides_field.split(", "):
|
||||
# Extract app name
|
||||
uninstall_app = entry.split("=")[0].strip()
|
||||
|
||||
self.results.append({"package_name": uninstall_app})
|
||||
body = match.group(2)
|
||||
name_match = re.search(r"(?:^|;\s*)name=([^;]+)", body)
|
||||
state = (
|
||||
"enabled"
|
||||
if re.search(r"(?:^|;\s*)enabled(?:;|$)", body)
|
||||
else "disabled"
|
||||
)
|
||||
overridable = bool(re.search(r"(?:^|;\s*)overridable(?:;|$)", body))
|
||||
overrides_field = body.split("rawOverrides={", 1)[1].split("}", 1)[0]
|
||||
for entry in overrides_field.split(","):
|
||||
package_name, separator, raw_value = entry.strip().partition("=")
|
||||
if not separator:
|
||||
continue
|
||||
value: bool | int | str
|
||||
if raw_value in ("true", "false"):
|
||||
value = raw_value == "true"
|
||||
else:
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except ValueError:
|
||||
value = raw_value
|
||||
self.results.append(
|
||||
{
|
||||
"change_id": int(match.group(1)),
|
||||
"change_name": name_match.group(1) if name_match else None,
|
||||
"change_state": state,
|
||||
"overridable": overridable,
|
||||
"package_name": package_name,
|
||||
"override_value": value,
|
||||
}
|
||||
)
|
||||
@@ -4,6 +4,7 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
from .package_resolvers import parse_resolver_table
|
||||
|
||||
INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS"
|
||||
INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED"
|
||||
@@ -18,115 +19,45 @@ class DumpsysReceiversArtifact(AndroidArtifact):
|
||||
"""
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for intent, receivers in self.results.items():
|
||||
for receiver in receivers:
|
||||
if intent == INTENT_NEW_OUTGOING_SMS:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept outgoing SMS messages: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming SMS messages: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_DATA_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming data SMS message: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_PHONE_STATE:
|
||||
self.log.info(
|
||||
"Found a receiver monitoring "
|
||||
'telephony state/incoming calls: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
elif intent == INTENT_NEW_OUTGOING_CALL:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring outgoing calls: "%s"',
|
||||
receiver["receiver"],
|
||||
)
|
||||
for receiver in self.results:
|
||||
intent = receiver["key"]
|
||||
if intent == INTENT_NEW_OUTGOING_SMS:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept outgoing SMS messages: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming SMS messages: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_DATA_SMS_RECEIVED:
|
||||
self.log.info(
|
||||
'Found a receiver to intercept incoming data SMS message: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_PHONE_STATE:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring telephony state/incoming calls: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
elif intent == INTENT_NEW_OUTGOING_CALL:
|
||||
self.log.info(
|
||||
'Found a receiver monitoring outgoing calls: "%s"',
|
||||
receiver["component"],
|
||||
)
|
||||
|
||||
if not self.indicators:
|
||||
continue
|
||||
if not self.indicators:
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(receiver["package_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
{intent: receiver},
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
continue
|
||||
ioc_match = self.indicators.check_app_id(receiver["package_name"])
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
receiver,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
|
||||
def parse(self, output: str) -> None:
|
||||
self.results: dict[str, list[dict[str, str]]] = {}
|
||||
|
||||
in_receiver_resolver_table = False
|
||||
in_non_data_actions = False
|
||||
intent = None
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Receiver Resolver Table:"):
|
||||
in_receiver_resolver_table = True
|
||||
continue
|
||||
|
||||
if not in_receiver_resolver_table:
|
||||
continue
|
||||
|
||||
if line.startswith(" Non-Data Actions:"):
|
||||
in_non_data_actions = True
|
||||
continue
|
||||
|
||||
if not in_non_data_actions:
|
||||
continue
|
||||
|
||||
# If we hit an empty line, the Non-Data Actions section should be
|
||||
# finished.
|
||||
if line.strip() == "":
|
||||
break
|
||||
|
||||
# We detect the action name.
|
||||
if (
|
||||
line.startswith(" " * 6)
|
||||
and not line.startswith(" " * 8)
|
||||
and ":" in line
|
||||
):
|
||||
intent = line.strip().replace(":", "")
|
||||
self.results[intent] = []
|
||||
continue
|
||||
|
||||
parts = line.strip().split(" ")
|
||||
if len(parts) < 2:
|
||||
# A single-token line here is not a receiver. Real dumpstate
|
||||
# output can print an action header mis-indented (observed with
|
||||
# 15 leading spaces instead of 6), which used to raise
|
||||
# IndexError and abort the whole module. Treat a trailing-colon
|
||||
# token as the next action, skip anything else.
|
||||
if parts[0].endswith(":"):
|
||||
intent = parts[0][:-1]
|
||||
self.results.setdefault(intent, [])
|
||||
continue
|
||||
|
||||
# If we are not in an intent block yet, skip.
|
||||
if not intent:
|
||||
continue
|
||||
|
||||
# If we are in a block but the line does not start with 8 spaces
|
||||
# it means the block ended a new one started, so we reset and
|
||||
# continue.
|
||||
if not line.startswith(" " * 8):
|
||||
intent = None
|
||||
continue
|
||||
|
||||
# If we got this far, we are processing receivers for the
|
||||
# activities we are interested in.
|
||||
receiver = parts[1]
|
||||
package_name = receiver.split("/")[0]
|
||||
|
||||
self.results[intent].append(
|
||||
{
|
||||
"package_name": package_name,
|
||||
"receiver": receiver,
|
||||
}
|
||||
)
|
||||
self.results = parse_resolver_table(output, "Receiver")
|
||||
@@ -28,19 +28,16 @@ INTERESTING_PROPERTIES = [
|
||||
class GetProp(AndroidArtifact):
|
||||
def parse(self, entry: str) -> None:
|
||||
self.results: List[Dict[str, str]] = []
|
||||
rxp = re.compile(r"\[(.+?)\]: \[(.+?)\]")
|
||||
# A property value may span several lines: persist.sys.boot.reason.history
|
||||
# prints one boot per line. Matching the whole section instead of line by
|
||||
# line lets a value run to the first closing bracket that ends a line.
|
||||
rxp = re.compile(
|
||||
r"^[ \t]*\[([^]]+)\]: \[(.*?)\][ \t\r]*$",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
|
||||
for line in entry.splitlines():
|
||||
line = line.strip()
|
||||
if line == "":
|
||||
continue
|
||||
|
||||
matches = re.findall(rxp, line)
|
||||
if not matches or len(matches[0]) != 2:
|
||||
continue
|
||||
|
||||
prop_entry = {"name": matches[0][0], "value": matches[0][1]}
|
||||
self.results.append(prop_entry)
|
||||
for name, value in rxp.findall(entry):
|
||||
self.results.append({"name": name, "value": value})
|
||||
|
||||
def get_device_timezone(self) -> str | None:
|
||||
"""
|
||||
|
||||
@@ -117,6 +117,55 @@ class Mounts(AndroidArtifact):
|
||||
# Skip lines that don't match expected format
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def parse_mountinfo(entry: str, process_id: int) -> list[dict[str, Any]]:
|
||||
"""Parse Linux /proc/PID/mountinfo records."""
|
||||
results = []
|
||||
for line in entry.splitlines():
|
||||
fields = line.split()
|
||||
if "-" not in fields:
|
||||
continue
|
||||
separator = fields.index("-")
|
||||
if separator < 6 or len(fields) < separator + 4:
|
||||
continue
|
||||
try:
|
||||
mount_id = int(fields[0])
|
||||
parent_id = int(fields[1])
|
||||
except ValueError:
|
||||
continue
|
||||
mount_options = fields[5].split(",")
|
||||
super_options = fields[separator + 3].split(",")
|
||||
options = list(dict.fromkeys(mount_options + super_options))
|
||||
# fields[5] are the per-mount (VFS) flags, the field after the
|
||||
# separator the superblock's; a mount is writable only if both
|
||||
# allow it. The merged list made every read-only overlay a HIGH.
|
||||
is_read_write = "rw" in mount_options and "rw" in super_options
|
||||
mount_point = fields[4].replace("\\040", " ")
|
||||
device = fields[separator + 2].replace("\\040", " ")
|
||||
filesystem_type = fields[separator + 1]
|
||||
is_system = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
|
||||
mount_point.startswith(f"{prefix}/")
|
||||
for prefix in SUSPICIOUS_MOUNT_POINTS
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"mount_id": mount_id,
|
||||
"parent_id": parent_id,
|
||||
"major_minor": fields[2],
|
||||
"root": fields[3].replace("\\040", " "),
|
||||
"mount_point": mount_point,
|
||||
"device": device,
|
||||
"filesystem_type": filesystem_type,
|
||||
"mount_options": ",".join(options),
|
||||
"options_list": options,
|
||||
"optional_fields": fields[6:separator],
|
||||
"is_system_partition": is_system,
|
||||
"is_read_write": is_read_write,
|
||||
"process_ids": [process_id],
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
"""
|
||||
Check for suspicious mount configurations that may indicate root access
|
||||
@@ -146,7 +195,14 @@ class Mounts(AndroidArtifact):
|
||||
)
|
||||
|
||||
# Check for other suspicious mount options
|
||||
suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS]
|
||||
# `rw` sitting in the merged option list is not evidence of
|
||||
# writability (see is_read_write in parse_mountinfo); every other
|
||||
# suspicious option is meaningful in either list.
|
||||
suspicious_opts = [
|
||||
opt
|
||||
for opt in options
|
||||
if opt in SUSPICIOUS_OPTIONS and (opt != "rw" or mount["is_read_write"])
|
||||
]
|
||||
if suspicious_opts and mount["is_system_partition"]:
|
||||
if (
|
||||
"noatime" in mount["mount_options"]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
|
||||
|
||||
RESOLVER_TYPES = {
|
||||
"Full MIME Types": "full_mime_type",
|
||||
"Base MIME Types": "base_mime_type",
|
||||
"Wild MIME Types": "wild_mime_type",
|
||||
"Schemes": "scheme",
|
||||
"Non-Data Actions": "non_data_action",
|
||||
"MIME Typed Actions": "mime_typed_action",
|
||||
}
|
||||
|
||||
ENTRY_RE = re.compile(
|
||||
r"^\s{8,}[0-9a-fA-F]+\s+(?P<component>\S+)"
|
||||
r"(?:\s+\((?P<filters>\d+)\s+filters?\))?\s*$"
|
||||
)
|
||||
|
||||
|
||||
def parse_resolver_table(content: str, table_name: str) -> list[dict]:
|
||||
"""Parse every resolver category from a dumpsys package table."""
|
||||
results: list[dict] = []
|
||||
in_table = False
|
||||
resolver_type: str | None = None
|
||||
key: str | None = None
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.startswith(f"{table_name} Resolver Table:"):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
|
||||
if line and not line.startswith(" "):
|
||||
break
|
||||
|
||||
section_match = re.match(r"^ {2}([^ ].*):\s*$", line)
|
||||
if section_match:
|
||||
resolver_type = RESOLVER_TYPES.get(section_match.group(1))
|
||||
key = None
|
||||
continue
|
||||
if resolver_type is None:
|
||||
continue
|
||||
|
||||
key_match = re.match(r"^ {6,}([^ ].*):\s*$", line)
|
||||
if key_match:
|
||||
key = key_match.group(1)
|
||||
continue
|
||||
if key is None:
|
||||
continue
|
||||
|
||||
entry_match = ENTRY_RE.match(line)
|
||||
if not entry_match:
|
||||
continue
|
||||
component = entry_match.group("component")
|
||||
results.append(
|
||||
{
|
||||
"resolver_type": resolver_type,
|
||||
"key": key,
|
||||
"package_name": component.split("/", 1)[0],
|
||||
"component": component,
|
||||
"filter_count": int(entry_match.group("filters") or 1),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -1,72 +1,91 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2023 The MVT Authors.
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
|
||||
FIELD_NAMES = {
|
||||
"LABEL": "label",
|
||||
"USER": "user",
|
||||
"PID": "pid",
|
||||
"TID": "tid",
|
||||
"PPID": "ppid",
|
||||
"VSZ": "virtual_memory_size",
|
||||
"RSS": "resident_set_size",
|
||||
"WCHAN": "wchan",
|
||||
"ADDR": "address",
|
||||
"S": "state",
|
||||
"PRI": "priority",
|
||||
"NI": "nice",
|
||||
"RTPRIO": "realtime_priority",
|
||||
"SCH": "scheduler",
|
||||
"PCY": "policy",
|
||||
"TIME": "cpu_time",
|
||||
"CMD": "command",
|
||||
"NAME": "command",
|
||||
}
|
||||
INTEGER_FIELDS = {
|
||||
"pid",
|
||||
"tid",
|
||||
"ppid",
|
||||
"virtual_memory_size",
|
||||
"resident_set_size",
|
||||
"priority",
|
||||
"nice",
|
||||
}
|
||||
|
||||
|
||||
class Processes(AndroidArtifact):
|
||||
def parse(self, entry: str) -> None:
|
||||
for line in entry.splitlines()[1:]:
|
||||
proc = line.split()
|
||||
self.results = []
|
||||
lines = [line for line in entry.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return
|
||||
headers = lines[0].split()
|
||||
if not all(header in FIELD_NAMES for header in headers):
|
||||
return
|
||||
|
||||
# Skip empty lines
|
||||
if len(proc) == 0:
|
||||
for line in lines[1:]:
|
||||
values = line.split(None, len(headers) - 1)
|
||||
if len(values) != len(headers):
|
||||
continue
|
||||
|
||||
# Sometimes WCHAN is empty.
|
||||
if len(proc) == 8:
|
||||
proc = proc[:5] + [""] + proc[5:]
|
||||
|
||||
# Sometimes there is the security label.
|
||||
if proc[0].startswith("u:r"):
|
||||
label = proc[0]
|
||||
proc = proc[1:]
|
||||
else:
|
||||
label = ""
|
||||
|
||||
# Sometimes there is no WCHAN.
|
||||
if len(proc) < 9:
|
||||
proc = proc[:5] + [""] + proc[5:]
|
||||
|
||||
self.results.append(
|
||||
{
|
||||
"user": proc[0],
|
||||
"pid": int(proc[1]),
|
||||
"ppid": int(proc[2]),
|
||||
"virtual_memory_size": int(proc[3]),
|
||||
"resident_set_size": int(proc[4]),
|
||||
"wchan": proc[5],
|
||||
"aprocress": proc[6],
|
||||
"stat": proc[7],
|
||||
"proc_name": proc[8].strip("[]"),
|
||||
"label": label,
|
||||
}
|
||||
)
|
||||
result = {}
|
||||
valid = True
|
||||
for header, raw in zip(headers, values):
|
||||
key = FIELD_NAMES[header]
|
||||
value: str | int = raw.strip("[]") if key == "command" else raw
|
||||
if key in INTEGER_FIELDS:
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
valid = False
|
||||
break
|
||||
result[key] = value
|
||||
if valid:
|
||||
self.results.append(result)
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
if not self.indicators:
|
||||
return
|
||||
|
||||
for result in self.results:
|
||||
proc_name = result.get("proc_name", "")
|
||||
if not proc_name:
|
||||
command = result.get("command", "")
|
||||
if not isinstance(command, str):
|
||||
continue
|
||||
|
||||
# Skipping this process because of false positives.
|
||||
if result["proc_name"] == "gatekeeperd":
|
||||
process_name = command.rsplit("/", 1)[-1]
|
||||
if not process_name or process_name == "gatekeeperd":
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_app_id(proc_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
continue
|
||||
|
||||
ioc_match = self.indicators.check_process(proc_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
|
||||
)
|
||||
for checker in (
|
||||
self.indicators.check_app_id,
|
||||
self.indicators.check_process,
|
||||
):
|
||||
ioc_match = checker(process_name)
|
||||
if ioc_match:
|
||||
self.alertstore.critical(
|
||||
ioc_match.message,
|
||||
"",
|
||||
result,
|
||||
matched_indicator=ioc_match.ioc,
|
||||
)
|
||||
break
|
||||
@@ -3,6 +3,13 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
|
||||
from .artifact import AndroidArtifact
|
||||
|
||||
ANDROID_DANGEROUS_SETTINGS = [
|
||||
@@ -58,23 +65,246 @@ ANDROID_DANGEROUS_SETTINGS = [
|
||||
},
|
||||
]
|
||||
|
||||
# dumpsys prints the fields of a setting record, and of a change history entry,
|
||||
# always in this order and separated by a single space. After the value come
|
||||
# `default:` and `defaultSystemSet:` when a default is recorded, then `tag:`;
|
||||
# some vendor builds add whether the value survives a restore, either as
|
||||
# `isValuePreservedInRestore:` or as a bare `notPreservedInRestore` token.
|
||||
SETTING_FIELDS = (
|
||||
"_id",
|
||||
"name",
|
||||
"pkg",
|
||||
"value",
|
||||
"default",
|
||||
"defaultSystemSet",
|
||||
"tag",
|
||||
"isValuePreservedInRestore",
|
||||
)
|
||||
HISTORY_FIELDS = ("time", "mode", "oldValue", "newValue", "package")
|
||||
|
||||
NAMESPACE_PATTERN = re.compile(
|
||||
r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$"
|
||||
)
|
||||
SECTION_END_PATTERN = re.compile(r"ending at: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
|
||||
|
||||
|
||||
class Settings(AndroidArtifact):
|
||||
"""Parser for the `dumpsys settings` output.
|
||||
|
||||
Every row of the settings provider becomes one result, keeping the fields
|
||||
dumpsys prints alongside the value: the row id, the package which recorded
|
||||
the setting, the default, the tag, and the change history. A setting name
|
||||
can appear more than once within a namespace, so results are a list rather
|
||||
than a mapping.
|
||||
"""
|
||||
|
||||
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult:
|
||||
records = []
|
||||
for entry in result.get("history", []):
|
||||
if not entry.get("timestamp"):
|
||||
continue
|
||||
|
||||
records.append(
|
||||
{
|
||||
"timestamp": entry["timestamp"],
|
||||
"module": self.__class__.__name__,
|
||||
"event": "settings_change",
|
||||
"data": (
|
||||
f"{result.get('namespace')} setting "
|
||||
f'"{result.get("name")}" changed from '
|
||||
f'"{entry.get("oldValue")}" to "{entry.get("newValue")}" '
|
||||
f"by {entry.get('pkg')}"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
def check_indicators(self) -> None:
|
||||
for namespace, settings in self.results.items():
|
||||
for key, value in settings.items():
|
||||
for danger in ANDROID_DANGEROUS_SETTINGS:
|
||||
# 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.alertstore.medium(
|
||||
f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})',
|
||||
"",
|
||||
{
|
||||
"namespace": namespace,
|
||||
"key": key,
|
||||
"value": value,
|
||||
"description": danger["description"],
|
||||
},
|
||||
)
|
||||
break
|
||||
for result in self.results:
|
||||
name = result.get("name")
|
||||
value = result.get("value")
|
||||
for danger in ANDROID_DANGEROUS_SETTINGS:
|
||||
# Check if one of the dangerous settings is using an unsafe
|
||||
# value (different than the one specified).
|
||||
if danger["key"] != name or danger["safe_value"] == value:
|
||||
continue
|
||||
|
||||
history = result.get("history") or []
|
||||
self.alertstore.medium(
|
||||
f'Found suspicious "{result.get("namespace")}" setting '
|
||||
f'"{name} = {value}" ({danger["description"]})',
|
||||
history[-1]["timestamp"] if history else "",
|
||||
result,
|
||||
)
|
||||
break
|
||||
|
||||
def parse(self, content: str) -> None:
|
||||
self.results: list[ModuleAtomicResult] = []
|
||||
section_end = self._parse_section_end(content)
|
||||
namespace: Optional[str] = None
|
||||
user: Optional[str] = None
|
||||
record_lines: list[str] = []
|
||||
history_lines: list[str] = []
|
||||
in_history = False
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal record_lines, history_lines, in_history
|
||||
if record_lines:
|
||||
self.results.append(
|
||||
self._build_record(
|
||||
namespace, user, record_lines, history_lines, section_end
|
||||
)
|
||||
)
|
||||
record_lines = []
|
||||
history_lines = []
|
||||
in_history = False
|
||||
|
||||
for line in content.splitlines():
|
||||
heading = NAMESPACE_PATTERN.match(line.strip())
|
||||
if heading:
|
||||
flush()
|
||||
namespace = heading.group(1).lower()
|
||||
user = heading.group(2)
|
||||
continue
|
||||
|
||||
if line.startswith("--------- "):
|
||||
# dumpsys closes every section with a duration trailer.
|
||||
flush()
|
||||
namespace = None
|
||||
continue
|
||||
|
||||
if namespace is None:
|
||||
continue
|
||||
|
||||
if not line.strip():
|
||||
# dumpsys prints a blank line after every namespace block and
|
||||
# after a change history, and other dumps such as the
|
||||
# generation registry follow the last block, so a blank line
|
||||
# closes the record being read.
|
||||
flush()
|
||||
continue
|
||||
|
||||
if line.startswith("_id:"):
|
||||
flush()
|
||||
record_lines = [line]
|
||||
continue
|
||||
|
||||
if not record_lines:
|
||||
continue
|
||||
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("History ("):
|
||||
in_history = True
|
||||
continue
|
||||
|
||||
if in_history:
|
||||
if stripped.startswith("time:"):
|
||||
history_lines.append(stripped)
|
||||
elif stripped and history_lines:
|
||||
# A history entry can be wrapped over several lines.
|
||||
history_lines[-1] += " " + stripped
|
||||
continue
|
||||
|
||||
# Anything else continues the value of the record being read.
|
||||
record_lines.append(line)
|
||||
|
||||
flush()
|
||||
|
||||
@staticmethod
|
||||
def _split_fields(text: str, keys: Sequence[str]) -> dict[str, str]:
|
||||
"""Split the `key:value` fields of one record.
|
||||
|
||||
Values are free-form and may contain spaces and newlines, so a field
|
||||
runs up to the start of the next key which is actually present. Keys
|
||||
dumpsys did not print are skipped.
|
||||
"""
|
||||
fields: dict[str, str] = {}
|
||||
key = keys[0]
|
||||
if not text.startswith(f"{key}:"):
|
||||
return fields
|
||||
|
||||
remainder = text[len(key) + 1 :]
|
||||
for next_key in keys[1:]:
|
||||
value, separator, rest = remainder.partition(f" {next_key}:")
|
||||
if separator:
|
||||
fields[key] = value
|
||||
key, remainder = next_key, rest
|
||||
|
||||
fields[key] = remainder
|
||||
return fields
|
||||
|
||||
@staticmethod
|
||||
def _parse_section_end(content: str) -> Optional[datetime]:
|
||||
"""Return the time the settings section was dumped, if reported."""
|
||||
match = SECTION_END_PATTERN.search(content)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_timestamp(
|
||||
value: str, section_end: Optional[datetime]
|
||||
) -> Optional[str]:
|
||||
"""Add the missing year to a `MM-DD HH:MM:SS.mmm` history timestamp.
|
||||
|
||||
dumpsys prints the change history without a year, so it is resolved
|
||||
against the time the section was dumped: the most recent matching date
|
||||
at or before that time.
|
||||
|
||||
The year is parsed together with the value, because strptime() would
|
||||
otherwise parse it in 1900, which has no 29 February. The most recent
|
||||
matching leap day can be eight years earlier when a century year is
|
||||
not a leap year and the current year's leap day is still in the future.
|
||||
"""
|
||||
if section_end is None:
|
||||
return None
|
||||
|
||||
for year in range(section_end.year, section_end.year - 9, -1):
|
||||
try:
|
||||
timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if timestamp <= section_end:
|
||||
return convert_datetime_to_iso(timestamp)
|
||||
|
||||
return None
|
||||
|
||||
def _parse_history(
|
||||
self, line: str, section_end: Optional[datetime]
|
||||
) -> ModuleAtomicResult:
|
||||
fields = self._split_fields(line, HISTORY_FIELDS)
|
||||
return {
|
||||
"timestamp": self._resolve_timestamp(fields.get("time", ""), section_end),
|
||||
"oldValue": fields.get("oldValue"),
|
||||
"newValue": fields.get("newValue"),
|
||||
"pkg": fields.get("package"),
|
||||
}
|
||||
|
||||
def _build_record(
|
||||
self,
|
||||
namespace: Optional[str],
|
||||
user: Optional[str],
|
||||
record_lines: list[str],
|
||||
history_lines: list[str],
|
||||
section_end: Optional[datetime],
|
||||
) -> ModuleAtomicResult:
|
||||
text = "\n".join(record_lines).rstrip()
|
||||
# The bare `notPreservedInRestore` token has no `key:` shape and is
|
||||
# printed last, so peel it off before splitting the fields.
|
||||
head = text.removesuffix(" notPreservedInRestore")
|
||||
|
||||
record: ModuleAtomicResult = {"namespace": namespace, "user": user}
|
||||
record.update(self._split_fields(head, SETTING_FIELDS))
|
||||
if head != text:
|
||||
record["isValuePreservedInRestore"] = "false"
|
||||
|
||||
record["history"] = [
|
||||
self._parse_history(entry, section_end) for entry in history_lines
|
||||
]
|
||||
return record
|
||||
@@ -131,6 +131,13 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
|
||||
) -> None:
|
||||
"""Parse Android tombstone crash files from a protobuf object."""
|
||||
self.results.append(self.parse_protobuf_record(file_name, file_timestamp, data))
|
||||
|
||||
def parse_protobuf_record(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
|
||||
) -> dict:
|
||||
if not data:
|
||||
raise ValueError("empty protobuf tombstone")
|
||||
tombstone_pb = Tombstone().parse(data)
|
||||
tombstone_dict = tombstone_pb.to_dict(
|
||||
casing=betterproto2.Casing.SNAKE, include_default_values=True
|
||||
@@ -143,20 +150,31 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
tombstone_dict["file_name"] = file_name
|
||||
tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp)
|
||||
tombstone_dict["process_name"] = self._proccess_name_from_thread(tombstone_dict)
|
||||
if isinstance(tombstone_dict.get("selinux_label"), str):
|
||||
tombstone_dict["selinux_label"] = tombstone_dict["selinux_label"].rstrip(
|
||||
"\x00"
|
||||
)
|
||||
|
||||
# Confirm the tombstone is valid, and matches the output model
|
||||
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
|
||||
self.results.append(tombstone.model_dump())
|
||||
return tombstone.model_dump()
|
||||
|
||||
def parse(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
|
||||
) -> None:
|
||||
"""Parse text Android tombstone crash files."""
|
||||
self.results.append(self.parse_text_record(file_name, file_timestamp, content))
|
||||
|
||||
def parse_text_record(
|
||||
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
|
||||
) -> dict:
|
||||
if not content:
|
||||
raise ValueError("empty plaintext tombstone")
|
||||
tombstone_dict = {
|
||||
"file_name": file_name,
|
||||
"file_timestamp": convert_datetime_to_iso(file_timestamp),
|
||||
}
|
||||
lines = content.decode("utf-8").splitlines()
|
||||
lines = content.decode("utf-8", errors="replace").splitlines()
|
||||
for line_num, line in enumerate(lines, 1):
|
||||
if not line.strip() or TOMBSTONE_DELIMITER in line:
|
||||
continue
|
||||
@@ -171,7 +189,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
|
||||
# Validate the tombstone and add it to the results
|
||||
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
|
||||
self.results.append(tombstone.model_dump())
|
||||
return tombstone.model_dump()
|
||||
|
||||
def _parse_tombstone_line(
|
||||
self, line: str, key: str, destination_key: str, tombstone: dict
|
||||
@@ -200,7 +218,9 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
if not separator or line_key != key:
|
||||
return False
|
||||
|
||||
value_clean = value.strip().strip("'")
|
||||
value_clean = value.strip()
|
||||
if len(value_clean) >= 2 and value_clean[0] == value_clean[-1] == "'":
|
||||
value_clean = value_clean[1:-1]
|
||||
if destination_key == "uid":
|
||||
tombstone[destination_key] = int(value_clean)
|
||||
elif destination_key == "process_uptime":
|
||||
@@ -274,9 +294,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
|
||||
@staticmethod
|
||||
def _parse_timestamp_string(timestamp: str) -> str:
|
||||
timestamp_parsed = parser.parse(timestamp)
|
||||
# Preserve the source wall-clock time while returning the project-wide ISO format.
|
||||
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
|
||||
return convert_datetime_to_iso(local_timestamp)
|
||||
return convert_datetime_to_iso(timestamp_parsed)
|
||||
|
||||
@staticmethod
|
||||
def _proccess_name_from_thread(tombstone_dict: dict) -> str:
|
||||
|
||||
+38
-8
@@ -28,6 +28,7 @@ from mvt.common.help import (
|
||||
HELP_MSG_DISABLE_UPDATE_CHECK,
|
||||
HELP_MSG_HASHES,
|
||||
HELP_MSG_IOC,
|
||||
HELP_MSG_JOBS,
|
||||
HELP_MSG_LIST_MODULES,
|
||||
HELP_MSG_LOAD_MODULE,
|
||||
HELP_MSG_MODULE,
|
||||
@@ -150,7 +151,20 @@ def check_adb(ctx):
|
||||
default=[],
|
||||
help=HELP_MSG_LOAD_MODULE,
|
||||
)
|
||||
@click.option(
|
||||
"--timezone",
|
||||
"-t",
|
||||
default=None,
|
||||
help=(
|
||||
"IANA timezone name for the device, for example 'Europe/Paris'. "
|
||||
"Bugreport file timestamps are the device's wall clock; by default the "
|
||||
"zone is read from persist.sys.timezone in the bugreport itself."
|
||||
),
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.option(
|
||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
||||
)
|
||||
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_bugreport(
|
||||
@@ -160,31 +174,38 @@ def check_bugreport(
|
||||
list_modules,
|
||||
module,
|
||||
load_module,
|
||||
timezone,
|
||||
verbose,
|
||||
jobs,
|
||||
bugreport_path,
|
||||
):
|
||||
from .cmd_check_bugreport import CmdAndroidCheckBugreport
|
||||
|
||||
set_verbose_logging(verbose or _get_verbose(ctx))
|
||||
custom_modules = _load_custom_modules(load_module)
|
||||
|
||||
module_options = {}
|
||||
if timezone:
|
||||
module_options["device_timezone"] = timezone
|
||||
|
||||
# Always generate hashes as bug reports are small.
|
||||
cmd = CmdAndroidCheckBugreport(
|
||||
target_path=bugreport_path,
|
||||
results_path=output,
|
||||
ioc_files=iocs,
|
||||
module_name=module,
|
||||
module_options=module_options if module_options else None,
|
||||
hashes=True,
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking Android bug report at path: %s", bugreport_path)
|
||||
|
||||
try:
|
||||
cmd.run()
|
||||
except BadZipFile as exc:
|
||||
@@ -221,6 +242,9 @@ def check_bugreport(
|
||||
@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_COMMAND)
|
||||
@click.option(
|
||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
||||
)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_backup(
|
||||
@@ -232,6 +256,7 @@ def check_backup(
|
||||
non_interactive,
|
||||
backup_password,
|
||||
verbose,
|
||||
jobs,
|
||||
backup_path,
|
||||
):
|
||||
from .cmd_check_backup import CmdAndroidCheckBackup
|
||||
@@ -253,14 +278,13 @@ def check_backup(
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking Android backup at path: %s", backup_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
@@ -298,6 +322,9 @@ def check_backup(
|
||||
@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_COMMAND)
|
||||
@click.option(
|
||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
||||
)
|
||||
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_androidqf(
|
||||
@@ -313,6 +340,7 @@ def check_androidqf(
|
||||
non_interactive,
|
||||
backup_password,
|
||||
verbose,
|
||||
jobs,
|
||||
androidqf_path,
|
||||
):
|
||||
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
|
||||
@@ -336,14 +364,13 @@ def check_androidqf(
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking AndroidQF acquisition at path: %s", androidqf_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_disable_adb_warning()
|
||||
@@ -387,6 +414,9 @@ def check_androidqf(
|
||||
),
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.option(
|
||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
||||
)
|
||||
@click.argument("LOGS_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_intrusion_logs(
|
||||
@@ -398,6 +428,7 @@ def check_intrusion_logs(
|
||||
load_module,
|
||||
timezone,
|
||||
verbose,
|
||||
jobs,
|
||||
logs_path,
|
||||
):
|
||||
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
|
||||
@@ -418,14 +449,13 @@ def check_intrusion_logs(
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking intrusion logs at path: %s", logs_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
|
||||
@@ -53,6 +53,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
jobs: int = 4,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -68,6 +69,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
self.platform = "android"
|
||||
@@ -82,6 +84,8 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
if not self.target_path:
|
||||
raise NoAndroidQFTargetPath
|
||||
|
||||
self.log.info("Checking AndroidQF acquisition at path: %s", self.target_path)
|
||||
|
||||
if os.path.isdir(self.target_path):
|
||||
self.__format = "dir"
|
||||
parent_path = Path(self.target_path).absolute().parent.as_posix()
|
||||
@@ -257,6 +261,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
hashes=self.hashes,
|
||||
sub_command=True,
|
||||
custom_modules=self.custom_modules,
|
||||
jobs=self.jobs,
|
||||
)
|
||||
cmd.from_zip(bugreport)
|
||||
cmd.run()
|
||||
@@ -288,6 +293,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
hashes=self.hashes,
|
||||
sub_command=True,
|
||||
custom_modules=self.custom_modules,
|
||||
jobs=self.jobs,
|
||||
)
|
||||
try:
|
||||
cmd.from_ab(backup)
|
||||
@@ -374,6 +380,7 @@ class CmdAndroidCheckAndroidQF(Command):
|
||||
hashes=self.hashes,
|
||||
sub_command=True,
|
||||
custom_modules=self.custom_modules,
|
||||
jobs=self.jobs,
|
||||
)
|
||||
cmd.run()
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ class CmdAndroidCheckBackup(Command):
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
jobs: int = 4,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -62,6 +63,7 @@ class CmdAndroidCheckBackup(Command):
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
self.platform = "android"
|
||||
@@ -129,6 +131,7 @@ class CmdAndroidCheckBackup(Command):
|
||||
assert self.target_path is not None # type: ignore[has-type]
|
||||
# Use a different local variable name to avoid any scoping issues
|
||||
backup_path: str = self.target_path # type: ignore[has-type]
|
||||
self.log.info("Checking Android backup at path: %s", backup_path)
|
||||
|
||||
if os.path.isfile(backup_path):
|
||||
self.__type = "ab"
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import fnmatch
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from mvt.android.modules.bugreport.base import BugReportModule
|
||||
from mvt.android.artifacts.getprop import GetProp
|
||||
from mvt.android.modules.bugreport.base import (
|
||||
DUMPSTATE_ENTRY_POINTS,
|
||||
BugReportModule,
|
||||
)
|
||||
from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
@@ -34,6 +40,7 @@ class CmdAndroidCheckBugreport(Command):
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
jobs: int = 4,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -49,6 +56,7 @@ class CmdAndroidCheckBugreport(Command):
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
self.platform = "android"
|
||||
@@ -87,14 +95,100 @@ class CmdAndroidCheckBugreport(Command):
|
||||
for file_name in self.__zip.namelist():
|
||||
self.__files.append(file_name)
|
||||
|
||||
if not self._has_dumpstate(self.__files):
|
||||
nested = self._nested_bugreport(bugreport_zip)
|
||||
if nested:
|
||||
self.__zip = nested
|
||||
self.__files = list(nested.namelist())
|
||||
|
||||
@staticmethod
|
||||
def _has_dumpstate(file_names: List[str]) -> bool:
|
||||
"""Whether these members carry any of the entry points the bug report
|
||||
modules read (see `BugReportModule._get_dumpstate_file`)."""
|
||||
return any(
|
||||
fnmatch.filter(file_names, pattern) for pattern in DUMPSTATE_ENTRY_POINTS
|
||||
)
|
||||
|
||||
def _nested_bugreport(self, outer: ZipFile) -> Optional[ZipFile]:
|
||||
"""Descend one level into an OEM wrapper archive.
|
||||
|
||||
MIUI / HyperOS hands out a zip of app logs, ANR traces and tcpdump
|
||||
captures with the real `bugreport-<device>-<timestamp>.zip` nested
|
||||
inside. Without this descent the outer archive has no entry point,
|
||||
every module reports it found no files, and the command still exits 0
|
||||
with an empty result.
|
||||
"""
|
||||
candidates = [
|
||||
name for name in outer.namelist() if name.lower().endswith(".zip")
|
||||
]
|
||||
candidates.sort(
|
||||
key=lambda name: (
|
||||
"bugreport" not in name.lower() and "dumpstate" not in name.lower()
|
||||
)
|
||||
)
|
||||
for name in candidates:
|
||||
try:
|
||||
inner = ZipFile(io.BytesIO(outer.read(name)))
|
||||
except (BadZipFile, OSError):
|
||||
continue
|
||||
if self._has_dumpstate(inner.namelist()):
|
||||
log.info("Found the bug report nested inside the archive: %s", name)
|
||||
return inner
|
||||
inner.close()
|
||||
return None
|
||||
|
||||
def init(self) -> None:
|
||||
if not self.target_path:
|
||||
if self.target_path:
|
||||
self.log.info("Checking Android bug report at path: %s", self.target_path)
|
||||
if os.path.isfile(self.target_path):
|
||||
self.from_zip(ZipFile(self.target_path))
|
||||
elif os.path.isdir(self.target_path):
|
||||
self.from_dir(self.target_path)
|
||||
self.log.warning(
|
||||
"Analysing an unpacked bugreport: file timestamps come from "
|
||||
"the extraction, not from the device. Analyse the original "
|
||||
"zip to keep the device's file timestamps."
|
||||
)
|
||||
if self.__format:
|
||||
self._resolve_device_timezone()
|
||||
|
||||
def _resolve_device_timezone(self) -> None:
|
||||
"""Name the device's timezone in module_options unless it is known already.
|
||||
|
||||
A bugreport's SYSTEM PROPERTIES section carries persist.sys.timezone.
|
||||
Zip entry times are the device's wall clock, and modules read them in
|
||||
this zone; --timezone or check-androidqf's own reading takes precedence.
|
||||
"""
|
||||
if self.module_options.get("device_timezone"):
|
||||
self.log.info("Device timezone: %s", self.module_options["device_timezone"])
|
||||
return
|
||||
|
||||
if os.path.isfile(self.target_path):
|
||||
self.from_zip(ZipFile(self.target_path))
|
||||
elif os.path.isdir(self.target_path):
|
||||
self.from_dir(self.target_path)
|
||||
probe = BugReportModule(log=self.log)
|
||||
self.module_init(probe)
|
||||
timezone = None
|
||||
try:
|
||||
dumpstate = probe._get_dumpstate_file()
|
||||
except Exception as exc:
|
||||
self.log.warning("Could not read the bugreport's dumpstate: %s", exc)
|
||||
dumpstate = None
|
||||
if dumpstate:
|
||||
properties = GetProp()
|
||||
properties.parse(
|
||||
BugReportModule.extract_command_section(
|
||||
dumpstate.decode("utf-8", errors="replace"),
|
||||
"------ SYSTEM PROPERTIES",
|
||||
)
|
||||
)
|
||||
timezone = properties.get_device_timezone()
|
||||
if timezone:
|
||||
self.log.info("Device timezone identified from the bugreport: %s", timezone)
|
||||
self.module_options["device_timezone"] = timezone
|
||||
else:
|
||||
self.log.warning(
|
||||
"persist.sys.timezone not found in the bugreport; file timestamps "
|
||||
"are the device's wall clock without a timezone. Pass --timezone "
|
||||
"to name it."
|
||||
)
|
||||
|
||||
def module_init(self, module: BugReportModule) -> None: # type: ignore[override]
|
||||
if self.__format == "zip":
|
||||
|
||||
@@ -37,6 +37,7 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
jobs: int = 4,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -52,6 +53,7 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
self.platform = "android"
|
||||
@@ -63,6 +65,8 @@ class CmdAndroidCheckIntrusionLogs(Command):
|
||||
if not self.target_path:
|
||||
raise ValueError("No target path specified")
|
||||
|
||||
self.log.info("Checking intrusion logs at path: %s", self.target_path)
|
||||
|
||||
if not os.path.isdir(self.target_path) and not (
|
||||
os.path.isfile(self.target_path)
|
||||
and self.target_path.lower().endswith(".zip")
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .aqf_files import AQFFiles
|
||||
from .aqf_getprop import AQFGetProp
|
||||
from .aqf_log_timestamps import AQFLogTimestamps
|
||||
@@ -12,7 +14,7 @@ from .aqf_settings import AQFSettings
|
||||
from .mounts import Mounts
|
||||
from .root_binaries import RootBinaries
|
||||
|
||||
ANDROIDQF_MODULES = [
|
||||
ANDROIDQF_MODULES: list[type[MVTModule]] = [
|
||||
AQFPackages,
|
||||
AQFProcesses,
|
||||
AQFGetProp,
|
||||
|
||||
@@ -8,8 +8,6 @@ import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from rich.progress import track
|
||||
|
||||
from mvt.android.utils import (
|
||||
BROWSER_INSTALLERS,
|
||||
PLAY_STORE_INSTALLERS,
|
||||
@@ -153,10 +151,9 @@ class AQFPackages(AndroidQFModule):
|
||||
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)
|
||||
):
|
||||
self.log.info("Looking up %d package files on VirusTotal...", total_hashes)
|
||||
for index, file_hash in enumerate(files_by_hash):
|
||||
self.log.debug("VirusTotal lookup %d/%d", index + 1, total_hashes)
|
||||
try:
|
||||
results = virustotal_lookup(file_hash)
|
||||
except VTNoKey as exc:
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
from mvt.common.module_types import ModuleResults
|
||||
|
||||
from .base import AndroidQFModule
|
||||
|
||||
@@ -15,43 +11,23 @@ from .base import AndroidQFModule
|
||||
class AQFSettings(SettingsArtifact, AndroidQFModule):
|
||||
"""This module analyse setting files"""
|
||||
|
||||
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,
|
||||
)
|
||||
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"):
|
||||
namespace = setting_file[setting_file.rfind("_") + 1 : -4]
|
||||
|
||||
self.results[namespace] = {}
|
||||
data = self._get_file_content(setting_file)
|
||||
for line in data.decode("utf-8").splitlines():
|
||||
line = line.strip()
|
||||
try:
|
||||
key, value = line.split("=", 1)
|
||||
except ValueError:
|
||||
name, separator, value = line.strip().partition("=")
|
||||
if not separator:
|
||||
continue
|
||||
|
||||
try:
|
||||
self.results[namespace][key] = value
|
||||
except IndexError:
|
||||
continue
|
||||
self.results.append(
|
||||
{
|
||||
"namespace": namespace,
|
||||
"user": None,
|
||||
"name": name,
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
|
||||
self.log.info(
|
||||
"Identified %d settings", sum([len(val) for val in self.results.values()])
|
||||
)
|
||||
self.log.info("Identified %d settings", len(self.results))
|
||||
@@ -81,12 +81,13 @@ class AndroidQFModule(MVTModule):
|
||||
return None
|
||||
|
||||
def _get_file_content(self, file_path):
|
||||
if self.archive:
|
||||
handle = self.archive.open(file_path)
|
||||
else:
|
||||
handle = open(os.path.join(self.parent_path, file_path), "rb")
|
||||
with self.resource_lock:
|
||||
if self.archive:
|
||||
handle = self.archive.open(file_path)
|
||||
else:
|
||||
handle = open(os.path.join(self.parent_path, file_path), "rb")
|
||||
|
||||
data = handle.read()
|
||||
handle.close()
|
||||
data = handle.read()
|
||||
handle.close()
|
||||
|
||||
return data
|
||||
return data
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .sms import SMS
|
||||
|
||||
BACKUP_MODULES = [SMS]
|
||||
BACKUP_MODULES: list[type[MVTModule]] = [SMS]
|
||||
@@ -55,21 +55,22 @@ class BackupModule(MVTModule):
|
||||
return fnmatch.filter(self.files, pattern)
|
||||
|
||||
def _get_file_content(self, file_path: str) -> bytes:
|
||||
handle = None
|
||||
if self.tar:
|
||||
try:
|
||||
member = self.tar.getmember(file_path)
|
||||
handle = self.tar.extractfile(member)
|
||||
if not handle:
|
||||
raise ValueError(f"Could not extract file: {file_path}")
|
||||
except KeyError:
|
||||
raise FileNotFoundError(f"File not found in tar: {file_path}")
|
||||
elif self.backup_path:
|
||||
handle = open(os.path.join(self.backup_path, file_path), "rb")
|
||||
else:
|
||||
raise ValueError("No backup path or tar file provided")
|
||||
with self.resource_lock:
|
||||
handle = None
|
||||
if self.tar:
|
||||
try:
|
||||
member = self.tar.getmember(file_path)
|
||||
handle = self.tar.extractfile(member)
|
||||
if not handle:
|
||||
raise ValueError(f"Could not extract file: {file_path}")
|
||||
except KeyError:
|
||||
raise FileNotFoundError(f"File not found in tar: {file_path}")
|
||||
elif self.backup_path:
|
||||
handle = open(os.path.join(self.backup_path, file_path), "rb")
|
||||
else:
|
||||
raise ValueError("No backup path or tar file provided")
|
||||
|
||||
data = handle.read()
|
||||
handle.close()
|
||||
data = handle.read()
|
||||
handle.close()
|
||||
|
||||
return data
|
||||
return data
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .dumpsys_accessibility import DumpsysAccessibility
|
||||
from .dumpsys_activities import DumpsysActivities
|
||||
from .dumpsys_appops import DumpsysAppops
|
||||
@@ -16,8 +18,11 @@ from .dumpsys_receivers import DumpsysReceivers
|
||||
from .dumpsys_adb_state import DumpsysADBState
|
||||
from .fs_timestamps import BugReportTimestamps
|
||||
from .tombstones import Tombstones
|
||||
from .mounts import Mounts
|
||||
from .processes import Processes
|
||||
from .settings import Settings
|
||||
|
||||
BUGREPORT_MODULES = [
|
||||
BUGREPORT_MODULES: list[type[MVTModule]] = [
|
||||
DumpsysAccessibility,
|
||||
DumpsysActivities,
|
||||
DumpsysAppops,
|
||||
@@ -31,4 +36,7 @@ BUGREPORT_MODULES = [
|
||||
DumpsysADBState,
|
||||
BugReportTimestamps,
|
||||
Tombstones,
|
||||
Processes,
|
||||
Settings,
|
||||
Mounts,
|
||||
]
|
||||
@@ -4,14 +4,45 @@
|
||||
# https://github.com/mvt-project/mvt/blob/main/LICENSE
|
||||
import datetime
|
||||
import fnmatch
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from zipfile import ZipFile
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from mvt.android.artifacts.artifact import AndroidArtifact
|
||||
from mvt.common.module import ModuleResults, MVTModule
|
||||
|
||||
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
|
||||
# section finishes and not necessarily between two sections.
|
||||
SECTION_DURATION = re.compile(r"^-{3,}\s*[0-9.]+s was the duration of", re.IGNORECASE)
|
||||
|
||||
# The members a bug report archive can be entered through, in the order
|
||||
# _get_dumpstate_file() tries them. An archive carrying none of them is not a
|
||||
# bug report at this level (see CmdAndroidCheckBugreport._has_dumpstate).
|
||||
DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
|
||||
|
||||
|
||||
_DUMPSTATE_BYTES_CACHE_KEY = ("android_bugreport", "dumpstate", "bytes")
|
||||
_DUMPSTATE_SECTIONS_CACHE_KEY = ("android_bugreport", "dumpstate", "sections")
|
||||
_SYSTEM_PROPERTIES_SECTION = b"------ SYSTEM PROPERTIES"
|
||||
_DUMPSYS_SEPARATORS = {
|
||||
b"DUMP OF SERVICE accessibility:",
|
||||
b"DUMP OF SERVICE adb:",
|
||||
b"DUMP OF SERVICE appops:",
|
||||
b"DUMP OF SERVICE batterystats:",
|
||||
b"DUMP OF SERVICE dbinfo:",
|
||||
b"DUMP OF SERVICE package:",
|
||||
b"DUMP OF SERVICE platform_compat:",
|
||||
}
|
||||
_DUMPSTATE_DELIMITER = (
|
||||
b"------------------------------------------------------------------------------"
|
||||
)
|
||||
_GETPROP_LINE_RE = re.compile(rb"\[(.+?)\]: \[(.+?)\]")
|
||||
|
||||
|
||||
class BugReportModule(MVTModule):
|
||||
"""This class provides a base for all Android Bug Report modules."""
|
||||
@@ -66,46 +97,210 @@ class BugReportModule(MVTModule):
|
||||
return []
|
||||
|
||||
def _get_file_content(self, file_path: str) -> bytes:
|
||||
if self.zip_archive:
|
||||
handle = self.zip_archive.open(file_path)
|
||||
else:
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
joined = os.path.join(self.extract_path, file_path)
|
||||
if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()):
|
||||
raise ValueError("unsafe file_path")
|
||||
handle = open(joined, "rb")
|
||||
with self.resource_lock:
|
||||
if self.zip_archive:
|
||||
handle = self.zip_archive.open(file_path)
|
||||
else:
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
joined = os.path.join(self.extract_path, file_path)
|
||||
if (
|
||||
not Path(joined)
|
||||
.resolve()
|
||||
.is_relative_to(Path(self.extract_path).resolve())
|
||||
):
|
||||
raise ValueError("unsafe file_path")
|
||||
handle = open(joined, "rb")
|
||||
|
||||
data = handle.read()
|
||||
handle.close()
|
||||
data = handle.read()
|
||||
handle.close()
|
||||
|
||||
return data
|
||||
return data
|
||||
|
||||
def _get_dumpstate_file(self) -> Optional[bytes]:
|
||||
main = self._get_files_by_pattern("main_entry.txt")
|
||||
if main:
|
||||
main_content = self._get_file_content(main[0])
|
||||
try:
|
||||
return self._get_file_content(main_content.decode().strip())
|
||||
except KeyError:
|
||||
return None
|
||||
with self.resource_lock:
|
||||
if _DUMPSTATE_BYTES_CACHE_KEY in self.resource_cache:
|
||||
return self.resource_cache[_DUMPSTATE_BYTES_CACHE_KEY]
|
||||
|
||||
dumpstate_logs = self._get_files_by_pattern("dumpState_*.log")
|
||||
if dumpstate_logs:
|
||||
return self._get_file_content(dumpstate_logs[0])
|
||||
content = None
|
||||
main_entry, dumpstate_log, dumpsys_txt = DUMPSTATE_ENTRY_POINTS
|
||||
main = self._get_files_by_pattern(main_entry)
|
||||
if main:
|
||||
main_content = self._get_file_content(main[0])
|
||||
try:
|
||||
content = self._get_file_content(main_content.decode().strip())
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
dumpstate_logs = self._get_files_by_pattern(dumpstate_log)
|
||||
if dumpstate_logs:
|
||||
content = self._get_file_content(dumpstate_logs[0])
|
||||
else:
|
||||
dumpsys_files = self._get_files_by_pattern(dumpsys_txt)
|
||||
if dumpsys_files:
|
||||
content = self._get_file_content(dumpsys_files[0])
|
||||
|
||||
dumpsys_files = self._get_files_by_pattern("*/dumpsys.txt")
|
||||
if dumpsys_files:
|
||||
return self._get_file_content(dumpsys_files[0])
|
||||
self.resource_cache[_DUMPSTATE_BYTES_CACHE_KEY] = content
|
||||
return content
|
||||
|
||||
return None
|
||||
def _get_dumpstate_sections(self) -> dict[bytes, bytes]:
|
||||
with self.resource_lock:
|
||||
if _DUMPSTATE_SECTIONS_CACHE_KEY in self.resource_cache:
|
||||
return self.resource_cache[_DUMPSTATE_SECTIONS_CACHE_KEY]
|
||||
|
||||
section_lines: dict[bytes, list[bytes]] = {
|
||||
separator: [] for separator in _DUMPSYS_SEPARATORS
|
||||
}
|
||||
section_lines[_SYSTEM_PROPERTIES_SECTION] = []
|
||||
current_section = None
|
||||
completed_sections = set()
|
||||
in_system_properties = False
|
||||
content = self._get_dumpstate_file()
|
||||
|
||||
if content:
|
||||
for raw_line in io.BytesIO(content):
|
||||
line = raw_line.rstrip(b"\r\n")
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith(_SYSTEM_PROPERTIES_SECTION):
|
||||
in_system_properties = True
|
||||
elif in_system_properties:
|
||||
if stripped == b"------":
|
||||
in_system_properties = False
|
||||
elif _GETPROP_LINE_RE.search(line):
|
||||
section_lines[_SYSTEM_PROPERTIES_SECTION].append(line)
|
||||
|
||||
if stripped in _DUMPSYS_SEPARATORS:
|
||||
if current_section is not None:
|
||||
completed_sections.add(current_section)
|
||||
current_section = (
|
||||
None if stripped in completed_sections else stripped
|
||||
)
|
||||
continue
|
||||
|
||||
if stripped.startswith(_DUMPSTATE_DELIMITER):
|
||||
if current_section is not None:
|
||||
completed_sections.add(current_section)
|
||||
current_section = None
|
||||
continue
|
||||
|
||||
if current_section is not None:
|
||||
section_lines[current_section].append(line)
|
||||
|
||||
sections = {
|
||||
separator: b"\n".join(lines)
|
||||
for separator, lines in section_lines.items()
|
||||
}
|
||||
self.resource_cache[_DUMPSTATE_SECTIONS_CACHE_KEY] = sections
|
||||
return sections
|
||||
|
||||
def _get_dumpsys_section_bytes(self, separator: bytes) -> Optional[bytes]:
|
||||
sections = self._get_dumpstate_sections()
|
||||
if separator in sections:
|
||||
return sections[separator]
|
||||
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
return None
|
||||
return AndroidArtifact.extract_dumpsys_section(content, separator, binary=True)
|
||||
|
||||
def _get_dumpsys_section(
|
||||
self, separator: str, errors: str = "replace"
|
||||
) -> Optional[str]:
|
||||
cache_key = (
|
||||
"android_bugreport",
|
||||
"dumpstate",
|
||||
"section",
|
||||
separator,
|
||||
errors,
|
||||
)
|
||||
with self.resource_lock:
|
||||
if cache_key in self.resource_cache:
|
||||
return self.resource_cache[cache_key]
|
||||
|
||||
section_bytes = self._get_dumpsys_section_bytes(separator.encode("utf-8"))
|
||||
section = (
|
||||
section_bytes.decode("utf-8", errors=errors)
|
||||
if section_bytes is not None
|
||||
else None
|
||||
)
|
||||
self.resource_cache[cache_key] = section
|
||||
return section
|
||||
|
||||
def _get_system_properties_text(self, errors: str = "ignore") -> Optional[str]:
|
||||
cache_key = (
|
||||
"android_bugreport",
|
||||
"dumpstate",
|
||||
"system_properties",
|
||||
errors,
|
||||
)
|
||||
with self.resource_lock:
|
||||
if cache_key in self.resource_cache:
|
||||
return self.resource_cache[cache_key]
|
||||
|
||||
sections = self._get_dumpstate_sections()
|
||||
section_bytes = sections.get(_SYSTEM_PROPERTIES_SECTION)
|
||||
section = (
|
||||
section_bytes.decode("utf-8", errors=errors)
|
||||
if section_bytes is not None
|
||||
else None
|
||||
)
|
||||
self.resource_cache[cache_key] = section
|
||||
return section
|
||||
|
||||
@staticmethod
|
||||
def extract_command_section(content: str, heading: str) -> str:
|
||||
"""Return a bugreport command section without consuming the next one.
|
||||
|
||||
Bugreport separators include timing text, so looking for a line equal to
|
||||
``------`` is not sufficient and can accidentally feed the remainder of
|
||||
dumpstate to a parser.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
in_section = False
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if not in_section:
|
||||
if stripped.startswith(heading):
|
||||
in_section = True
|
||||
continue
|
||||
if stripped.startswith("------"):
|
||||
# dumpstate prints a section's timing line when that section
|
||||
# finishes, which can land in the middle of the one being
|
||||
# written. Treating it as a boundary truncates the section at
|
||||
# an arbitrary point, silently.
|
||||
if SECTION_DURATION.match(stripped):
|
||||
continue
|
||||
break
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _device_timezone(self) -> Optional[datetime.tzinfo]:
|
||||
"""The device's timezone named in module_options, or None when unknown."""
|
||||
name = self.module_options.get("device_timezone")
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except ZoneInfoNotFoundError:
|
||||
self.log.warning("Unknown device timezone %s", name)
|
||||
return None
|
||||
|
||||
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
|
||||
"""When the file was last modified.
|
||||
|
||||
A zip entry carries the device's wall clock, so it is returned in the
|
||||
device's timezone when the bugreport names one and naive otherwise.
|
||||
An unpacked bugreport's mtime is whatever the extraction left, an
|
||||
instant returned in UTC.
|
||||
"""
|
||||
if self.zip_archive:
|
||||
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
||||
return datetime.datetime(*file_timetuple)
|
||||
else:
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
file_stat = os.stat(os.path.join(self.extract_path, file_path))
|
||||
return datetime.datetime.fromtimestamp(file_stat.st_mtime)
|
||||
with self.resource_lock:
|
||||
file_timetuple = self.zip_archive.getinfo(file_path).date_time
|
||||
return datetime.datetime(*file_timetuple, tzinfo=self._device_timezone())
|
||||
if not self.extract_path:
|
||||
raise ValueError("extract_path is not set")
|
||||
file_stat = os.stat(os.path.join(self.extract_path, file_path))
|
||||
return datetime.datetime.fromtimestamp(
|
||||
file_stat.st_mtime, tz=datetime.timezone.utc
|
||||
)
|
||||
@@ -34,25 +34,34 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
full_dumpsys = self._get_dumpstate_file()
|
||||
if not full_dumpsys:
|
||||
content = self._get_dumpsys_section(
|
||||
"DUMP OF SERVICE accessibility:", errors="ignore"
|
||||
)
|
||||
if content is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
content = self.extract_dumpsys_section(
|
||||
full_dumpsys.decode("utf-8", errors="ignore"),
|
||||
"DUMP OF SERVICE accessibility:",
|
||||
)
|
||||
self.parse(content)
|
||||
|
||||
listed = unnamed = 0
|
||||
for result in self.results:
|
||||
self.log.info(
|
||||
'Found installed accessibility service "%s"', result.get("service")
|
||||
)
|
||||
if result.get("component"):
|
||||
listed += 1
|
||||
self.log.info(
|
||||
'Found accessibility service "%s"', result.get("component")
|
||||
)
|
||||
continue
|
||||
# The operator gets this per user as a LOW alert from
|
||||
# check_indicators(); here it only has to survive into the summary,
|
||||
# so that a stated count never reads as "a total of 0".
|
||||
unnamed += result.get("unnamed_service_count") or 0
|
||||
|
||||
self.log.info(
|
||||
"Identified a total of %d accessibility services", len(self.results)
|
||||
"Identified a total of %d accessibility services (%d more stated by "
|
||||
"the dump without a component name)",
|
||||
listed,
|
||||
unnamed,
|
||||
)
|
||||
@@ -38,19 +38,14 @@ class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule):
|
||||
self.results = results if results else []
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
section = self._get_dumpsys_section("DUMP OF SERVICE package:", errors="ignore")
|
||||
if section is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
# Extract package section
|
||||
section = self.extract_dumpsys_section(
|
||||
content.decode("utf-8", errors="ignore"), "DUMP OF SERVICE package:"
|
||||
)
|
||||
|
||||
# Parse
|
||||
self.parse(section)
|
||||
|
||||
|
||||
@@ -38,19 +38,14 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
full_dumpsys = self._get_dumpstate_file()
|
||||
if not full_dumpsys:
|
||||
content = self._get_dumpsys_section_bytes(b"DUMP OF SERVICE adb:")
|
||||
if content is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
content = self.extract_dumpsys_section(
|
||||
full_dumpsys,
|
||||
b"DUMP OF SERVICE adb:",
|
||||
binary=True,
|
||||
)
|
||||
self.parse(content)
|
||||
if self.results:
|
||||
self.log.info(
|
||||
|
||||
@@ -34,17 +34,14 @@ class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
section = self._get_dumpsys_section("DUMP OF SERVICE appops:")
|
||||
if section is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
section = self.extract_dumpsys_section(
|
||||
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE appops:"
|
||||
)
|
||||
self.parse(section)
|
||||
|
||||
self.log.info(
|
||||
|
||||
@@ -34,17 +34,14 @@ class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE batterystats:")
|
||||
if dumpsys_section is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
dumpsys_section = self.extract_dumpsys_section(
|
||||
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
|
||||
)
|
||||
self.parse(dumpsys_section)
|
||||
|
||||
self.log.info("Extracted a total of %d battery daily stats", len(self.results))
|
||||
@@ -34,17 +34,14 @@ class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE batterystats:")
|
||||
if dumpsys_section is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
dumpsys_section = self.extract_dumpsys_section(
|
||||
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
|
||||
)
|
||||
self.parse(dumpsys_section)
|
||||
|
||||
self.log.info(
|
||||
|
||||
@@ -36,18 +36,14 @@ class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
section = self._get_dumpsys_section("DUMP OF SERVICE dbinfo:", errors="ignore")
|
||||
if section is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
section = self.extract_dumpsys_section(
|
||||
data.decode("utf-8", errors="ignore"), "DUMP OF SERVICE dbinfo:"
|
||||
)
|
||||
|
||||
self.parse(section)
|
||||
self.log.info(
|
||||
"Extracted a total of %d database connection pool records",
|
||||
|
||||
@@ -36,29 +36,13 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
|
||||
self.results = [] if not results else results
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
content = self._get_system_properties_text()
|
||||
if content is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
lines = []
|
||||
in_getprop = False
|
||||
|
||||
for line in content.decode(errors="ignore").splitlines():
|
||||
if line.strip().startswith("------ SYSTEM PROPERTIES"):
|
||||
in_getprop = True
|
||||
continue
|
||||
|
||||
if not in_getprop:
|
||||
continue
|
||||
|
||||
if line.strip() == "------":
|
||||
break
|
||||
|
||||
lines.append(line)
|
||||
|
||||
self.parse("\n".join(lines))
|
||||
self.parse(content)
|
||||
self.log.info("Extracted %d Android system properties", len(self.results))
|
||||
@@ -35,22 +35,22 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
content = self._get_dumpsys_section("DUMP OF SERVICE package:")
|
||||
if content is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
content = self.extract_dumpsys_section(
|
||||
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
|
||||
)
|
||||
self.parse(content)
|
||||
|
||||
for result in self.results:
|
||||
dangerous_permissions_count = 0
|
||||
for perm in result["permissions"]:
|
||||
permissions = list(result["permissions"])
|
||||
for user in result.get("users", []):
|
||||
permissions.extend(user.get("permissions", []))
|
||||
for perm in permissions:
|
||||
if perm["name"] in DANGEROUS_PERMISSIONS:
|
||||
dangerous_permissions_count += 1
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from mvt.common.module_types import ModuleResults
|
||||
|
||||
|
||||
class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
||||
"""This module extracts details on uninstalled apps."""
|
||||
"""This module extracts raw per-package compatibility overrides."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -34,18 +34,14 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
content = self._get_dumpsys_section("DUMP OF SERVICE platform_compat:")
|
||||
if content is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
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))
|
||||
self.log.info("Found %d package compatibility overrides", len(self.results))
|
||||
@@ -33,20 +33,16 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
|
||||
results=results,
|
||||
)
|
||||
|
||||
self.results = results if results else {}
|
||||
self.results = results if results else []
|
||||
|
||||
def run(self) -> None:
|
||||
content = self._get_dumpstate_file()
|
||||
if not content:
|
||||
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE package:")
|
||||
if dumpsys_section is None:
|
||||
self.log.error(
|
||||
"Unable to find dumpstate file. "
|
||||
"Did you provide a valid bug report archive?"
|
||||
)
|
||||
return
|
||||
|
||||
dumpsys_section = self.extract_dumpsys_section(
|
||||
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
|
||||
)
|
||||
|
||||
self.parse(dumpsys_section)
|
||||
self.log.info("Extracted receivers for %d intents", len(self.results))
|
||||
self.log.info("Extracted %d package receivers", len(self.results))
|
||||
@@ -40,13 +40,29 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
|
||||
|
||||
self.results = []
|
||||
for file in filesystem_files:
|
||||
# Only the modification time is available in the zip file metadata.
|
||||
# The timezone is the local timezone of the machine the phone.
|
||||
# A zip entry keeps the device's wall clock, read in the device's
|
||||
# timezone when the command found one (see CmdAndroidCheckBugreport);
|
||||
# an unpacked bugreport's mtime is read as a UTC instant.
|
||||
modification_time = self._get_file_modification_time(file)
|
||||
self.results.append(
|
||||
{
|
||||
"path": file,
|
||||
"modified_time": convert_datetime_to_iso(modification_time),
|
||||
"modified_time": convert_datetime_to_iso(
|
||||
modification_time.replace(tzinfo=None)
|
||||
),
|
||||
"modified_time_utc": (
|
||||
convert_datetime_to_iso(modification_time)
|
||||
if modification_time.tzinfo
|
||||
else None
|
||||
),
|
||||
"timezone": (
|
||||
self.module_options.get("device_timezone")
|
||||
if self.zip_archive
|
||||
else "UTC"
|
||||
),
|
||||
"timestamp_source": (
|
||||
"zip_metadata" if self.zip_archive else "filesystem_metadata"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
import re
|
||||
|
||||
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Mounts(MountsArtifact, BugReportModule):
|
||||
"""Extract and deduplicate process mount namespaces from mountinfo."""
|
||||
|
||||
def run(self) -> None:
|
||||
mount_files = self._get_files_by_pattern("FS/proc/*/mountinfo")
|
||||
if not mount_files:
|
||||
mount_files = self._get_files_by_pattern("*/proc/*/mountinfo")
|
||||
unique: dict[tuple, dict] = {}
|
||||
for file_path in mount_files:
|
||||
pid_match = re.search(r"/proc/(\d+)/mountinfo$", file_path)
|
||||
if not pid_match:
|
||||
continue
|
||||
entries = self.parse_mountinfo(
|
||||
self._get_file_content(file_path).decode("utf-8", errors="replace"),
|
||||
int(pid_match.group(1)),
|
||||
)
|
||||
for entry in entries:
|
||||
identity = (
|
||||
entry["major_minor"],
|
||||
entry["root"],
|
||||
entry["mount_point"],
|
||||
entry["device"],
|
||||
entry["filesystem_type"],
|
||||
entry["mount_options"],
|
||||
)
|
||||
if identity in unique:
|
||||
unique[identity]["process_ids"].extend(entry["process_ids"])
|
||||
else:
|
||||
unique[identity] = entry
|
||||
self.results = list(unique.values())
|
||||
self.log.info("Extracted %d unique mount records", len(self.results))
|
||||
@@ -0,0 +1,22 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
from mvt.android.artifacts.processes import Processes as ProcessesArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Processes(ProcessesArtifact, BugReportModule):
|
||||
"""Extract the process and thread table from dumpstate."""
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
self.log.error("Unable to find dumpstate file")
|
||||
return
|
||||
section = self.extract_command_section(
|
||||
data.decode("utf-8", errors="replace"),
|
||||
"------ PROCESSES AND THREADS",
|
||||
)
|
||||
self.parse(section)
|
||||
self.log.info("Identified %d running process threads", len(self.results))
|
||||
@@ -0,0 +1,21 @@
|
||||
# Mobile Verification Toolkit (MVT)
|
||||
# Copyright (c) 2021-2026 The MVT Authors.
|
||||
|
||||
from mvt.android.artifacts.settings import Settings as SettingsArtifact
|
||||
|
||||
from .base import BugReportModule
|
||||
|
||||
|
||||
class Settings(SettingsArtifact, BugReportModule):
|
||||
"""Extract all SettingsProvider namespaces and users."""
|
||||
|
||||
def run(self) -> None:
|
||||
data = self._get_dumpstate_file()
|
||||
if not data:
|
||||
self.log.error("Unable to find dumpstate file")
|
||||
return
|
||||
section = self.extract_dumpsys_section(
|
||||
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:"
|
||||
)
|
||||
self.parse(section)
|
||||
self.log.info("Identified %d Android settings", len(self.results))
|
||||
@@ -43,21 +43,76 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
|
||||
)
|
||||
return
|
||||
|
||||
for tombstone_file in sorted(tombstone_files):
|
||||
tombstone_filename = tombstone_file.split("/")[-1]
|
||||
modification_time = self._get_file_modification_time(tombstone_file)
|
||||
tombstone_data = self._get_file_content(tombstone_file)
|
||||
grouped: dict[str, dict[str, str]] = {}
|
||||
for file_path in tombstone_files:
|
||||
file_name = file_path.rsplit("/", 1)[-1]
|
||||
source = "protobuf" if file_name.endswith(".pb") else "text"
|
||||
crash_id = file_name.removesuffix(".pb")
|
||||
grouped.setdefault(crash_id, {})[source] = file_path
|
||||
|
||||
try:
|
||||
if tombstone_file.endswith(".pb"):
|
||||
self.parse_protobuf(
|
||||
tombstone_filename, modification_time, tombstone_data
|
||||
for crash_id, paths in sorted(grouped.items()):
|
||||
parsed_sources: dict[str, dict] = {}
|
||||
source_records: dict[str, dict] = {}
|
||||
for source in ("text", "protobuf"):
|
||||
file_path = paths.get(source)
|
||||
if file_path is None:
|
||||
continue
|
||||
file_name = file_path.rsplit("/", 1)[-1]
|
||||
file_timestamp = self._get_file_modification_time(file_path)
|
||||
source_info = {
|
||||
"file_name": file_name,
|
||||
"file_timestamp": file_timestamp.isoformat(),
|
||||
"parsed": False,
|
||||
"error": None,
|
||||
"record": None,
|
||||
}
|
||||
try:
|
||||
data = self._get_file_content(file_path)
|
||||
if source == "protobuf":
|
||||
record = self.parse_protobuf_record(
|
||||
file_name, file_timestamp, data
|
||||
)
|
||||
else:
|
||||
record = self.parse_text_record(file_name, file_timestamp, data)
|
||||
source_info["parsed"] = True
|
||||
source_info["record"] = record
|
||||
source_records[source] = record
|
||||
except Exception as exc:
|
||||
source_info["error"] = str(exc)
|
||||
self.log.error(
|
||||
"Error parsing tombstone file %s: %s", file_path, exc
|
||||
)
|
||||
else:
|
||||
self.parse(tombstone_filename, modification_time, tombstone_data)
|
||||
except ValueError as e:
|
||||
# Catch any exceptions raised during parsing or validation.
|
||||
self.log.error(f"Error parsing tombstone file {tombstone_file}: {e}")
|
||||
parsed_sources[source] = source_info
|
||||
|
||||
if not source_records:
|
||||
continue
|
||||
preferred = source_records.get("protobuf") or source_records["text"]
|
||||
canonical = dict(preferred)
|
||||
text_record = source_records.get("text")
|
||||
if text_record:
|
||||
for key, value in text_record.items():
|
||||
if canonical.get(key) in (None, "", [], {}):
|
||||
canonical[key] = value
|
||||
|
||||
differences = {}
|
||||
protobuf_record = source_records.get("protobuf")
|
||||
if text_record and protobuf_record:
|
||||
for key in text_record.keys() & protobuf_record.keys():
|
||||
if key in ("file_name", "file_timestamp"):
|
||||
continue
|
||||
if text_record[key] != protobuf_record[key]:
|
||||
differences[key] = {
|
||||
"text": text_record[key],
|
||||
"protobuf": protobuf_record[key],
|
||||
}
|
||||
canonical.update(
|
||||
{
|
||||
"crash_id": crash_id,
|
||||
"sources": parsed_sources,
|
||||
"differences": differences,
|
||||
}
|
||||
)
|
||||
self.results.append(canonical)
|
||||
|
||||
self.log.info(
|
||||
"Extracted a total of %d tombstone files",
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .connect_event import ConnectEvent
|
||||
from .dns_event import DnsEvent
|
||||
from .security_event import SecurityEvent
|
||||
|
||||
INTRUSION_LOGS_MODULES = [
|
||||
INTRUSION_LOGS_MODULES: list[type[MVTModule]] = [
|
||||
DnsEvent,
|
||||
ConnectEvent,
|
||||
SecurityEvent,
|
||||
|
||||
@@ -56,6 +56,10 @@ class CmdCheckIOCS(Command):
|
||||
if entry not in all_modules:
|
||||
all_modules.append(entry)
|
||||
|
||||
# Read the indicators once, so that a missing indicators file is
|
||||
# reported even when no stored result matches a module.
|
||||
iocs = self.iocs
|
||||
|
||||
log.info("Checking stored results against provided indicators...")
|
||||
|
||||
total_detections = 0
|
||||
@@ -83,8 +87,8 @@ class CmdCheckIOCS(Command):
|
||||
log.warning("No result from this module, skipping it")
|
||||
continue
|
||||
|
||||
if self.iocs.total_ioc_count > 0:
|
||||
m.indicators = self.iocs
|
||||
if iocs.total_ioc_count > 0:
|
||||
m.indicators = iocs
|
||||
m.indicators.log = m.log
|
||||
|
||||
try:
|
||||
|
||||
+234
-35
@@ -6,8 +6,12 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from functools import cached_property
|
||||
from heapq import heappop, heappush
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -18,6 +22,7 @@ from rich.text import Text
|
||||
from .alerts import AlertLevel, AlertStore
|
||||
from .config import settings
|
||||
from .indicators import Indicators
|
||||
from .log import MVTLogHandler, finish_module_log_buffer, start_module_log_buffer
|
||||
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
|
||||
from .module_loader import (
|
||||
ModuleOrigin,
|
||||
@@ -51,6 +56,7 @@ class Command:
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
jobs: int = 4,
|
||||
) -> None:
|
||||
self.name = ""
|
||||
self.platform = ""
|
||||
@@ -66,6 +72,11 @@ class Command:
|
||||
self.sub_command = sub_command
|
||||
self.disable_version_check = disable_version_check
|
||||
self.disable_indicator_check = disable_indicator_check
|
||||
if jobs < 1:
|
||||
raise ValueError("jobs must be at least 1")
|
||||
self.jobs = jobs
|
||||
self._resource_lock = threading.RLock()
|
||||
self._resource_cache: dict[object, Any] = {}
|
||||
|
||||
# This dictionary can contain options that will be passed down from
|
||||
# the Command to all modules. This can for example be used to pass
|
||||
@@ -84,18 +95,18 @@ class Command:
|
||||
self.timeline: ModuleTimeline = []
|
||||
self.url_results: list[URLResult] = []
|
||||
|
||||
# Load IOCs
|
||||
self._create_storage()
|
||||
self._setup_logging()
|
||||
|
||||
if iocs is not None:
|
||||
self.iocs = iocs
|
||||
else:
|
||||
self.iocs = Indicators(self.log)
|
||||
self.iocs.load_indicators_files(self.ioc_files)
|
||||
|
||||
self.alertstore = AlertStore()
|
||||
|
||||
@cached_property
|
||||
def iocs(self) -> Indicators:
|
||||
"""Load indicators on first use. Nested commands share their parent's."""
|
||||
iocs = Indicators(self.log)
|
||||
iocs.load_indicators_files(self.ioc_files)
|
||||
return iocs
|
||||
|
||||
def _create_storage(self) -> None:
|
||||
if self.results_path and not os.path.exists(self.results_path):
|
||||
try:
|
||||
@@ -709,28 +720,22 @@ class Command:
|
||||
|
||||
return ordered
|
||||
|
||||
def run(self) -> None:
|
||||
ordered_modules = self._ordered_modules()
|
||||
if ordered_modules is None:
|
||||
return
|
||||
|
||||
self._log_loaded_modules(ordered_modules)
|
||||
|
||||
def _run_module(
|
||||
self,
|
||||
module: type[MVTModule],
|
||||
executed_by_type: dict[type[MVTModule], MVTModule],
|
||||
capture_logs: bool,
|
||||
) -> tuple[MVTModule, list[logging.LogRecord], bool]:
|
||||
"""Initialize and run one module, optionally buffering console logs."""
|
||||
token = start_module_log_buffer() if capture_logs else None
|
||||
records: list[logging.LogRecord] = []
|
||||
encrypted = False
|
||||
try:
|
||||
self.init()
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
executed_by_type: dict[type[MVTModule], MVTModule] = {}
|
||||
for module in ordered_modules:
|
||||
|
||||
module_logger = get_module_logger(module)
|
||||
|
||||
m = module(
|
||||
target_path=self.target_path,
|
||||
results_path=self.results_path,
|
||||
module_options=self.module_options,
|
||||
log=module_logger,
|
||||
log=get_module_logger(module),
|
||||
)
|
||||
# Dependencies are keyed by the module class they declare, even
|
||||
# when it was replaced, so that a module asking for the results
|
||||
@@ -739,10 +744,12 @@ class Command:
|
||||
dependency: executed_by_type[resolved]
|
||||
for dependency, resolved in self._module_dependencies(module)
|
||||
}
|
||||
m.resource_lock = self._resource_lock
|
||||
m.resource_cache = self._resource_cache
|
||||
|
||||
if self.iocs.total_ioc_count:
|
||||
# IOC collections are shared read-only during module execution.
|
||||
m.indicators = self.iocs
|
||||
m.indicators.log = m.log
|
||||
|
||||
if self.serial:
|
||||
m.serial = self.serial
|
||||
@@ -755,23 +762,215 @@ class Command:
|
||||
try:
|
||||
run_module(m)
|
||||
except EncryptedBackupError:
|
||||
self.log.critical(
|
||||
"The backup appears to be encrypted. "
|
||||
"Please decrypt it first using `mvt-ios decrypt-backup`."
|
||||
)
|
||||
return
|
||||
encrypted = True
|
||||
finally:
|
||||
if token is not None:
|
||||
records = finish_module_log_buffer(token)
|
||||
|
||||
self.executed.append(m)
|
||||
executed_by_type[module] = m
|
||||
self.timeline.extend(m.timeline)
|
||||
self.url_results.extend(m.url_results)
|
||||
self.alertstore.extend(m.alertstore.alerts)
|
||||
return m, records, encrypted
|
||||
|
||||
@staticmethod
|
||||
def _console_handler() -> Optional[MVTLogHandler]:
|
||||
for handler in reversed(logging.getLogger("mvt").handlers):
|
||||
if isinstance(handler, MVTLogHandler):
|
||||
return handler
|
||||
return None
|
||||
|
||||
def _publish_completed_modules(
|
||||
self,
|
||||
ordered_modules: list[type[MVTModule]],
|
||||
completed: dict[type[MVTModule], MVTModule],
|
||||
) -> None:
|
||||
"""Expose a stable snapshot of completed modules to initialization hooks."""
|
||||
self.executed = [
|
||||
completed[module] for module in ordered_modules if module in completed
|
||||
]
|
||||
|
||||
def _aggregate_modules(
|
||||
self,
|
||||
ordered_modules: list[type[MVTModule]],
|
||||
completed: dict[type[MVTModule], MVTModule],
|
||||
) -> None:
|
||||
"""Aggregate results in stable topological order."""
|
||||
for module in ordered_modules:
|
||||
if module not in completed:
|
||||
continue
|
||||
instance = completed[module]
|
||||
self.timeline.extend(instance.timeline)
|
||||
self.url_results.extend(instance.url_results)
|
||||
self.alertstore.extend(instance.alertstore.alerts)
|
||||
|
||||
def _run_sequential(
|
||||
self, ordered_modules: list[type[MVTModule]]
|
||||
) -> tuple[dict[type[MVTModule], MVTModule], bool]:
|
||||
completed: dict[type[MVTModule], MVTModule] = {}
|
||||
for module in ordered_modules:
|
||||
instance, _, encrypted = self._run_module(module, completed, False)
|
||||
if encrypted:
|
||||
return completed, True
|
||||
completed[module] = instance
|
||||
self._publish_completed_modules(ordered_modules, completed)
|
||||
return completed, False
|
||||
|
||||
def _run_parallel(
|
||||
self, ordered_modules: list[type[MVTModule]], jobs: int
|
||||
) -> tuple[dict[type[MVTModule], MVTModule], bool]:
|
||||
"""Run a stable module DAG with bounded worker threads."""
|
||||
completed: dict[type[MVTModule], MVTModule] = {}
|
||||
scheduled: set[type[MVTModule]] = set()
|
||||
running: dict[Future, type[MVTModule]] = {}
|
||||
completion_queue: queue.Queue[Future] = queue.Queue()
|
||||
handler = self._console_handler()
|
||||
status = None
|
||||
fatal = False
|
||||
|
||||
def ready_modules() -> list[type[MVTModule]]:
|
||||
return [
|
||||
module
|
||||
for module in ordered_modules
|
||||
if module not in scheduled
|
||||
and all(
|
||||
dependency in completed
|
||||
for _, dependency in self._module_dependencies(module)
|
||||
)
|
||||
]
|
||||
|
||||
def update_status() -> None:
|
||||
if status is None:
|
||||
return
|
||||
names = ", ".join(module.__name__ for module in running.values())
|
||||
status.update(
|
||||
f"Modules: {len(completed)}/{len(ordered_modules)} complete"
|
||||
+ (f"; running {names}" if names else "")
|
||||
)
|
||||
|
||||
if handler and handler.console.is_terminal:
|
||||
status = handler.console.status("")
|
||||
status.start()
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=jobs) as executor:
|
||||
while len(completed) < len(ordered_modules):
|
||||
ready = ready_modules()
|
||||
|
||||
# An unsafe module is a scheduler barrier: it starts only
|
||||
# after active workers drain and blocks later ready work.
|
||||
if ready and not ready[0].parallel_safe:
|
||||
if running:
|
||||
future = completion_queue.get()
|
||||
module = running.pop(future)
|
||||
instance, records, encrypted = future.result()
|
||||
if handler:
|
||||
handler.emit_module_records(module.__name__, records)
|
||||
if encrypted:
|
||||
fatal = True
|
||||
break
|
||||
completed[module] = instance
|
||||
self._publish_completed_modules(ordered_modules, completed)
|
||||
update_status()
|
||||
continue
|
||||
|
||||
module = ready[0]
|
||||
scheduled.add(module)
|
||||
if status is not None:
|
||||
status.update(
|
||||
f"Modules: {len(completed)}/{len(ordered_modules)} "
|
||||
f"complete; running {module.__name__}"
|
||||
)
|
||||
instance, records, encrypted = self._run_module(
|
||||
module, completed, True
|
||||
)
|
||||
if handler:
|
||||
handler.emit_module_records(module.__name__, records)
|
||||
if encrypted:
|
||||
fatal = True
|
||||
break
|
||||
completed[module] = instance
|
||||
self._publish_completed_modules(ordered_modules, completed)
|
||||
update_status()
|
||||
continue
|
||||
|
||||
for module in ready:
|
||||
if len(running) >= jobs or not module.parallel_safe:
|
||||
break
|
||||
scheduled.add(module)
|
||||
future = executor.submit(
|
||||
self._run_module, module, dict(completed), True
|
||||
)
|
||||
running[future] = module
|
||||
future.add_done_callback(completion_queue.put)
|
||||
|
||||
update_status()
|
||||
if not running:
|
||||
break
|
||||
|
||||
future = completion_queue.get()
|
||||
module = running.pop(future)
|
||||
instance, records, encrypted = future.result()
|
||||
if handler:
|
||||
handler.emit_module_records(module.__name__, records)
|
||||
if encrypted:
|
||||
fatal = True
|
||||
break
|
||||
completed[module] = instance
|
||||
self._publish_completed_modules(ordered_modules, completed)
|
||||
update_status()
|
||||
|
||||
if fatal:
|
||||
for future in running:
|
||||
future.cancel()
|
||||
# The executor context waits for already-active work. Its
|
||||
# results are intentionally discarded after the fatal error.
|
||||
finally:
|
||||
if status is not None:
|
||||
status.stop()
|
||||
|
||||
return completed, fatal
|
||||
|
||||
def run(self) -> None:
|
||||
self._create_storage()
|
||||
self._setup_logging()
|
||||
|
||||
ordered_modules = self._ordered_modules()
|
||||
if ordered_modules is None:
|
||||
return
|
||||
|
||||
# Load shared indicators before touching the acquisition.
|
||||
self.iocs
|
||||
|
||||
try:
|
||||
self.init()
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
self._log_loaded_modules(ordered_modules)
|
||||
|
||||
jobs = self.jobs
|
||||
if settings.PROFILE and jobs > 1:
|
||||
self.log.warning(
|
||||
"MVT_PROFILE is enabled; forcing sequential module execution."
|
||||
)
|
||||
jobs = 1
|
||||
|
||||
if jobs == 1:
|
||||
completed, fatal = self._run_sequential(ordered_modules)
|
||||
else:
|
||||
completed, fatal = self._run_parallel(ordered_modules, jobs)
|
||||
|
||||
self._aggregate_modules(ordered_modules, completed)
|
||||
|
||||
try:
|
||||
self.finish()
|
||||
except NotImplementedError:
|
||||
pass
|
||||
|
||||
if fatal:
|
||||
self.log.critical(
|
||||
"The backup appears to be encrypted. "
|
||||
"Please decrypt it first using `mvt-ios decrypt-backup`."
|
||||
)
|
||||
return
|
||||
|
||||
# We only store the timeline from the parent/main command
|
||||
if self.sub_command:
|
||||
return
|
||||
|
||||
@@ -12,7 +12,9 @@ from pydantic_settings import (
|
||||
YamlConfigSettingsSource,
|
||||
)
|
||||
|
||||
MVT_CONFIG_FOLDER = user_config_dir("mvt")
|
||||
# MVT_CONFIG_FOLDER in the environment relocates the settings file, so that
|
||||
# a test run or a scripted install never touches the user's own.
|
||||
MVT_CONFIG_FOLDER = os.environ.get("MVT_CONFIG_FOLDER") or user_config_dir("mvt")
|
||||
MVT_CONFIG_PATH = os.path.join(MVT_CONFIG_FOLDER, "config.yaml")
|
||||
|
||||
|
||||
@@ -59,13 +61,16 @@ class MVTSettings(BaseSettings):
|
||||
dotenv_settings: PydanticBaseSettingsSource,
|
||||
file_secret_settings: PydanticBaseSettingsSource,
|
||||
) -> Tuple[PydanticBaseSettingsSource, ...]:
|
||||
yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH)
|
||||
sources: Tuple[PydanticBaseSettingsSource, ...] = (
|
||||
yaml_source,
|
||||
YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH),
|
||||
init_settings,
|
||||
)
|
||||
# Always load env variables by default
|
||||
sources = (env_settings,) + sources
|
||||
# Load env variables only when asked to. initialise() constructs the
|
||||
# settings once without them so that what gets written back to
|
||||
# config.yaml never includes values taken from the environment.
|
||||
# init_settings() returns the keyword arguments passed to the constructor.
|
||||
if init_settings().get("load_env", True):
|
||||
sources = (env_settings,) + sources
|
||||
return sources
|
||||
|
||||
def save_settings(
|
||||
@@ -92,7 +97,7 @@ class MVTSettings(BaseSettings):
|
||||
|
||||
Afterwards we load the settings again, this time including the env variables.
|
||||
"""
|
||||
# Set invalid env prefix to avoid loading env variables.
|
||||
# Construct the settings without env variables so they are not persisted.
|
||||
settings = cls(load_env=False)
|
||||
settings.save_settings()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ HELP_MSG_LOAD_MODULE = (
|
||||
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"
|
||||
HELP_MSG_JOBS = "Number of extraction modules to run concurrently"
|
||||
HELP_MSG_VERBOSE_COMMAND = (
|
||||
"Verbose mode (kept for compatibility, pass --verbose before the command "
|
||||
"name instead)"
|
||||
@@ -39,6 +40,7 @@ HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
|
||||
HELP_MSG_BACKUP_DESTINATION = (
|
||||
"Path to the folder where the decrypted backup should be stored"
|
||||
)
|
||||
HELP_MSG_DECRYPT_JOBS = "Number of files to decrypt concurrently"
|
||||
HELP_MSG_IOS_BACKUP_PASSWORD = (
|
||||
"Password to use to decrypt the backup (or, set the {MVT_IOS_BACKUP_PASSWORD} "
|
||||
"environment variable)"
|
||||
|
||||
@@ -18,7 +18,9 @@ from appdirs import user_data_dir
|
||||
from .config import settings
|
||||
from .url import URL
|
||||
|
||||
MVT_DATA_FOLDER = user_data_dir("mvt")
|
||||
# MVT_DATA_FOLDER in the environment relocates the downloaded indicators and
|
||||
# the update-check state kept next to them.
|
||||
MVT_DATA_FOLDER = os.environ.get("MVT_DATA_FOLDER") or user_data_dir("mvt")
|
||||
MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -71,7 +73,9 @@ class Indicators:
|
||||
if os.path.isfile(path) and path.lower().endswith(".stix2"):
|
||||
self.parse_stix2(path)
|
||||
elif os.path.isdir(path):
|
||||
for file in glob.glob(os.path.join(path, "**", "*.stix2"), recursive=True):
|
||||
for file in glob.glob(
|
||||
os.path.join(path, "**", "*.stix2"), recursive=True
|
||||
):
|
||||
self.parse_stix2(file)
|
||||
else:
|
||||
self.log.error(
|
||||
@@ -518,9 +522,7 @@ class Indicators:
|
||||
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)
|
||||
)
|
||||
unique_urls = list(dict.fromkeys(url for urls in batches for url in urls))
|
||||
|
||||
if not unique_urls:
|
||||
return [None] * len(batches)
|
||||
|
||||
+41
-1
@@ -4,6 +4,8 @@
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from contextvars import ContextVar, Token
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from typing import Optional
|
||||
@@ -27,16 +29,33 @@ logging.addLevelName(HIGH_ALERT, "HIGH")
|
||||
logging.addLevelName(CRITICAL_ALERT, "CRITICAL")
|
||||
|
||||
|
||||
_module_log_buffer: ContextVar[Optional[list[logging.LogRecord]]] = ContextVar(
|
||||
"mvt_module_log_buffer", default=None
|
||||
)
|
||||
|
||||
|
||||
def start_module_log_buffer() -> Token:
|
||||
"""Capture console records in the current module execution context."""
|
||||
return _module_log_buffer.set([])
|
||||
|
||||
|
||||
def finish_module_log_buffer(token: Token) -> list[logging.LogRecord]:
|
||||
records = _module_log_buffer.get() or []
|
||||
_module_log_buffer.reset(token)
|
||||
return records
|
||||
|
||||
|
||||
class MVTLogHandler(RichHandler):
|
||||
def __init__(self, console: Optional[Console] = None, level: int = logging.DEBUG):
|
||||
super().__init__(console=console, level=level)
|
||||
self._console_lock = threading.RLock()
|
||||
|
||||
def __add_prefix_space(self, level: str) -> str:
|
||||
max_length = len("CRITICAL ALERT")
|
||||
space = max_length - len(level)
|
||||
return f"{level}{' ' * space}"
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
def _emit_record(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
msg = rf"[grey50]\[{record.name}][/] {self.format(record)}"
|
||||
|
||||
@@ -63,3 +82,24 @@ class MVTLogHandler(RichHandler):
|
||||
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
buffer = _module_log_buffer.get()
|
||||
if buffer is not None:
|
||||
# More than one CLI can be imported in-process (notably in tests),
|
||||
# which can install multiple console handlers for the same record.
|
||||
if not buffer or buffer[-1] is not record:
|
||||
buffer.append(record)
|
||||
return
|
||||
|
||||
with self._console_lock:
|
||||
self._emit_record(record)
|
||||
|
||||
def emit_module_records(
|
||||
self, module_name: str, records: list[logging.LogRecord]
|
||||
) -> None:
|
||||
"""Print one module's captured records without interleaving."""
|
||||
with self._console_lock:
|
||||
self.console.print(f"[bold cyan]--- {module_name} ---[/bold cyan]")
|
||||
for record in records:
|
||||
self._emit_record(record)
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
@@ -46,6 +47,7 @@ class MVTModule:
|
||||
slug: Optional[str] = None
|
||||
dependencies: Sequence[type["MVTModule"]] = ()
|
||||
supported_commands: Sequence[tuple[str, str]] = ()
|
||||
parallel_safe: bool = True
|
||||
# A custom module can name a module class it supersedes, usually a
|
||||
# built-in one. When both are available to a command, the named module is
|
||||
# dropped from the run and this module takes its place, including in the
|
||||
@@ -90,6 +92,12 @@ class MVTModule:
|
||||
self.timeline: ModuleTimeline = []
|
||||
self.url_results: list[URLResult] = []
|
||||
self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {}
|
||||
# Commands replace this with a command-scoped lock for modules sharing
|
||||
# archive handles or other non-thread-safe read resources.
|
||||
self.resource_lock = threading.RLock()
|
||||
# Commands also replace this with a command-scoped cache. Cached values
|
||||
# must be immutable, because parallel modules can read them concurrently.
|
||||
self.resource_cache: Dict[object, Any] = {}
|
||||
|
||||
def get_dependency_results(
|
||||
self, module_class: type["MVTModule"]
|
||||
@@ -185,17 +193,20 @@ class MVTModule:
|
||||
|
||||
"""
|
||||
timeline_set = set()
|
||||
deduplicated = []
|
||||
for record in timeline:
|
||||
timeline_set.add(
|
||||
json.dumps(
|
||||
asdict(record)
|
||||
if is_dataclass(record) and not isinstance(record, type)
|
||||
else record,
|
||||
sort_keys=True,
|
||||
)
|
||||
serialized = json.dumps(
|
||||
asdict(record)
|
||||
if is_dataclass(record) and not isinstance(record, type)
|
||||
else record,
|
||||
sort_keys=True,
|
||||
)
|
||||
if serialized in timeline_set:
|
||||
continue
|
||||
timeline_set.add(serialized)
|
||||
deduplicated.append(json.loads(serialized))
|
||||
|
||||
return [json.loads(record) for record in timeline_set]
|
||||
return deduplicated
|
||||
|
||||
def to_timeline(self) -> None:
|
||||
"""Convert results into a timeline."""
|
||||
|
||||
@@ -349,7 +349,9 @@ class URL:
|
||||
return tld_obj
|
||||
if tld_obj is None:
|
||||
return ""
|
||||
return tld_obj.parsed_url.netloc.lower().lstrip("www.")
|
||||
# removeprefix, not lstrip: lstrip takes a set of characters, so it ate the
|
||||
# leading "w"s and dots of any domain ("web.evil.com" -> "eb.evil.com").
|
||||
return tld_obj.parsed_url.netloc.lower().removeprefix("www.")
|
||||
|
||||
def get_top_level(self) -> str:
|
||||
"""Get only the top-level domain from a URL.
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
# 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.7.29"
|
||||
from importlib.metadata import version
|
||||
|
||||
MVT_VERSION = version("mvt")
|
||||
+33
-12
@@ -24,6 +24,7 @@ from mvt.common.help import (
|
||||
HELP_MSG_VERSION,
|
||||
HELP_MSG_DECRYPT_BACKUP,
|
||||
HELP_MSG_BACKUP_DESTINATION,
|
||||
HELP_MSG_DECRYPT_JOBS,
|
||||
HELP_MSG_IOS_BACKUP_PASSWORD,
|
||||
HELP_MSG_BACKUP_KEYFILE,
|
||||
HELP_MSG_HASHES,
|
||||
@@ -35,6 +36,7 @@ from mvt.common.help import (
|
||||
HELP_MSG_LOAD_MODULE,
|
||||
HELP_MSG_MODULE,
|
||||
HELP_MSG_VERBOSE,
|
||||
HELP_MSG_JOBS,
|
||||
HELP_MSG_VERBOSE_COMMAND,
|
||||
HELP_MSG_CHECK_FS,
|
||||
HELP_MSG_CHECK_IOCS,
|
||||
@@ -45,6 +47,10 @@ from mvt.common.help import (
|
||||
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
|
||||
)
|
||||
from mvt.common.password import prompt_password
|
||||
from .decrypt_config import (
|
||||
DEFAULT_DECRYPT_WORKERS,
|
||||
MAX_DECRYPT_WORKERS,
|
||||
)
|
||||
|
||||
# The commands import what they run only when they are invoked. This module is
|
||||
# imported at every start of mvt-ios, including by shell completion on every
|
||||
@@ -128,6 +134,13 @@ def version():
|
||||
"decrypt-backup", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_DECRYPT_BACKUP
|
||||
)
|
||||
@click.option("--destination", "-d", required=True, help=HELP_MSG_BACKUP_DESTINATION)
|
||||
@click.option(
|
||||
"--jobs",
|
||||
type=click.IntRange(1, MAX_DECRYPT_WORKERS),
|
||||
default=DEFAULT_DECRYPT_WORKERS,
|
||||
show_default=True,
|
||||
help=HELP_MSG_DECRYPT_JOBS,
|
||||
)
|
||||
@click.option(
|
||||
"--password",
|
||||
"-p",
|
||||
@@ -146,10 +159,10 @@ def version():
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def decrypt_backup(ctx, destination, password, key_file, hashes, backup_path):
|
||||
def decrypt_backup(ctx, destination, jobs, password, key_file, hashes, backup_path):
|
||||
from .decrypt import DecryptBackup
|
||||
|
||||
backup = DecryptBackup(backup_path, destination)
|
||||
backup = DecryptBackup(backup_path, destination, max_workers=jobs)
|
||||
|
||||
if key_file:
|
||||
if MVT_IOS_BACKUP_PASSWORD in os.environ:
|
||||
@@ -267,6 +280,9 @@ def extract_key(password, key_file, backup_path):
|
||||
)
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.option(
|
||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
||||
)
|
||||
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_backup(
|
||||
@@ -279,6 +295,7 @@ def check_backup(
|
||||
load_module,
|
||||
hashes,
|
||||
verbose,
|
||||
jobs,
|
||||
backup_path,
|
||||
):
|
||||
from .cmd_check_backup import CmdIOSCheckBackup
|
||||
@@ -297,6 +314,7 @@ def check_backup(
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
@@ -306,8 +324,6 @@ def check_backup(
|
||||
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()
|
||||
cmd.show_support_message()
|
||||
@@ -338,6 +354,9 @@ def check_backup(
|
||||
)
|
||||
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
|
||||
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
|
||||
@click.option(
|
||||
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
|
||||
)
|
||||
@click.argument("DUMP_PATH", type=click.Path(exists=True))
|
||||
@click.pass_context
|
||||
def check_fs(
|
||||
@@ -350,6 +369,7 @@ def check_fs(
|
||||
load_module,
|
||||
hashes,
|
||||
verbose,
|
||||
jobs,
|
||||
dump_path,
|
||||
):
|
||||
from .cmd_check_fs import CmdIOSCheckFS
|
||||
@@ -368,14 +388,13 @@ def check_fs(
|
||||
disable_version_check=_get_disable_flags(ctx)[0],
|
||||
disable_indicator_check=_get_disable_flags(ctx)[1],
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
if list_modules:
|
||||
cmd.list_modules()
|
||||
return
|
||||
|
||||
log.info("Checking iOS filesystem located at: %s", dump_path)
|
||||
|
||||
cmd.run()
|
||||
cmd.show_alerts_brief()
|
||||
cmd.show_support_message()
|
||||
@@ -437,18 +456,20 @@ def check_sysdiagnose(
|
||||
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\"),)."
|
||||
# MVT's own module only records the device details; the checks come from
|
||||
# custom modules, so a run without any must not look like a clean analysis.
|
||||
if all(module in cmd.modules for module in cmd._available_modules()):
|
||||
log.warning(
|
||||
"No forensic sysdiagnose modules have been loaded: MVT's own "
|
||||
"SysdiagnoseInfo module only records the device details. Install a "
|
||||
"module package or load a module that declares supported_commands = "
|
||||
'(("ios", "check-sysdiagnose"),) to check the 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()
|
||||
|
||||
@@ -38,6 +38,7 @@ class CmdIOSCheckBackup(Command):
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
jobs: int = 4,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -53,12 +54,16 @@ class CmdIOSCheckBackup(Command):
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
self.platform = "ios"
|
||||
self.name = "check-backup"
|
||||
self.modules = BACKUP_MODULES + MIXED_MODULES
|
||||
|
||||
def init(self) -> None:
|
||||
self.log.info("Checking iTunes backup located at: %s", self.target_path)
|
||||
|
||||
def resolve_backup_path(self) -> bool:
|
||||
target_path = getattr(self, "target_path", None)
|
||||
if not isinstance(target_path, str) or not target_path:
|
||||
|
||||
@@ -31,6 +31,7 @@ class CmdIOSCheckFS(Command):
|
||||
disable_version_check: bool = False,
|
||||
disable_indicator_check: bool = False,
|
||||
custom_modules: Optional[list[type[MVTModule]]] = None,
|
||||
jobs: int = 4,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
target_path=target_path,
|
||||
@@ -45,11 +46,15 @@ class CmdIOSCheckFS(Command):
|
||||
disable_version_check=disable_version_check,
|
||||
disable_indicator_check=disable_indicator_check,
|
||||
custom_modules=custom_modules,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
self.platform = "ios"
|
||||
self.name = "check-fs"
|
||||
self.modules = FS_MODULES + MIXED_MODULES
|
||||
|
||||
def init(self) -> None:
|
||||
self.log.info("Checking iOS filesystem located at: %s", self.target_path)
|
||||
|
||||
def module_init(self, module):
|
||||
module.is_fs_dump = True
|
||||
@@ -7,7 +7,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import zlib
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Optional
|
||||
@@ -16,6 +18,8 @@ from mvt.common.command import Command
|
||||
from mvt.common.indicators import Indicators
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .modules.sysdiagnose import SYSDIAGNOSE_MODULES
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -52,6 +56,7 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
)
|
||||
self.platform = "ios"
|
||||
self.name = "check-sysdiagnose"
|
||||
self.modules = SYSDIAGNOSE_MODULES
|
||||
self.sysdiagnose_format: Optional[str] = None
|
||||
self.sysdiagnose_archive: Optional[tarfile.TarFile] = None
|
||||
self.sysdiagnose_files: list[str] = []
|
||||
@@ -76,11 +81,15 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
if not self.target_path:
|
||||
raise ValueError("A sysdiagnose path is required")
|
||||
|
||||
self.log.info("Checking iOS sysdiagnose at path: %s", self.target_path)
|
||||
|
||||
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:
|
||||
if filename.startswith("._"):
|
||||
continue
|
||||
absolute_path = os.path.join(root, filename)
|
||||
file_path = os.path.relpath(absolute_path, parent_path)
|
||||
self.sysdiagnose_files.append(file_path)
|
||||
@@ -94,8 +103,19 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
|
||||
self.log.info("Parsing sysdiagnose archive. This might take a while...")
|
||||
self.sysdiagnose_format = "tar"
|
||||
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
|
||||
self._extract_sysdiagnose_archive()
|
||||
try:
|
||||
self.sysdiagnose_archive = tarfile.open(self.target_path, "r:gz")
|
||||
self._extract_sysdiagnose_archive()
|
||||
except (tarfile.ReadError, EOFError, zlib.error, OSError) as exc:
|
||||
# A truncated archive ends in EOFError from gzip, which Click would
|
||||
# otherwise report as a bare "Aborted!" with no reason.
|
||||
self.log.critical(
|
||||
"Unable to read the sysdiagnose archive %s: %s. "
|
||||
"The file may be truncated or not a gzip-compressed tarball.",
|
||||
self.target_path,
|
||||
exc,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def _extract_sysdiagnose_archive(self) -> None:
|
||||
archive = self.sysdiagnose_archive
|
||||
@@ -119,6 +139,11 @@ class CmdIOSCheckSysdiagnose(Command):
|
||||
|
||||
if not member_path.parts:
|
||||
continue
|
||||
# AppleDouble sidecars (._name) carry a file's extended attributes,
|
||||
# not sysdiagnose content. Device archives hold hundreds of them;
|
||||
# bsdtar hides them from listings, tarfile does not.
|
||||
if member_path.name.startswith("._"):
|
||||
continue
|
||||
archive_roots.add(member_path.parts[0])
|
||||
|
||||
if member.isdir():
|
||||
|
||||
@@ -16,7 +16,8 @@ from mvt.common.module import MVTModule
|
||||
from .modules.backup import BACKUP_MODULES
|
||||
from .modules.fs import FS_MODULES
|
||||
from .modules.mixed import MIXED_MODULES
|
||||
from .modules.sysdiagnose import SYSDIAGNOSE_MODULES
|
||||
|
||||
IOS_CHECK_IOCS_MODULES: list[type[MVTModule]] = (
|
||||
BACKUP_MODULES + FS_MODULES + MIXED_MODULES
|
||||
BACKUP_MODULES + FS_MODULES + MIXED_MODULES + SYSDIAGNOSE_MODULES
|
||||
)
|
||||
@@ -1271,5 +1271,17 @@
|
||||
{
|
||||
"version": "26.6.1",
|
||||
"build": "23G83"
|
||||
},
|
||||
{
|
||||
"version": "26.6.2",
|
||||
"build": "23G90"
|
||||
},
|
||||
{
|
||||
"version": "27.0",
|
||||
"build": "24A437"
|
||||
},
|
||||
{
|
||||
"version": "27.0.1",
|
||||
"build": "24A446"
|
||||
}
|
||||
]
|
||||
+294
-76
@@ -6,18 +6,179 @@
|
||||
import binascii
|
||||
import glob
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import os.path
|
||||
import plistlib
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from concurrent.futures import (
|
||||
ALL_COMPLETED,
|
||||
FIRST_COMPLETED,
|
||||
Future,
|
||||
ThreadPoolExecutor,
|
||||
wait,
|
||||
)
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from iOSbackup import iOSbackup
|
||||
from iphone_backup_decrypt import EncryptedBackup
|
||||
from iphone_backup_decrypt import google_iphone_dataprotection
|
||||
from iphone_backup_decrypt.utils import FilePlist
|
||||
|
||||
from .decrypt_config import DEFAULT_DECRYPT_WORKERS, MAX_DECRYPT_WORKERS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Import pbkdf2_hmac from the same source iphone_backup_decrypt uses internally,
|
||||
# so our key derivation is consistent with theirs.
|
||||
try:
|
||||
from fastpbkdf2 import pbkdf2_hmac
|
||||
except ImportError:
|
||||
import Crypto.Hash.SHA1
|
||||
import Crypto.Hash.SHA256
|
||||
import Crypto.Protocol.KDF
|
||||
|
||||
_HASH_FNS = {"sha1": Crypto.Hash.SHA1, "sha256": Crypto.Hash.SHA256}
|
||||
|
||||
def pbkdf2_hmac(hash_name, password, salt, iterations, dklen=None):
|
||||
return Crypto.Protocol.KDF.PBKDF2(
|
||||
password, salt, dklen, iterations, hmac_hash_module=_HASH_FNS[hash_name]
|
||||
)
|
||||
|
||||
|
||||
class MVTEncryptedBackup(EncryptedBackup):
|
||||
"""Extends EncryptedBackup with derived key export/import.
|
||||
|
||||
NOTE: This subclass relies on internal APIs of iphone_backup_decrypt
|
||||
(specifically _read_and_unlock_keybag, _keybag, and the Keybag class
|
||||
internals). Pinned to iphone_backup_decrypt==0.10.0.
|
||||
"""
|
||||
|
||||
def __init__(self, *, backup_directory, passphrase=None, derived_key=None):
|
||||
if passphrase:
|
||||
super().__init__(backup_directory=backup_directory, passphrase=passphrase)
|
||||
self._derived_key = None # Will be set after keybag unlock
|
||||
elif derived_key:
|
||||
self._init_without_passphrase(backup_directory, derived_key)
|
||||
else:
|
||||
raise ValueError("Either passphrase or derived_key must be provided")
|
||||
|
||||
def _init_without_passphrase(self, backup_directory, derived_key):
|
||||
"""Replicate parent __init__ state without requiring a passphrase."""
|
||||
self.decrypted = False
|
||||
self._backup_directory = os.path.expandvars(backup_directory)
|
||||
self._passphrase = None
|
||||
self._manifest_plist_path = os.path.join(
|
||||
self._backup_directory, "Manifest.plist"
|
||||
)
|
||||
self._manifest_plist = None
|
||||
self._manifest_db_path = os.path.join(self._backup_directory, "Manifest.db")
|
||||
self._keybag = None
|
||||
self._unlocked = False
|
||||
self._temporary_folder = tempfile.mkdtemp()
|
||||
self._temp_decrypted_manifest_db_path = os.path.join(
|
||||
self._temporary_folder, "Manifest.db"
|
||||
)
|
||||
self._temp_manifest_db_conn = None
|
||||
self._derived_key = derived_key # 32 raw bytes
|
||||
|
||||
def _read_and_unlock_keybag(self):
|
||||
"""Override to capture derived key on password unlock, or use
|
||||
a pre-derived key to skip PBKDF2."""
|
||||
if self._unlocked:
|
||||
return self._unlocked
|
||||
|
||||
with open(self._manifest_plist_path, "rb") as infile:
|
||||
self._manifest_plist = plistlib.load(infile)
|
||||
self._keybag = google_iphone_dataprotection.Keybag(
|
||||
self._manifest_plist["BackupKeyBag"]
|
||||
)
|
||||
|
||||
if self._derived_key:
|
||||
# Skip PBKDF2, unwrap class keys directly with pre-derived key
|
||||
self._unlocked = _unlock_keybag_with_derived_key(
|
||||
self._keybag, self._derived_key
|
||||
)
|
||||
else:
|
||||
# Normal path: full PBKDF2 derivation, capturing the intermediate key
|
||||
self._unlocked, self._derived_key = _unlock_keybag_and_capture_key(
|
||||
self._keybag, self._passphrase
|
||||
)
|
||||
self._passphrase = None
|
||||
|
||||
if not self._unlocked:
|
||||
raise ValueError("Failed to decrypt keys: incorrect passphrase?")
|
||||
return True
|
||||
|
||||
def get_decryption_key(self):
|
||||
"""Return derived key as hex string (64 chars / 32 bytes)."""
|
||||
if self._derived_key is None:
|
||||
raise ValueError("No derived key available")
|
||||
return self._derived_key.hex()
|
||||
|
||||
def extract_file_by_id(self, *, file_id, file_bplist, output_filename):
|
||||
"""Extract one manifest entry without loading the whole file into memory."""
|
||||
self._read_and_unlock_keybag()
|
||||
file_plist = FilePlist(file_bplist)
|
||||
|
||||
if file_plist.encryption_key is None:
|
||||
source_filename = os.path.join(
|
||||
self._backup_directory, file_id[:2], file_id
|
||||
)
|
||||
shutil.copy2(source_filename, output_filename)
|
||||
return
|
||||
|
||||
inner_key = self._keybag.unwrapKeyForClass(
|
||||
file_plist.protection_class, file_plist.encryption_key
|
||||
)
|
||||
self._decrypt_file_to_disk(
|
||||
file_id=file_id,
|
||||
key=inner_key,
|
||||
file_plist=file_plist,
|
||||
output_filepath=output_filename,
|
||||
)
|
||||
|
||||
|
||||
def _unlock_keybag_with_derived_key(keybag, passphrase_key):
|
||||
"""Unlock keybag class keys using a pre-derived passphrase_key,
|
||||
skipping the expensive PBKDF2 rounds."""
|
||||
WRAP_PASSPHRASE = 2
|
||||
for classkey in keybag.classKeys.values():
|
||||
if b"WPKY" not in classkey:
|
||||
continue
|
||||
if classkey[b"WRAP"] & WRAP_PASSPHRASE:
|
||||
k = google_iphone_dataprotection._AESUnwrap(
|
||||
passphrase_key, classkey[b"WPKY"]
|
||||
)
|
||||
if not k:
|
||||
return False
|
||||
classkey[b"KEY"] = k
|
||||
return True
|
||||
|
||||
|
||||
def _unlock_keybag_and_capture_key(keybag, passphrase):
|
||||
"""Run full PBKDF2 key derivation and AES unwrap, returning
|
||||
(success, passphrase_key) so the derived key can be exported."""
|
||||
passphrase_round1 = pbkdf2_hmac(
|
||||
"sha256", passphrase, keybag.attrs[b"DPSL"], keybag.attrs[b"DPIC"], 32
|
||||
)
|
||||
passphrase_key = pbkdf2_hmac(
|
||||
"sha1", passphrase_round1, keybag.attrs[b"SALT"], keybag.attrs[b"ITER"], 32
|
||||
)
|
||||
WRAP_PASSPHRASE = 2
|
||||
for classkey in keybag.classKeys.values():
|
||||
if b"WPKY" not in classkey:
|
||||
continue
|
||||
if classkey[b"WRAP"] & WRAP_PASSPHRASE:
|
||||
k = google_iphone_dataprotection._AESUnwrap(
|
||||
passphrase_key, classkey[b"WPKY"]
|
||||
)
|
||||
if not k:
|
||||
return False, None
|
||||
classkey[b"KEY"] = k
|
||||
return True, passphrase_key
|
||||
|
||||
|
||||
class DecryptBackup:
|
||||
"""This class provides functions to decrypt an encrypted iTunes backup
|
||||
@@ -26,19 +187,68 @@ class DecryptBackup:
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, backup_path: str, dest_path: Optional[str] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
backup_path: str,
|
||||
dest_path: Optional[str] = None,
|
||||
max_workers: int = DEFAULT_DECRYPT_WORKERS,
|
||||
) -> None:
|
||||
"""Decrypts an encrypted iOS backup.
|
||||
:param backup_path: Path to the encrypted backup folder
|
||||
:param dest_path: Path to the folder where to store the decrypted backup
|
||||
"""
|
||||
self.backup_path = os.path.abspath(backup_path)
|
||||
self.dest_path = dest_path
|
||||
self._backup = None
|
||||
self._decryption_key = None
|
||||
if not 1 <= max_workers <= MAX_DECRYPT_WORKERS:
|
||||
raise ValueError(f"max_workers must be between 1 and {MAX_DECRYPT_WORKERS}")
|
||||
self.max_workers = max_workers
|
||||
self._backup: Optional[MVTEncryptedBackup] = None
|
||||
self._decryption_key: Optional[str] = None
|
||||
|
||||
def can_process(self) -> bool:
|
||||
return self._backup is not None
|
||||
|
||||
def _process_file(
|
||||
self,
|
||||
*,
|
||||
file_id: str,
|
||||
file_bplist: bytes,
|
||||
output_path: Path,
|
||||
relative_path: str,
|
||||
domain: str,
|
||||
) -> None:
|
||||
assert self._backup is not None
|
||||
self._backup.extract_file_by_id(
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_filename=str(output_path),
|
||||
)
|
||||
log.info(
|
||||
"Decrypted file %s [%s] to %s/%s",
|
||||
relative_path,
|
||||
domain,
|
||||
output_path.parent,
|
||||
file_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_files(
|
||||
pending: dict[Future[None], str], *, all_files: bool = False
|
||||
) -> None:
|
||||
if not pending:
|
||||
return
|
||||
|
||||
done, _ = wait(
|
||||
pending,
|
||||
return_when=ALL_COMPLETED if all_files else FIRST_COMPLETED,
|
||||
)
|
||||
for future in done:
|
||||
relative_path = pending.pop(future)
|
||||
try:
|
||||
future.result()
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt file %s: %s", relative_path, exc)
|
||||
|
||||
@staticmethod
|
||||
def is_encrypted(backup_path: str) -> bool:
|
||||
"""Query Manifest.db file to see if it's encrypted or not.
|
||||
@@ -58,21 +268,6 @@ class DecryptBackup:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _process_file(
|
||||
self, relative_path: str, domain: str, item, file_id: str, item_folder: str
|
||||
) -> None:
|
||||
assert self._backup is not None
|
||||
self._backup.getFileDecryptedCopy(
|
||||
manifestEntry=item, targetName=file_id, targetFolder=item_folder
|
||||
)
|
||||
log.info(
|
||||
"Decrypted file %s [%s] to %s/%s",
|
||||
relative_path,
|
||||
domain,
|
||||
item_folder,
|
||||
file_id,
|
||||
)
|
||||
|
||||
def process_backup(self) -> None:
|
||||
assert self._backup is not None
|
||||
assert self.dest_path is not None
|
||||
@@ -81,53 +276,71 @@ class DecryptBackup:
|
||||
os.makedirs(self.dest_path)
|
||||
|
||||
manifest_path = os.path.join(self.dest_path, "Manifest.db")
|
||||
# We extract a decrypted Manifest.db.
|
||||
self._backup.getManifestDB()
|
||||
# We store it to the destination folder.
|
||||
shutil.copy(self._backup.manifestDB, manifest_path)
|
||||
# Extract a decrypted Manifest.db to the destination folder.
|
||||
self._backup.save_manifest_file(output_filename=manifest_path)
|
||||
|
||||
pool = multiprocessing.Pool(multiprocessing.cpu_count())
|
||||
|
||||
for item in self._backup.getBackupFilesList():
|
||||
try:
|
||||
file_id = item["backupFile"]
|
||||
relative_path = item["relativePath"]
|
||||
domain = item["domain"]
|
||||
|
||||
# This may be a partial backup. Skip files from the manifest
|
||||
# which do not exist locally.
|
||||
source_file_path = os.path.join(self.backup_path, file_id[0:2], file_id)
|
||||
if not Path(source_file_path).resolve().is_relative_to(Path(self.backup_path).resolve()):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(source_file_path):
|
||||
log.debug(
|
||||
"Skipping file %s. File not found in encrypted backup directory.",
|
||||
source_file_path,
|
||||
)
|
||||
continue
|
||||
|
||||
item_folder = os.path.join(self.dest_path, file_id[0:2]) # type: ignore[arg-type]
|
||||
if not Path(os.path.join(item_folder, file_id)).resolve().is_relative_to(Path(self.dest_path).resolve()):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(item_folder):
|
||||
os.makedirs(item_folder)
|
||||
|
||||
# iOSBackup getFileDecryptedCopy() claims to read a "file"
|
||||
# parameter but the code actually is reading the "manifest" key.
|
||||
# Add manifest plist to both keys to handle this.
|
||||
item["manifest"] = item["file"]
|
||||
|
||||
pool.apply_async(
|
||||
self._process_file,
|
||||
args=(relative_path, domain, item, file_id, item_folder),
|
||||
# Iterate over all files in the backup and decrypt them,
|
||||
# preserving the XX/file_id directory structure that downstream
|
||||
# modules expect.
|
||||
backup_root = Path(self.backup_path).resolve()
|
||||
dest_root = Path(self.dest_path).resolve()
|
||||
pending: dict[Future[None], str] = {}
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
with self._backup.manifest_db_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT fileID, domain, relativePath, file FROM Files WHERE flags=1"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Failed to decrypt file %s: %s", relative_path, exc)
|
||||
for file_id, domain, relative_path, file_bplist in cur:
|
||||
# This may be a partial backup. Skip files from the manifest
|
||||
# which do not exist locally.
|
||||
source_file_path = backup_root / file_id[:2] / file_id
|
||||
if not source_file_path.resolve().is_relative_to(backup_root):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
if not os.path.exists(source_file_path):
|
||||
log.debug(
|
||||
"Skipping file %s. File not found in encrypted "
|
||||
"backup directory.",
|
||||
source_file_path,
|
||||
)
|
||||
continue
|
||||
|
||||
pool.close()
|
||||
pool.join()
|
||||
output_path = dest_root / file_id[:2] / file_id
|
||||
if not output_path.resolve().is_relative_to(dest_root):
|
||||
log.warning("Skipping unsafe file_id: %r", file_id)
|
||||
continue
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if self.max_workers == 1:
|
||||
try:
|
||||
self._process_file(
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_path=output_path,
|
||||
relative_path=relative_path,
|
||||
domain=domain,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
"Failed to decrypt file %s: %s",
|
||||
relative_path,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
future = executor.submit(
|
||||
self._process_file,
|
||||
file_id=file_id,
|
||||
file_bplist=file_bplist,
|
||||
output_path=output_path,
|
||||
relative_path=relative_path,
|
||||
domain=domain,
|
||||
)
|
||||
pending[future] = relative_path
|
||||
if len(pending) >= self.max_workers:
|
||||
self._wait_for_files(pending)
|
||||
|
||||
self._wait_for_files(pending, all_files=True)
|
||||
|
||||
# Copying over the root plist files as well.
|
||||
for file_name in os.listdir(self.backup_path):
|
||||
@@ -168,20 +381,23 @@ class DecryptBackup:
|
||||
return
|
||||
|
||||
try:
|
||||
self._backup = iOSbackup(
|
||||
udid=os.path.basename(self.backup_path),
|
||||
cleartextpassword=password,
|
||||
backuproot=os.path.dirname(self.backup_path),
|
||||
self._backup = MVTEncryptedBackup(
|
||||
backup_directory=self.backup_path,
|
||||
passphrase=password,
|
||||
)
|
||||
# Eagerly trigger keybag unlock so wrong-password errors
|
||||
# surface here rather than later during process_backup().
|
||||
self._backup.test_decryption()
|
||||
except Exception as exc:
|
||||
self._backup = None
|
||||
if (
|
||||
isinstance(exc, KeyError)
|
||||
and len(exc.args) > 0
|
||||
and exc.args[0] == b"KEY"
|
||||
isinstance(exc, ValueError)
|
||||
and "passphrase" in str(exc).lower()
|
||||
):
|
||||
log.critical("Failed to decrypt backup. Password is probably wrong.")
|
||||
elif (
|
||||
isinstance(exc, FileNotFoundError)
|
||||
and hasattr(exc, "filename")
|
||||
and os.path.basename(exc.filename) == "Manifest.plist"
|
||||
):
|
||||
log.critical(
|
||||
@@ -224,12 +440,14 @@ class DecryptBackup:
|
||||
|
||||
try:
|
||||
key_bytes_raw = binascii.unhexlify(key_bytes)
|
||||
self._backup = iOSbackup(
|
||||
udid=os.path.basename(self.backup_path),
|
||||
derivedkey=key_bytes_raw,
|
||||
backuproot=os.path.dirname(self.backup_path),
|
||||
self._backup = MVTEncryptedBackup(
|
||||
backup_directory=self.backup_path,
|
||||
derived_key=key_bytes_raw,
|
||||
)
|
||||
# Eagerly trigger keybag unlock so wrong-key errors surface here.
|
||||
self._backup.test_decryption()
|
||||
except Exception as exc:
|
||||
self._backup = None
|
||||
log.exception(exc)
|
||||
log.critical(
|
||||
"Failed to decrypt backup. Did you provide the correct key file?"
|
||||
@@ -240,7 +458,7 @@ class DecryptBackup:
|
||||
if not self._backup:
|
||||
return
|
||||
|
||||
self._decryption_key = self._backup.getDecryptionKey()
|
||||
self._decryption_key = self._backup.get_decryption_key()
|
||||
log.info(
|
||||
'Derived decryption key for backup at path %s is: "%s"',
|
||||
self.backup_path,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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/
|
||||
|
||||
"""Configuration shared by the iOS CLI and backup decryption implementation."""
|
||||
|
||||
DEFAULT_DECRYPT_WORKERS = 4
|
||||
MAX_DECRYPT_WORKERS = 32
|
||||
@@ -3,9 +3,16 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .backup_info import BackupInfo
|
||||
from .configuration_profiles import ConfigurationProfiles
|
||||
from .manifest import Manifest
|
||||
from .profile_events import ProfileEvents
|
||||
|
||||
BACKUP_MODULES = [BackupInfo, ConfigurationProfiles, Manifest, ProfileEvents]
|
||||
BACKUP_MODULES: list[type[MVTModule]] = [
|
||||
BackupInfo,
|
||||
ConfigurationProfiles,
|
||||
Manifest,
|
||||
ProfileEvents,
|
||||
]
|
||||
@@ -147,6 +147,12 @@ class Manifest(IOSExtraction):
|
||||
)
|
||||
names = [description[0] for description in cur.description]
|
||||
|
||||
# An incomplete backup still lists the files it failed to acquire in
|
||||
# its manifest. Only regular files are stored in the backup folder, so
|
||||
# directories and symlinks (flags 2 and 4) are not looked up.
|
||||
stored_file_ids = self._get_stored_backup_file_ids()
|
||||
missing_files = 0
|
||||
|
||||
for file_entry in cur:
|
||||
file_data = {}
|
||||
for index, value in enumerate(file_entry):
|
||||
@@ -160,6 +166,22 @@ class Manifest(IOSExtraction):
|
||||
"created": "",
|
||||
}
|
||||
|
||||
if (
|
||||
stored_file_ids is not None
|
||||
and file_data["flags"] == 1
|
||||
and file_data["fileID"] not in stored_file_ids
|
||||
):
|
||||
# Without this, a module which found nothing for one of these
|
||||
# files would look like a negative result rather than a gap in
|
||||
# the acquisition.
|
||||
cleaned_metadata["missing"] = True
|
||||
missing_files += 1
|
||||
self.log.debug(
|
||||
"File %s is listed in the manifest but was not found in the "
|
||||
"backup folder",
|
||||
cleaned_metadata["relative_path"],
|
||||
)
|
||||
|
||||
if file_data["file"]:
|
||||
try:
|
||||
file_plist = plistlib.load(io.BytesIO(file_data["file"]))
|
||||
@@ -197,3 +219,10 @@ class Manifest(IOSExtraction):
|
||||
conn.close()
|
||||
|
||||
self.log.info("Extracted a total of %d file metadata items", len(self.results))
|
||||
|
||||
if missing_files:
|
||||
self.log.info(
|
||||
"Found %d files listed in the manifest but missing from the backup "
|
||||
"folder. The backup might be incomplete.",
|
||||
missing_files,
|
||||
)
|
||||
@@ -216,6 +216,55 @@ class IOSExtraction(MVTModule):
|
||||
|
||||
return None
|
||||
|
||||
def _get_stored_backup_file_ids(self) -> Optional[set[str]]:
|
||||
"""List the IDs of the files actually stored in the backup folder.
|
||||
|
||||
A backup folder stores each file under a two character subfolder named
|
||||
after the first two characters of its file ID. Walking the folders once
|
||||
is cheaper than a filesystem lookup per file ID, and it keeps callers
|
||||
which compare a whole manifest against the folder from paying a
|
||||
`resolve()` for every entry.
|
||||
|
||||
:returns: The file IDs found at their expected paths within the backup
|
||||
folder, or None if the inventory could not be completed.
|
||||
"""
|
||||
if not self.target_path:
|
||||
return None
|
||||
|
||||
file_ids: set[str] = set()
|
||||
try:
|
||||
backup_root = Path(self.target_path).resolve()
|
||||
with os.scandir(self.target_path) as entries:
|
||||
for entry in entries:
|
||||
if len(entry.name) != 2 or any(
|
||||
char not in "0123456789abcdef" for char in entry.name
|
||||
):
|
||||
continue
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
if not Path(entry.path).resolve().is_relative_to(backup_root):
|
||||
continue
|
||||
with os.scandir(entry.path) as sub_entries:
|
||||
for sub_entry in sub_entries:
|
||||
if sub_entry.name[:2] != entry.name:
|
||||
continue
|
||||
if sub_entry.is_symlink() and not Path(
|
||||
sub_entry.path
|
||||
).resolve().is_relative_to(backup_root):
|
||||
continue
|
||||
if sub_entry.is_file():
|
||||
file_ids.add(sub_entry.name)
|
||||
except OSError as exc:
|
||||
self.log.warning(
|
||||
"Unable to list the files stored in the backup folder %s: %s. "
|
||||
"Skipping the missing-file check.",
|
||||
self.target_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
return file_ids
|
||||
|
||||
def _get_fs_files_from_patterns(self, root_paths: list) -> Iterator[str]:
|
||||
if not self.target_path:
|
||||
return
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .analytics import Analytics
|
||||
from .analytics_ios_versions import AnalyticsIOSVersions
|
||||
from .cache_files import CacheFiles
|
||||
@@ -15,7 +17,7 @@ from .webkit_indexeddb import WebkitIndexedDB
|
||||
from .webkit_localstorage import WebkitLocalStorage
|
||||
from .webkit_safariviewservice import WebkitSafariViewService
|
||||
|
||||
FS_MODULES = [
|
||||
FS_MODULES: list[type[MVTModule]] = [
|
||||
CacheFiles,
|
||||
Filesystem,
|
||||
Netusage,
|
||||
|
||||
@@ -82,7 +82,9 @@ class Filesystem(IOSExtraction):
|
||||
try:
|
||||
dir_path = os.path.join(root, dir_name)
|
||||
result = {
|
||||
"path": os.path.relpath(dir_path, self.target_path),
|
||||
"path": os.path.relpath(dir_path, self.target_path).replace(
|
||||
os.sep, "/"
|
||||
),
|
||||
"modified": convert_unix_to_iso(os.stat(dir_path).st_mtime),
|
||||
}
|
||||
except Exception:
|
||||
@@ -94,7 +96,9 @@ class Filesystem(IOSExtraction):
|
||||
try:
|
||||
file_path = os.path.join(root, file_name)
|
||||
result = {
|
||||
"path": os.path.relpath(file_path, self.target_path),
|
||||
"path": os.path.relpath(file_path, self.target_path).replace(
|
||||
os.sep, "/"
|
||||
),
|
||||
"modified": convert_unix_to_iso(os.stat(file_path).st_mtime),
|
||||
}
|
||||
except Exception:
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .applications import Applications
|
||||
from .calendar import Calendar
|
||||
from .calls import Calls
|
||||
@@ -28,7 +30,7 @@ from .webkit_session_resource_log import WebkitSessionResourceLog
|
||||
from .whatsapp import Whatsapp
|
||||
from .whatsapp_contacts import WhatsappContacts
|
||||
|
||||
MIXED_MODULES = [
|
||||
MIXED_MODULES: list[type[MVTModule]] = [
|
||||
Calls,
|
||||
ChromeFavicon,
|
||||
ChromeHistory,
|
||||
|
||||
@@ -3,4 +3,9 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
from mvt.common.module import MVTModule
|
||||
|
||||
from .base import SysdiagnoseExtraction
|
||||
from .sysdiagnose_info import SysdiagnoseInfo
|
||||
|
||||
SYSDIAGNOSE_MODULES: list[type[MVTModule]] = [SysdiagnoseInfo]
|
||||
@@ -0,0 +1,228 @@
|
||||
# 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 json
|
||||
import logging
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mvt.common.module_types import ModuleResults
|
||||
from mvt.common.utils import convert_datetime_to_iso
|
||||
from mvt.ios.versions import (
|
||||
find_version_by_build,
|
||||
get_device_desc_from_id,
|
||||
is_ios_version_outdated,
|
||||
)
|
||||
|
||||
from .base import SysdiagnoseExtraction
|
||||
|
||||
# The fields dumpsys prints in the remotectl dump state and the mobile
|
||||
# activation request which are worth a log line of their own.
|
||||
LOGGED_FIELDS = (
|
||||
"ProductName",
|
||||
"ProductType",
|
||||
"SerialNumber",
|
||||
"OSVersion",
|
||||
"RegionCode",
|
||||
"IMEI",
|
||||
"BuildVersion",
|
||||
)
|
||||
|
||||
|
||||
class SysdiagnoseInfo(SysdiagnoseExtraction):
|
||||
"""Extract details about the device and the sysdiagnose itself.
|
||||
|
||||
The fields come from four files of the archive: the remotectl dump state
|
||||
(product type, OS version, serial number, region and the rest of its
|
||||
Properties block), the mobile activation request (UDID, IMEI, MEID and the
|
||||
OS build), the App Store daemon database (the Apple account name and email)
|
||||
and sysdiagnose.log (the archive's original file name and creation time).
|
||||
|
||||
Newer iOS versions no longer include the App Store daemon database in a
|
||||
sysdiagnose; it is still read for the analysis of older archives.
|
||||
"""
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
self.results: dict = results if results is not None else {}
|
||||
|
||||
def _copy_sqlite_db(self, file_path: str, directory: str) -> str:
|
||||
"""Copy a database and its WAL sidecars out of the archive.
|
||||
|
||||
A database dumped mid-transaction keeps its latest rows in the -wal
|
||||
file next to it, which SQLite only reads when both sit in the same
|
||||
directory under the same name.
|
||||
"""
|
||||
available_files = self.tar_files if self.tar else self.files
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
if suffix and f"{file_path}{suffix}" not in available_files:
|
||||
continue
|
||||
copy_path = os.path.join(directory, f"{Path(file_path).name}{suffix}")
|
||||
with open(copy_path, "wb") as handle:
|
||||
handle.write(self._get_file_content(f"{file_path}{suffix}"))
|
||||
|
||||
return os.path.join(directory, Path(file_path).name)
|
||||
|
||||
def _process_appstored(self, file_path: str) -> None:
|
||||
self.log.info("Found App Store daemon database at: %s", file_path)
|
||||
with tempfile.TemporaryDirectory(prefix="mvt_sqlite_") as directory:
|
||||
db_path = Path(self._copy_sqlite_db(file_path, directory)).resolve()
|
||||
conn = sqlite3.connect(f"{db_path.as_uri()}?mode=ro", uri=True)
|
||||
try:
|
||||
self._read_appstored(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _read_appstored(self, conn: sqlite3.Connection) -> None:
|
||||
cur = conn.cursor()
|
||||
# The account name sits in an opaque structure of every asset row.
|
||||
try:
|
||||
rows = cur.execute("SELECT sinfs_data FROM asset;").fetchall()
|
||||
except sqlite3.DatabaseError as exc:
|
||||
self.log.debug("Unable to read the asset table: %s", exc)
|
||||
rows = []
|
||||
|
||||
for (sinfs_data,) in rows:
|
||||
try:
|
||||
sinf = plistlib.loads(sinfs_data)[0]["sinf"]
|
||||
except (plistlib.InvalidFileException, IndexError, KeyError, TypeError):
|
||||
continue
|
||||
match = re.search(rb"name(.*?)\x00", sinf)
|
||||
if match:
|
||||
self.results["Account Name"] = match.group(1).decode(
|
||||
"utf-8", errors="replace"
|
||||
)
|
||||
break
|
||||
|
||||
try:
|
||||
row = cur.execute(
|
||||
"SELECT store_account_name FROM job_software "
|
||||
"WHERE store_account_name IS NOT NULL LIMIT 1;"
|
||||
).fetchone()
|
||||
except sqlite3.DatabaseError as exc:
|
||||
self.log.debug("Unable to read the job_software table: %s", exc)
|
||||
return
|
||||
|
||||
if row:
|
||||
self.results["Email Address"] = row[0]
|
||||
|
||||
def _process_activation_log(self, file_path: str) -> None:
|
||||
self.log.info("Found mobile activation request at: %s", file_path)
|
||||
content = self._get_file_content(file_path)
|
||||
match = re.search(rb"BODY:\s+({.+?})\s", content, re.MULTILINE)
|
||||
if not match:
|
||||
return
|
||||
|
||||
try:
|
||||
body = json.loads(match.group(1))
|
||||
except json.JSONDecodeError as exc:
|
||||
self.log.warning("Unable to parse the activation request body: %s", exc)
|
||||
return
|
||||
|
||||
self.results.update(
|
||||
{
|
||||
"SerialNumber": body.get("serial-number"),
|
||||
"ProductType": body.get("productType"),
|
||||
"ProductName": body.get("productName"),
|
||||
"IMEI": body.get("imei"),
|
||||
"ProductVersion": body.get("os-version"),
|
||||
"UniqueIdentifier": body.get("udid"),
|
||||
"MEID": body.get("meid"),
|
||||
"BuildVersion": body.get("os-build"),
|
||||
}
|
||||
)
|
||||
|
||||
def _process_dumpstate(self, file_path: str) -> None:
|
||||
self.log.info("Found remotectl dump state at: %s", file_path)
|
||||
content = self._get_file_content(file_path).decode("utf-8", errors="replace")
|
||||
in_properties = False
|
||||
for line in content.splitlines():
|
||||
if not in_properties:
|
||||
in_properties = line == "\tProperties: {"
|
||||
continue
|
||||
|
||||
if line == "\t}":
|
||||
break
|
||||
|
||||
key, separator, value = line.partition("=>")
|
||||
if separator:
|
||||
self.results[key.strip()] = value.strip()
|
||||
|
||||
def _process_sysdiagnose_log(self, file_path: str) -> None:
|
||||
self.log.info("Found sysdiagnose.log at: %s", file_path)
|
||||
content = self._get_file_content(file_path).decode("utf-8", errors="replace")
|
||||
match = re.search(r"sysdiagnose_\S+?\.tar\.gz", content)
|
||||
if not match:
|
||||
self.log.info("Could not find the original output path in sysdiagnose.log")
|
||||
return
|
||||
|
||||
file_name = os.path.basename(match.group(0))
|
||||
try:
|
||||
created = datetime.strptime(
|
||||
"_".join(file_name.split("_")[1:3]), "%Y.%m.%d_%H-%M-%S%z"
|
||||
)
|
||||
except ValueError:
|
||||
self.log.warning("Unexpected sysdiagnose file name: %s", file_name)
|
||||
return
|
||||
|
||||
self.results["OriginalFilename"] = file_name
|
||||
self.results["CreatedTimestamp"] = convert_datetime_to_iso(created)
|
||||
|
||||
def run(self) -> None:
|
||||
for file_path in self._get_files_by_pattern(
|
||||
"*/logs/appinstallation/appstored.sqlitedb"
|
||||
):
|
||||
self._process_appstored(file_path)
|
||||
|
||||
for file_path in self._get_files_by_pattern(
|
||||
"*/logs/MobileActivation/collection_oob_request.txt"
|
||||
):
|
||||
self._process_activation_log(file_path)
|
||||
|
||||
for file_path in self._get_files_by_pattern("*/remotectl_dumpstate.txt"):
|
||||
self._process_dumpstate(file_path)
|
||||
|
||||
for file_path in self._get_files_by_pattern("*/sysdiagnose.log"):
|
||||
self._process_sysdiagnose_log(file_path)
|
||||
|
||||
# The activation request names the product "iPhone OS"; the model
|
||||
# description is what an analyst wants to read.
|
||||
product_name = get_device_desc_from_id(self.results.get("ProductType", ""))
|
||||
if product_name:
|
||||
self.results["ProductName"] = product_name
|
||||
|
||||
for field in LOGGED_FIELDS:
|
||||
if field not in self.results:
|
||||
continue
|
||||
value = self.results[field]
|
||||
if field == "BuildVersion" and value:
|
||||
self.log.info("%s: %s - %s", field, value, find_version_by_build(value))
|
||||
else:
|
||||
self.log.info("%s: %s", field, value)
|
||||
|
||||
if self.results.get("BuildVersion"):
|
||||
is_ios_version_outdated(self.results["BuildVersion"], self.log)
|
||||
@@ -23,7 +23,7 @@ class TestDumpsysAccessibilityArtifact:
|
||||
assert len(da.results) == 4
|
||||
assert da.results[0]["package_name"] == "com.android.settings"
|
||||
assert (
|
||||
da.results[0]["service"]
|
||||
da.results[0]["component"]
|
||||
== "com.android.settings/com.samsung.android.settings.development.gpuwatch.GPUWatchInterceptor"
|
||||
)
|
||||
|
||||
@@ -35,9 +35,16 @@ class TestDumpsysAccessibilityArtifact:
|
||||
|
||||
assert len(da.results) == 0
|
||||
da.parse(data)
|
||||
assert len(da.results) == 1
|
||||
# One named service, plus one count-only record: the dump states
|
||||
# `installedServiceCount=2` and names only one component.
|
||||
assert len(da.results) == 2
|
||||
assert da.results[0]["package_name"] == "com.malware.accessibility"
|
||||
assert da.results[0]["service"] == "com.malware.service.malwareservice"
|
||||
assert da.results[0]["service_name"] == "com.malware.service.malwareservice"
|
||||
assert da.results[0]["enabled"] is True
|
||||
# This fixture never prints an `installed services:` section, so the
|
||||
# dump does not state the installed status. Reporting False would turn
|
||||
# "not stated" into "not installed", so it reads None here.
|
||||
assert da.results[0]["installed"] is None
|
||||
|
||||
def test_accessibility_service_alert(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
@@ -48,9 +55,27 @@ class TestDumpsysAccessibilityArtifact:
|
||||
|
||||
da.check_indicators()
|
||||
|
||||
assert len(da.alertstore.alerts) == 1
|
||||
assert len(da.alertstore.alerts) == 2
|
||||
assert da.alertstore.alerts[0].level == AlertLevel.MEDIUM
|
||||
assert da.alertstore.alerts[0].event == da.results[0]
|
||||
assert da.alertstore.alerts[1].level == AlertLevel.LOW
|
||||
assert da.alertstore.alerts[1].event == da.results[1]
|
||||
|
||||
def test_same_component_is_kept_for_each_user(self):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
da.parse(
|
||||
"""User state[attributes:{id=0
|
||||
installed services: {
|
||||
0 : com.example/.Service
|
||||
}
|
||||
User state[attributes:{id=10
|
||||
installed services: {
|
||||
0 : com.example/.Service
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
assert [result["user_id"] for result in da.results] == [0, 10]
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
da = DumpsysAccessibilityArtifact()
|
||||
@@ -66,7 +91,11 @@ class TestDumpsysAccessibilityArtifact:
|
||||
assert len(da.alertstore.alerts) == 0
|
||||
da.check_indicators()
|
||||
assert len(da.alertstore.alerts) == len(da.results)
|
||||
assert da.alertstore.count(AlertLevel.MEDIUM) == 3
|
||||
# Every service in this fixture is installed and switched off
|
||||
# (`enabled services:{}` is printed and empty), so the three non-IOC
|
||||
# findings are LOW, not MEDIUM. The IOC match is unaffected by the
|
||||
# state.
|
||||
assert da.alertstore.count(AlertLevel.LOW) == 3
|
||||
assert da.alertstore.count(AlertLevel.CRITICAL) == 1
|
||||
critical_alert = next(
|
||||
alert
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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/
|
||||
"""The dump's own installed-service count must survive into the artifact.
|
||||
|
||||
Most builds never print the `installed services: {…}` block; they state `installedServiceCount=N` in the user's `attributes:{…}` line and
|
||||
list nothing. Dropping that number makes an artifact that says "no
|
||||
accessibility services" about a dump that said there are five.
|
||||
"""
|
||||
|
||||
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
|
||||
from mvt.common.alerts import AlertLevel
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
AOSP_NO_LIST = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[
|
||||
attributes:{id=0, touchExplorationEnabled=false, installedServiceCount=5}
|
||||
Bound services:{}
|
||||
Enabled services:{}
|
||||
Binding services:{}
|
||||
Crashed services:{}
|
||||
"""
|
||||
|
||||
ONE_UI_WITH_LIST = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, installedServiceCount=2}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
1 : com.other.app/.Helper
|
||||
}
|
||||
enabled services: {
|
||||
}
|
||||
"""
|
||||
|
||||
TWO_USERS = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, installedServiceCount=1}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
}
|
||||
User state[attributes:{id=95, installedServiceCount=3}
|
||||
Enabled services:{}
|
||||
"""
|
||||
|
||||
PARTIAL_TWO_USERS = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, installedServiceCount=3}
|
||||
Enabled services:{{com.example.app/com.example.app.Service}}
|
||||
User state[attributes:{id=10, installedServiceCount=1}
|
||||
Enabled services:{{com.other.app/.Helper}}
|
||||
"""
|
||||
|
||||
ZERO_COUNT = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, installedServiceCount=0}
|
||||
Enabled services:{}
|
||||
"""
|
||||
|
||||
|
||||
def _parse(content):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(content)
|
||||
return artifact
|
||||
|
||||
|
||||
class TestAccessibilityInstalledServiceCount:
|
||||
def test_stated_count_without_a_list_is_kept(self):
|
||||
artifact = _parse(AOSP_NO_LIST)
|
||||
assert len(artifact.results) == 1
|
||||
record = artifact.results[0]
|
||||
assert record["installed_service_count"] == 5
|
||||
assert record["component"] is None
|
||||
# Every state flag stays unknown: the dump named no service to which a
|
||||
# state could belong.
|
||||
assert record["installed"] is None
|
||||
assert record["enabled"] is None
|
||||
|
||||
def test_a_stated_count_is_reported_as_low(self):
|
||||
artifact = _parse(AOSP_NO_LIST)
|
||||
artifact.check_indicators()
|
||||
alerts = artifact.alertstore.alerts
|
||||
assert len(alerts) == 1
|
||||
# A count is a coverage statement, not a running service: it must not
|
||||
# compete with a service the dump says is enabled.
|
||||
assert alerts[0].level == AlertLevel.LOW
|
||||
assert "does not list their component names" in alerts[0].message
|
||||
assert "5 installed" in alerts[0].message
|
||||
|
||||
def test_a_listed_user_carries_the_count_on_each_service(self):
|
||||
artifact = _parse(ONE_UI_WITH_LIST)
|
||||
assert len(artifact.results) == 2
|
||||
assert {record["installed_service_count"] for record in artifact.results} == {2}
|
||||
assert all(record["component"] for record in artifact.results)
|
||||
|
||||
def test_only_the_unlisted_user_gets_a_count_record(self):
|
||||
artifact = _parse(TWO_USERS)
|
||||
listed = [record for record in artifact.results if record["component"]]
|
||||
unlisted = [record for record in artifact.results if not record["component"]]
|
||||
assert [record["user_id"] for record in listed] == [0]
|
||||
assert [record["user_id"] for record in unlisted] == [95]
|
||||
assert unlisted[0]["installed_service_count"] == 3
|
||||
|
||||
def test_a_zero_count_adds_nothing(self):
|
||||
# "Zero installed" is a negative result the empty section already
|
||||
# states; a record for it would be noise.
|
||||
assert _parse(ZERO_COUNT).results == []
|
||||
|
||||
def test_a_partly_named_count_reports_the_unnamed_rest(self):
|
||||
# The Android 14 fixture states `installedServiceCount=2` and names one
|
||||
# enabled component. The listing is incomplete, and must not read as
|
||||
# complete.
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
with open(
|
||||
get_artifact("android_data/dumpsys_accessibility_v14_or_later.txt")
|
||||
) as handle:
|
||||
artifact.parse(handle.read())
|
||||
unlisted = [record for record in artifact.results if not record["component"]]
|
||||
assert len(unlisted) == 1
|
||||
assert unlisted[0]["installed_service_count"] == 2
|
||||
assert unlisted[0]["unnamed_service_count"] == 1
|
||||
|
||||
artifact.check_indicators()
|
||||
low = [
|
||||
alert
|
||||
for alert in artifact.alertstore.alerts
|
||||
if alert.level == AlertLevel.LOW
|
||||
]
|
||||
assert len(low) == 1
|
||||
assert "only 1 of them (1 unnamed)" in low[0].message
|
||||
|
||||
def test_the_unnamed_rest_is_counted_per_user(self):
|
||||
# User 0 names one of three, user 10 names its only one: the gap
|
||||
# belongs to user 0 alone.
|
||||
artifact = _parse(PARTIAL_TWO_USERS)
|
||||
unlisted = [record for record in artifact.results if not record["component"]]
|
||||
assert [
|
||||
(record["user_id"], record["unnamed_service_count"]) for record in unlisted
|
||||
] == [(0, 2)]
|
||||
|
||||
def test_a_fully_named_count_adds_nothing(self):
|
||||
artifact = _parse(ONE_UI_WITH_LIST)
|
||||
assert all(
|
||||
record["unnamed_service_count"] is None for record in artifact.results
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
# 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/
|
||||
"""Installed is not the same as enabled, and the dump says which.
|
||||
|
||||
Two things this guards:
|
||||
|
||||
* a section the dump never printed must read as None ("not stated"), not as
|
||||
False ("not enabled");
|
||||
* the alert must name the state, instead of firing identically on a device
|
||||
where nothing is switched on and one where something is bound.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from mvt.android.artifacts.dumpsys_accessibility import DumpsysAccessibilityArtifact
|
||||
from mvt.common.alerts import AlertLevel
|
||||
|
||||
from ..utils import get_artifact
|
||||
|
||||
NO_STATE_SECTIONS = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, currentUser=true}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
}
|
||||
"""
|
||||
|
||||
ENABLED_BLOCK = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, currentUser=true}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
1 : com.other.app/.Helper
|
||||
}
|
||||
enabled services: {
|
||||
0 : com.other.app/.Helper
|
||||
}
|
||||
bound services:{
|
||||
0 : com.other.app/.Helper
|
||||
}
|
||||
"""
|
||||
|
||||
# User 0 prints only `installed services`, user 10 only `Enabled services`.
|
||||
# Neither section speaks for the other user.
|
||||
TWO_USERS_DIFFERENT_SECTIONS = """\
|
||||
ACCESSIBILITY MANAGER (dumpsys accessibility)
|
||||
User state[attributes:{id=0, currentUser=true}
|
||||
installed services: {
|
||||
0 : com.example.app/com.example.app.Service
|
||||
}
|
||||
User state[attributes:{id=10, currentUser=false}
|
||||
Enabled services:{{com.other.app/.Helper}}
|
||||
"""
|
||||
|
||||
|
||||
class _IndicatorsMatching:
|
||||
"""Minimal stand-in: matches one package id, like the STIX2 loader would."""
|
||||
|
||||
def __init__(self, package_name: str) -> None:
|
||||
self.package_name = package_name
|
||||
|
||||
def check_app_id(self, app_id):
|
||||
if app_id != self.package_name:
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
message=f"Found a known suspicious app: {app_id}", ioc={"value": app_id}
|
||||
)
|
||||
|
||||
|
||||
class TestAccessibilityServiceState:
|
||||
def _parse(self, content):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(content)
|
||||
return {r["component"]: r for r in artifact.results}
|
||||
|
||||
def test_absent_sections_leave_the_state_unknown(self):
|
||||
# None, not False: a build that does not print the sections says
|
||||
# nothing about what is enabled, and that must not read as "nothing".
|
||||
state = self._parse(NO_STATE_SECTIONS)[
|
||||
"com.example.app/com.example.app.Service"
|
||||
]
|
||||
assert state["installed"] is True
|
||||
assert state["enabled"] is None
|
||||
assert state["bound"] is None
|
||||
|
||||
def test_enabled_and_bound_are_attributed_per_service(self):
|
||||
results = self._parse(ENABLED_BLOCK)
|
||||
installed_only = results["com.example.app/com.example.app.Service"]
|
||||
active = results["com.other.app/.Helper"]
|
||||
assert (installed_only["enabled"], installed_only["bound"]) == (False, False)
|
||||
assert (active["enabled"], active["bound"]) == (True, True)
|
||||
|
||||
def test_alert_message_carries_the_state(self):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
with open(get_artifact("android_data/dumpsys_accessibility.txt")) as handle:
|
||||
artifact.parse(handle.read())
|
||||
artifact.check_indicators()
|
||||
assert artifact.alertstore.alerts
|
||||
assert all("installed" in alert.message for alert in artifact.alertstore.alerts)
|
||||
|
||||
def test_a_switched_off_service_is_low_and_a_running_one_medium(self):
|
||||
# A service the dump says is OFF still reaches the analyst, but must not
|
||||
# compete with one that is actually bound. "Not stated" is not "off".
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(ENABLED_BLOCK)
|
||||
artifact.check_indicators()
|
||||
by_level = {}
|
||||
for alert in artifact.alertstore.alerts:
|
||||
by_level.setdefault(alert.level, []).append(alert.message)
|
||||
assert artifact.alertstore.count(AlertLevel.LOW) == 1
|
||||
assert artifact.alertstore.count(AlertLevel.MEDIUM) == 1
|
||||
assert "com.example.app" in by_level[AlertLevel.LOW][0]
|
||||
assert "com.other.app" in by_level[AlertLevel.MEDIUM][0]
|
||||
|
||||
def test_an_unstated_enabled_state_stays_medium(self):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(NO_STATE_SECTIONS)
|
||||
artifact.check_indicators()
|
||||
assert artifact.alertstore.count(AlertLevel.MEDIUM) == 1
|
||||
assert artifact.alertstore.count(AlertLevel.LOW) == 0
|
||||
|
||||
def test_a_disabled_service_is_still_matched_against_indicators(self):
|
||||
# The state decides the severity of an ordinary finding, never whether
|
||||
# the package is compared with the IOC feeds.
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(ENABLED_BLOCK)
|
||||
artifact.indicators = _IndicatorsMatching("com.example.app")
|
||||
artifact.check_indicators()
|
||||
assert artifact.alertstore.count(AlertLevel.CRITICAL) == 1
|
||||
assert artifact.alertstore.count(AlertLevel.LOW) == 0
|
||||
|
||||
def test_printed_sections_are_tracked_per_user(self):
|
||||
artifact = DumpsysAccessibilityArtifact()
|
||||
artifact.results = []
|
||||
artifact.parse(TWO_USERS_DIFFERENT_SECTIONS)
|
||||
by_user = {r["user_id"]: r for r in artifact.results}
|
||||
# User 0's enabled state is not stated, so it is unknown, not off.
|
||||
assert (by_user[0]["installed"], by_user[0]["enabled"]) == (True, None)
|
||||
# User 10's installed state is not stated either.
|
||||
assert (by_user[10]["installed"], by_user[10]["enabled"]) == (None, True)
|
||||
|
||||
artifact.check_indicators()
|
||||
assert artifact.alertstore.count(AlertLevel.LOW) == 0
|
||||
assert artifact.alertstore.count(AlertLevel.MEDIUM) == 2
|
||||
assert not any(
|
||||
"installed, not enabled" in alert.message
|
||||
for alert in artifact.alertstore.alerts
|
||||
)
|
||||
@@ -3,6 +3,8 @@
|
||||
# Use of this software is governed by the MVT License 1.1 that can be found at
|
||||
# https://license.mvt.re/1.1/
|
||||
|
||||
import base64
|
||||
|
||||
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
|
||||
from mvt.android.modules.bugreport.dumpsys_adb_state import DumpsysADBState
|
||||
from mvt.common.alerts import AlertLevel
|
||||
@@ -11,6 +13,17 @@ from ..utils import get_artifact
|
||||
|
||||
|
||||
class TestDumpsysADBArtifact:
|
||||
def test_parsing_binary_xml_recovers_key(self):
|
||||
public_key = base64.b64encode(bytes(range(256)))
|
||||
keystore = DumpsysADBArtifact().parse_binary_xml(
|
||||
b"ABX\x00binary-key=" + public_key + b" user@host\x00lastConnection"
|
||||
)
|
||||
|
||||
assert len(keystore) == 1
|
||||
assert keystore[0]["key"] == public_key.decode()
|
||||
assert keystore[0]["user"] == "user@host"
|
||||
assert keystore[0]["last_connected"] is None
|
||||
|
||||
def test_parsing(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
file = get_artifact("android_data/dumpsys_adb.txt")
|
||||
@@ -117,6 +130,180 @@ class TestDumpsysADBArtifact:
|
||||
assert key_store_entry["fingerprint"] == expected_fingerprint
|
||||
assert key_store_entry["last_connected"] == "1628501829898"
|
||||
|
||||
def test_parsing_adb_xml_with_crlf_line_endings(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
file = get_artifact("android_data/dumpsys_adb_xml.txt")
|
||||
with open(file, "rb") as f:
|
||||
data = f.read().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")
|
||||
|
||||
da_adb.parse(data)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["user_keys"][0]["user"] == "user@laptop"
|
||||
assert da_adb.results[0]["keystore"][0]["last_connected"] == "1628501829898"
|
||||
|
||||
def test_parsing_adb_wifi_with_mixed_line_endings(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
data = (
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" user_keys=QUJDRA== host@example\n"
|
||||
b" adb_wifi={\n"
|
||||
b" enabled=false\n"
|
||||
b" }\n"
|
||||
b" }\r\n"
|
||||
b"}\n"
|
||||
b"--------- duration\n"
|
||||
)
|
||||
|
||||
da_adb.parse(data)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["user_keys"][0]["user"] == "host@example"
|
||||
assert da_adb.results[0]["adb_wifi"]["enabled"] == b"false"
|
||||
|
||||
ADB_STATE = (
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" user_keys=QUJDRA== host@example\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
b"--------- 0.5s was the duration of 'dumpsys adb'\n"
|
||||
)
|
||||
|
||||
def test_a_later_dumpsys_section_does_not_extend_the_adb_state(self):
|
||||
# A bug report holds many sections. Looking for the last closing brace
|
||||
# in the whole output pulled a later section into this one, which threw
|
||||
# IndexError out of parse() and lost the ADB records entirely.
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
self.ADB_STATE + b"DUMP OF SERVICE other:\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=false\n"
|
||||
b" user_keys=RVZJTA== attacker@host\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert [key["user"] for key in da_adb.results[0]["user_keys"]] == [
|
||||
"host@example"
|
||||
]
|
||||
assert da_adb.results[0]["connected_to_adb"] is True
|
||||
|
||||
def test_braces_in_key_comment_are_not_state_delimiters(self):
|
||||
for user in (b"host{example", b"host}}example"):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" user_keys=QUJDRA== " + user + b"\n }\n}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["user_keys"][0]["user"] == user.decode()
|
||||
|
||||
def test_state_without_keys_is_parsed_without_error(self, caplog):
|
||||
# Shape printed by every device with no stored ADB keys (Samsung
|
||||
# SM-A525F, Android 12). The block's closing brace is dedented from
|
||||
# the last entry and must close exactly one level.
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=false\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
b"--------- 0.015s was the duration of dumpsys adb\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["connected_to_adb"] is False
|
||||
assert not [r for r in caplog.records if r.levelname == "ERROR"]
|
||||
|
||||
def test_nested_block_as_last_entry_is_parsed_without_error(self, caplog):
|
||||
# Shape printed by Pixel devices with no stored ADB keys.
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=false\n"
|
||||
b" adb_wifi={\n"
|
||||
b" enabled=false\n"
|
||||
b" tls_port=0\n"
|
||||
b" }\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["connected_to_adb"] is False
|
||||
assert da_adb.results[0]["adb_wifi"]["tls_port"] == b"0"
|
||||
assert not [r for r in caplog.records if r.levelname == "ERROR"]
|
||||
|
||||
def test_entry_after_nested_block_stays_in_debugging_manager(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" adb_wifi={\n"
|
||||
b" enabled=false\n"
|
||||
b" }\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["connected_to_adb"] is True
|
||||
|
||||
def test_blank_line_after_single_line_value_does_not_raise(self):
|
||||
# `system_keys` is the content of /adb_keys and ends with a newline,
|
||||
# so a blank line follows it. It is not a multiline field, so the
|
||||
# blank line is not a multiline terminator either.
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" user_keys=QUJDRA== host@example\n"
|
||||
b"\n"
|
||||
b" system_keys=RUZHSA== system@example\n"
|
||||
b"\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["user_keys"][0]["user"] == "host@example"
|
||||
assert da_adb.results[0]["system_keys"] == "RUZHSA== system@example"
|
||||
|
||||
def test_unbalanced_state_is_reported_rather_than_raising(self):
|
||||
da_adb = DumpsysADBArtifact()
|
||||
da_adb.parse(
|
||||
b"ADB MANAGER STATE (dumpsys adb):\n"
|
||||
b"{\n"
|
||||
b" debugging_manager={\n"
|
||||
b" connected_to_adb=true\n"
|
||||
b" }\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
)
|
||||
|
||||
# No exception, and whatever was read before the bad line is kept.
|
||||
assert len(da_adb.results) == 1
|
||||
assert da_adb.results[0]["connected_to_adb"] is True
|
||||
|
||||
|
||||
class TestDumpsysADBStateAlerts:
|
||||
def test_no_androidqf_context_preserves_existing_behavior(self):
|
||||
|
||||
@@ -25,10 +25,37 @@ class TestDumpsysAppopsArtifact:
|
||||
assert da.results[0]["uid"] == "0"
|
||||
assert len(da.results[0]["permissions"]) == 1
|
||||
assert da.results[0]["permissions"][0]["name"] == "MANAGE_IPSEC_TUNNELS"
|
||||
assert da.results[0]["permissions"][0]["access"] == "allow"
|
||||
assert da.results[0]["permissions"][0]["mode"] == "allow"
|
||||
assert da.results[6]["package_name"] == "com.sec.factory.camera"
|
||||
assert len(da.results[6]["permissions"][1]["entries"]) == 1
|
||||
assert len(da.results[11]["permissions"]) == 4
|
||||
wake_lock = next(
|
||||
permission
|
||||
for permission in da.results[11]["permissions"]
|
||||
if permission["name"] == "WAKE_LOCK"
|
||||
)
|
||||
assert wake_lock["entries"][0]["duration"] == "+126ms"
|
||||
|
||||
def test_running_and_attribution_are_retained(self):
|
||||
da = DumpsysAppopsArtifact()
|
||||
da.parse(
|
||||
""" Uid 0:
|
||||
state=cch
|
||||
Package com.example:
|
||||
CAMERA (allow):
|
||||
camera=[
|
||||
Access: [fg-s] 2025-01-01 00:00:00.000 (-1s) duration=+2ms
|
||||
]
|
||||
RECORD_AUDIO (allow):
|
||||
Running start at: +3s
|
||||
"""
|
||||
)
|
||||
|
||||
camera = da.results[0]["permissions"][0]["entries"][0]
|
||||
running = da.results[0]["permissions"][1]["entries"][0]
|
||||
assert camera["attribution"] == "camera"
|
||||
assert running["event"] == "running"
|
||||
assert running["relative_time"] == "+3s"
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
da = DumpsysAppopsArtifact()
|
||||
|
||||
@@ -57,18 +57,19 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
assert uninstall_alert.message == (
|
||||
"Detected uninstall of package com.example.removed (vers 0)"
|
||||
)
|
||||
assert uninstall_alert.event_time == "2022-08-16"
|
||||
assert uninstall_alert.event_time == "2022-08-16 15:56:39"
|
||||
assert uninstall_alert.event["package_name"] == "com.example.removed"
|
||||
assert uninstall_alert.event["vers"] == "0"
|
||||
assert uninstall_alert.event["version_code"] == 0
|
||||
assert uninstall_alert.event["action"] == "uninstall"
|
||||
|
||||
assert downgrade_alert.level == AlertLevel.MEDIUM
|
||||
assert downgrade_alert.message == (
|
||||
"Detected downgrade of package com.example.app from vers 10 to vers 9"
|
||||
)
|
||||
assert downgrade_alert.event_time == "2022-08-17"
|
||||
assert downgrade_alert.event_time == "2022-08-17 15:56:39"
|
||||
assert downgrade_alert.event["package_name"] == "com.example.app"
|
||||
assert downgrade_alert.event["action"] == "downgrade"
|
||||
assert downgrade_alert.event["previous_vers"] == "10"
|
||||
assert downgrade_alert.event["previous_version_code"] == 10
|
||||
|
||||
def test_newest_first_update_is_not_reported_as_downgrade(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
@@ -107,7 +108,19 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
assert downgrade_alert.event_time == "2026-01-10"
|
||||
assert downgrade_alert.event["package_name"] == "com.example.app"
|
||||
assert downgrade_alert.event["action"] == "downgrade"
|
||||
assert downgrade_alert.event["previous_vers"] == "102"
|
||||
assert downgrade_alert.event["previous_version_code"] == 102
|
||||
|
||||
def test_duplicate_updates_retain_occurrence_count(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
""" Daily from 2026-01-10-01-02-03 to 2026-01-11-04-05-06:
|
||||
Update com.example.app vers=12
|
||||
Update com.example.app vers=12
|
||||
"""
|
||||
)
|
||||
|
||||
assert dba.results[0]["occurrences"] == 2
|
||||
assert dba.results[0]["period_start"] == "2026-01-10 01:02:03"
|
||||
|
||||
def test_reinstall_after_uninstall_is_not_reported_as_downgrade(self):
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
@@ -131,3 +144,18 @@ class TestDumpsysBatteryDailyArtifact:
|
||||
"Detected uninstall of package com.example.app (vers 0)"
|
||||
)
|
||||
assert uninstall_alert.event_time == "2026-01-10"
|
||||
|
||||
def test_malformed_update_line_does_not_lose_later_records(self):
|
||||
# A truncated "Update" line, or one from a vendor that omits "vers=",
|
||||
# used to raise out of parse() and lose every record after it.
|
||||
dba = DumpsysBatteryDailyArtifact()
|
||||
dba.parse(
|
||||
" Daily from 2021-05-10-08-00-00 to 2021-05-11-08-00-00:\n"
|
||||
" Update com.first vers=1\n"
|
||||
" Update com.truncated\n"
|
||||
" Update com.no.equals vers 2\n"
|
||||
" Update com.last vers=3\n"
|
||||
)
|
||||
|
||||
assert [r["package_name"] for r in dba.results] == ["com.first", "com.last"]
|
||||
assert [r["version_code"] for r in dba.results] == [1, 3]
|
||||
@@ -54,6 +54,7 @@ class TestDumpsysBatteryHistoryArtifact:
|
||||
assert len(dba.results) == 2
|
||||
assert dba.results[0] == {
|
||||
"time_elapsed": "07-15 20:27:39.431",
|
||||
"timestamp": "1900-07-15 20:27:39.431000",
|
||||
"event": "start_job",
|
||||
"uid": "u0a123",
|
||||
"package_name": "com.example",
|
||||
@@ -61,3 +62,23 @@ class TestDumpsysBatteryHistoryArtifact:
|
||||
}
|
||||
assert dba.results[1]["event"] == "end_job"
|
||||
assert dba.results[1]["uid"] == "u0a123"
|
||||
|
||||
def test_wake_lock_without_component_is_retained(self):
|
||||
dba = DumpsysBatteryHistoryArtifact()
|
||||
dba.parse(
|
||||
"Battery History:\n"
|
||||
" 0 (2) 100 RESET:TIME: 2025-09-05-01-04-52-139\n"
|
||||
' +1s (2) 100 +running +wake_lock=1000:"*alarm*:TIME_TICK"\n'
|
||||
' +2s (2) 100 +running +wake_lock=u0a1:"*walarm*:com.whatsapp.MessageHandler.LOGOUT_ACTION"\n'
|
||||
"\n"
|
||||
)
|
||||
|
||||
assert [record["event"] for record in dba.results] == ["wake", "wake"]
|
||||
assert dba.results[0]["package_name"] is None
|
||||
assert dba.results[1]["package_name"] == "com.whatsapp"
|
||||
|
||||
def test_decorated_sync_job_uses_component_package(self):
|
||||
dba = DumpsysBatteryHistoryArtifact()
|
||||
dba.parse('+1s (2) 100 +job=u0a1:"@SyncManager@gmail-ls/com.google:android"\n')
|
||||
|
||||
assert dba.results[0]["package_name"] == "com.google"
|
||||
@@ -53,9 +53,32 @@ Connection pool for /data/user/0/com.example/databases/current.db:
|
||||
|
||||
assert dbi.results == [
|
||||
{
|
||||
"isodate": "07-15 20:27:39.431",
|
||||
"timestamp": "07-15 20:27:39.431",
|
||||
"pid": None,
|
||||
"action": "executeForCursorWindow",
|
||||
"duration_ms": 1,
|
||||
"status": "succeeded",
|
||||
"sql": "SELECT 1",
|
||||
"path": "/data/user/0/com.example/databases/current.db",
|
||||
"pool_path": "/data/user/0/com.example/databases/current.db",
|
||||
"connection_number": None,
|
||||
"is_primary": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_parses_operations_from_multiple_connections(self):
|
||||
dbi = DumpsysDBInfoArtifact()
|
||||
dbi.parse(
|
||||
"""Connection pool for /data/example.db:
|
||||
Connection #0:
|
||||
isPrimaryConnection: true
|
||||
Most recently executed operations:
|
||||
0: [2025-01-01 00:00:00.000] execute took 1ms - succeeded, sql="SELECT 1", path=/data/example.db
|
||||
Connection #1:
|
||||
isPrimaryConnection: false
|
||||
Most recently executed operations:
|
||||
0: [2025-01-01 00:00:01.000] execute took 2ms - succeeded, sql="SELECT 2", path=/data/example.db
|
||||
"""
|
||||
)
|
||||
|
||||
assert [record["connection_number"] for record in dbi.results] == [0, 1]
|
||||
@@ -21,12 +21,16 @@ class TestDumpsysPackageActivitiesArtifact:
|
||||
|
||||
assert len(dpa.results) == 0
|
||||
dpa.parse(data)
|
||||
assert len(dpa.results) == 4
|
||||
assert dpa.results[0]["package_name"] == "com.samsung.android.app.social"
|
||||
assert len(dpa.results) == 10
|
||||
assert dpa.results[0]["package_name"] == "com.samsung.android.messaging"
|
||||
assert (
|
||||
dpa.results[0]["activity"]
|
||||
== "com.samsung.android.app.social/.feed.FeedsActivity"
|
||||
dpa.results[0]["component"]
|
||||
== "com.samsung.android.messaging/.ui.RcsTransferContent"
|
||||
)
|
||||
assert {result["resolver_type"] for result in dpa.results} == {
|
||||
"full_mime_type",
|
||||
"non_data_action",
|
||||
}
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
dpa = DumpsysPackageActivitiesArtifact()
|
||||
@@ -41,4 +45,4 @@ class TestDumpsysPackageActivitiesArtifact:
|
||||
dpa.indicators = ind
|
||||
assert len(dpa.alertstore.alerts) == 0
|
||||
dpa.check_indicators()
|
||||
assert len(dpa.alertstore.alerts) == 1
|
||||
assert len(dpa.alertstore.alerts) == 2
|
||||
@@ -25,7 +25,11 @@ class TestDumpsysPackagesArtifact:
|
||||
== "com.samsung.android.provider.filterprovider"
|
||||
)
|
||||
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]["version_code"] == 500700000
|
||||
assert dpa.results[0]["min_sdk"] == 28
|
||||
assert dpa.results[0]["target_sdk"] == 28
|
||||
assert dpa.results[0]["package_type"] == "active"
|
||||
assert dpa.results[0]["users"][0]["user_id"] == 0
|
||||
assert dpa.results[0]["system"] is True
|
||||
|
||||
def test_parsing_system_flag(self):
|
||||
@@ -60,42 +64,27 @@ class TestDumpsysPackagesArtifact:
|
||||
dpa.check_indicators()
|
||||
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
|
||||
def test_hidden_packages_and_per_user_state(self):
|
||||
dpa = DumpsysPackagesArtifact()
|
||||
dpa.parse(
|
||||
"""Packages:
|
||||
Package [com.example.active]:
|
||||
appId=10001
|
||||
versionCode=12 minSdk=29 targetSdk=35
|
||||
User 0: installed=true hidden=false
|
||||
firstInstallTime=2025-01-01 01:02:03
|
||||
User 10: installed=false hidden=true
|
||||
firstInstallTime=2025-01-02 01:02:03
|
||||
Hidden system packages:
|
||||
Package [com.example.hidden]:
|
||||
appId=10002
|
||||
installerPackageName=null
|
||||
"""
|
||||
)
|
||||
|
||||
assert details["first_install_time"] == "2024-01-10 09:19:39"
|
||||
runtime_permissions = [
|
||||
permission
|
||||
for permission in details["permissions"]
|
||||
if permission["type"] == "runtime"
|
||||
assert [record["package_type"] for record in dpa.results] == [
|
||||
"active",
|
||||
"hidden_system",
|
||||
]
|
||||
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"
|
||||
assert len(dpa.results[0]["users"]) == 2
|
||||
assert dpa.results[1]["installer"] is None
|
||||
@@ -22,6 +22,9 @@ class TestDumpsysPlatformCompatArtifact:
|
||||
assert len(dbi.results) == 2
|
||||
assert dbi.results[0]["package_name"] == "org.torproject.torbrowser"
|
||||
assert dbi.results[1]["package_name"] == "org.article19.circulo.next"
|
||||
assert dbi.results[0]["change_id"] == 168419799
|
||||
assert dbi.results[0]["change_name"] == "DOWNSCALED"
|
||||
assert dbi.results[0]["override_value"] is False
|
||||
|
||||
def test_ioc_check(self, indicator_file):
|
||||
dbi = DumpsysPlatformCompatArtifact()
|
||||
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user