Extracts and audits Android OTA and firmware images, without running them.
Firmware is a pile of containers inside containers: an OTA zip holds a
payload.bin, which holds partitions, which hold ext4 or erofs trees, which
hold init scripts and properties. android-doctor opens every layer, with no
root, no mount and no device, and answers one question: is this build
shipping a debuggable, rooted or unsigned configuration? Output is plain
text for people and --json for agents.
Contents
- Get started
- What it catches
- Agent integration
- CLI reference
- Format support
- What it will not tell you
- Exit codes
- Privacy and telemetry
- Build
- Third-party code and references
- License
Get started
1. Install
Three ways to get it. The npm and crates.io packages are published from each tagged release together with the binaries.
The npm package in npm/ is a small launcher. On first run it downloads
the release binary for your platform (macOS aarch64 or x86_64, Linux x86_64)
into ~/.cache/android-doctor/<version>/, then runs it and passes on its exit
code. It falls back to an android-doctor on your PATH.
Or take a prebuilt binary from the
GitHub releases:
download android-doctor-<target>.tar.gz (aarch64-apple-darwin,
x86_64-apple-darwin or x86_64-unknown-linux-gnu), extract it, and place the
binary on your PATH:
2. Identify and extract
identify says what a file is by its magic bytes, never by its name:
system.img: ext4 filesystem
extract reads an OTA zip, a directory or a bare payload.bin and writes the
partition images. See What extract does.
3. Audit an image
audit reads the file system inside the image (ext2/3/4 or erofs) and reports
ADB properties, setuid files, su binaries and init services. This image was
built from a tree with a debuggable build.prop and a setuid su:
ADB: ro.secure=0 ro.adb.secure=0 ro.debuggable=1 usb=not set: adbd can run as root
== system (5 entries)
[high] debuggable-build: ro.debuggable=1 in system/build.prop: adbd runs as root
[high] insecure-adb: ro.secure=0 in system/build.prop: adbd keeps root
[high] adb-unauthenticated: ro.adb.secure=0 in system/build.prop: ADB connections need no authorization
[high] su-binary: /system/xbin/su looks like a su binary
[info] setuid-files: 1 setuid/setgid files
properties:
ro.debuggable=1 (system/build.prop)
ro.secure=0 (system/build.prop)
ro.adb.secure=0 (system/build.prop)
ro.build.version.security_patch=2021-01-05 (system/build.prop)
setuid/setgid files:
4755 501:0 /system/xbin/su
4. Health-scan a directory
doctor scan runs the deterministic rule set over an unpacked firmware
directory: security rules plus quality rules (missing partitions, duplicate
properties, SELinux label gaps, mode anomalies, service hygiene, debug
leftovers). Add --json for one object per finding with id, category,
severity, subject, message and remedy:
warn quality partition_coverage: system.img: expected partition image system is missing
└ ensure system.img is present; the device may not boot without it
warn quality partition_coverage: vendor.img: expected partition image vendor is missing
└ ensure vendor.img is present; the device may not boot without it
info security avb_signature: vbmeta.img: no vbmeta.img found; AVB signature not checked
└ point doctor at a directory containing vbmeta.img
A rule that cannot evaluate says so (info ... cannot evaluate) instead of
staying silent.
What it catches
| Area | Examples |
|---|---|
| ADB and debug posture | ro.debuggable=1, ro.secure=0, ro.adb.secure=0, adbd services running as root or shell |
| Privilege | su binaries, setuid/setgid files, file capabilities, world-writable files |
| Boot chain | AVB flags, rollback index, chained vbmeta, unsigned images, RSA signature and partition hash checks |
| Secrets and apps | Hardcoded credentials in file contents, APK package names and signing certificates |
| Quality | Missing partitions, duplicate properties, SELinux label gaps, mode anomalies, init service hygiene, debug leftovers |
| Staleness | Security patch level age from OTA metadata: ok, stale (over 90 days), very stale (over 365) |
Agent integration
Two ways to hand the tool to an AI coding agent.
MCP server. android-doctor mcp speaks the Model Context Protocol over
stdio and exposes three tools, identify, doctor and audit, each taking a
path. Findings arrive as structured data instead of scraped stdout. Register
it in your agent's MCP config:
Skill installer. doctor install writes a short skill describing the
commands, findings and severities:
Besides the per-user skill file, --agent cursor writes
.cursor/rules/android-doctor.mdc and --agent codex / --agent opencode
add a managed block to AGENTS.md, both in the current directory. Re-running
replaces the block and never touches the rest of the file.
See AGENTS.md for the contributor and agent-usage contract.
CLI reference
Every subcommand takes --help. --no-color (or NO_COLOR) disables colour.
| Command | What it does |
|---|---|
extract <ota.zip|dir|payload.bin> [-o out] [--only a,b] [--list] [--force] |
OTA to partition images (add --files for file trees, --base for incremental OTAs) |
unpack <boot.img|vendor_boot.img> [-o dir] [--json] |
Header, and with -o the kernel, ramdisk and dtb sections |
ramdisk <boot.img|ramdisk> [-o dir] [--json] [--list] |
Ramdisk contents and ADB properties |
audit <image|dir>... [--json] |
ADB properties, setuid files, su binaries, init services, findings |
vbmeta <vbmeta.img> [--images dir] [--json] [--key key.pem] |
AVB header, key, signature, descriptors, partition hashes |
ls <image> [path] / cat <image> <path> |
Browse and read files inside an ext2/3/4 or erofs image |
files <image> [-o dir] [--json] |
List or extract an image's files with SELinux labels |
unsparse <file>... -o out.img |
Android sparse images to a raw image |
identify <path>... [--json] |
What is this file, by magic bytes |
info <ota> [--json] / report <ota> [--json] |
Build metadata / staleness verdict |
dt <file> [--json] |
Device tree, dtbo table or container header |
amlogic <file> |
Describe an Amlogic image container |
partitions <dir> [--json] [--sector-size N] |
Qualcomm rawprogram*.xml / MediaTek scatter.txt flash manifest |
hash-tree <image> |
Regenerate the dm-verity hash tree for a rebuilt partition |
doctor scan <dir> [--json] |
Deterministic health scan |
doctor install [--agent <name>] [--print-only] |
Install the agent skill |
mcp [--verbose] |
MCP server on stdio |
What extract does
extract recognises the kind of OTA by looking inside it.
Full A/B OTAs (a zip with a stored payload.bin, a directory holding payload.bin, or a bare payload.bin):
- Decodes the
REPLACE,REPLACE_BZ,REPLACE_XZandZSTDoperations (andZERO/DISCARD) in parallel across all CPU cores, straight from the zip (no unzip step), with a progress bar per partition. - Checks the SHA-256 of every operation's data and of every finished image against the manifest, and says so on the result line (
sha256 verified). - All-or-nothing: if anything fails, no image is published and every
.partfile is removed. - Incremental OTAs (operations that need the previous build) are refused up front with a message naming the operation types.
- Limits that stop a hostile file from burning time or disk: manifest 64 MiB, 1024 partitions, 64 GiB per partition.
- Not done: partitions that declare dm-verity/FEC extents get their data extracted, but the whole-image hash is reported as "not checked", because the device adds those bytes at install time. A
payload.binstored compressed inside the zip must be unzipped first.
Full block OTAs (<part>.new.dat.br or .new.dat plus <part>.transfer.list, as a zip or an unpacked directory):
- Reads a zip in place (no unzip step), decompresses brotli on the fly and rebuilds each partition image, one thread per partition with a progress bar.
- Joins numbered pieces (
<part>.new.dat.N,<part>.new.dat.br.N) when an OTA ships them. - Copies the top-level
*.imgfiles (boot, recovery, dtbo, vbmeta, ...) unchanged. - Prints the filesystem (
ext2/ext3/ext4/erofs) on the result line of each image that holds one (raw images such asboot.imgget no tag);--listshows what would be written, with sizes, and writes nothing;--only system,bootselects images. - Refuses to overwrite existing files unless
--force; writes<name>.partand renames when complete, so a failed run never leaves a half-written image; never writes through symlinks.
report rates the security patch level by age: up to 90 days is ok, up to 365 is stale, older is very stale. The Android version line is informational.
Format support
Status: verified = checked against an independent reference tool on real firmware; synthetic = implemented and tested with generated fixtures only; planned = not implemented yet; no = not supported.
| Format | Status | Notes |
|---|---|---|
A/B payload.bin, full OTA (REPLACE, REPLACE_BZ, REPLACE_XZ) |
verified | A real 18-partition OTA: every image equals payload-dumper-go's and the hash decoded from the manifest by protoc; about 2.5x faster than payload-dumper-go on that file |
A/B payload.bin: ZERO, DISCARD, ZSTD operations |
synthetic | The real sample has none; checked against an independent payload builder and against payload-dumper-go on those payloads |
Block OTA, brotli (*.new.dat.br) |
verified | Two real OTAs, sha256 equal to brotli -d + sdat2img |
Block OTA, raw (*.new.dat) and numbered pieces |
verified | Real partitions split into pieces rebuild to the reference hashes |
| Raw images inside an OTA (boot, recovery, ...) | verified | Byte-identical to the zip entries |
| Filesystem detection: ext2/3/4 | verified | Four real images, cross-checked with an independent superblock parse |
| Filesystem detection: erofs | verified | Real mkfs.erofs images (plain and lz4hc) |
Android sparse images (unsparse), single and split files |
verified | Real partitions converted by img2simg (block sizes 1024 to 65536) and split by simg2simg: sha256 equal to the original and to simg2img |
File identification (identify) |
verified | 27 real files, from OTA zips to boot images to xz/zstd/lz4 output |
super.img (dynamic partitions): detect (identify), parse LP metadata and split into partition images (extract) |
verified | Synthetic super image with geometry/header/table checksums and a linear extent: geometry magic detected, checksums validated, and extents split byte-for-byte; extract writes one <partition>.img per linear/zero extent |
Files out of ext2/3/4 images (files, ls, cat, extract --files) |
verified | Four real partition images (3,553 entries) equal 7z's path list and file hashes, and SELinux labels and file capabilities of every inode equal an independent scanner; images built by mke2fs -d from a known tree in six variants (ext2, ext3, ext4, 128-byte inodes with 1 KiB blocks, 64bit, no extents) equal the source tree in content, mode, hardlinks, sparse files and xattrs. Written from the on-disk format: no root, no mount, bounded memory (23 MB peak on 540 hostile images). extract <ota> --files writes every ext2/3/4 image's tree and manifest under <out>/files/<image>/; ls and cat read straight from an image without extracting it (checked against 7z's listing and bytes, and against the extracted tree). Case-colliding names are renamed name~case2 on case-insensitive hosts. Not supported: inline_data, encrypted files, meta_bg, xattr values stored in their own inode |
AVB vbmeta (vbmeta): header, public key, hash/hashtree/property/cmdline/chain descriptors, footer, authentication digest, partition hashes |
verified | Every field equals AOSP avbtool info_image on four real images (a signed RSA-4096 vbmeta with 22 descriptors, a boot and a dtbo image read through their footers, and an unsigned vbmeta with verification disabled); the boot and dtbo hashes equal the descriptors and a flipped byte is caught. The RSA signature itself is not verified, nor are hashtrees |
Security audit (audit): ADB and build properties, setuid/setgid files, file capabilities, world-writable files, su binaries, init services (adbd, root or shell-domain services), findings with a severity |
verified | On the four real stock images every list equals an independent re-derivation from 7z's listing and file contents (properties, setuid, world-writable, su, all 117 init services); images built from a known tree with su, setuid files, debuggable properties and an adbd service give the expected 12 rule findings, identical for ext4 and erofs. Reports every file that sets a property (init load order is not simulated) and reads at most 50,000 text files per image. Not included: APK package names and signing certificates, SELinux policy analysis |
Files out of erofs images (same files, ls, cat, extract --files) |
verified | Images built by mkfs.erofs 1.9 in ten layouts (plain, lz4, lz4hc, lzma, deflate, two algorithms at once, big pclusters with ztailpacking, fragments with dedupe, chunk-based, an xattr prefix dictionary): every entry equals fsck.erofs --extract in content, size and links, hard links stay shared, and ls/cat agree. Reading is done by the am-fs-erofs crate (MIT, clean-room); this tool adds limits on depth, entries, directory and file size, bounded reads and hole-preserving output, and was run against 600 corrupted images (no panic, hang or kill, 48 MB peak). Not verified: ZSTD-compressed images (the Homebrew mkfs.erofs has no zstd, so none could be built) and a real device image; images using 48-bit addressing or the metabox are refused by name |
Boot / recovery / vendor_boot images: header and sections (unpack) |
verified | Real STB boot and recovery (header v1) and a real A/B boot (v2): every section and header field equals AOSP's unpack_bootimg.py. Boot v0, v3, v4 and vendor_boot v3, v4 on images built by AOSP's mkbootimg.py: identical |
Ramdisk (ramdisk): gzip, bzip2, xz, zstd, lz4 (frame and legacy), plain cpio |
verified | A real 37 MB gzip ramdisk (486 entries): every file sha256, directory, symlink target, mode and size equals bsdtar's; archives built by bsdtar and compressed with the standard tools in all six formats; a vendor_boot with gzip, lz4 and xz fragments built by mkbootimg.py |
ADB/debug properties from a ramdisk (ro.secure, ro.adb.secure, ro.debuggable, ...) |
verified | Found through the default.prop -> prop.default symlink on the real ramdisk, equal to the values read from the extracted file |
Encrypted boot sections (Amlogic @AML containers and ciphertext) |
detected only | unpack labels them (aml-container, unknown-high-entropy) and ramdisk explains why it cannot read them; there is no way to decrypt without the vendor's keys. The real STB recovery image is one of these |
tar, .tar.md5, gzip, bzip2, xz, lz4 wrappers |
verified | Synthetic tars built with tar::Builder and wrapped with flate2: plain, gzip and Samsung .tar.md5 all round-trip boot.img/system.img byte-for-byte, and an extract run over an update directory unpacks SUPER.tar.md5 into its partition images instead of copying the blob. A .tar.md5 whose trailing MD5 does not match the tar is refused before anything is written; an entry named ../evil, an absolute path or a NUL is rejected, and device and fifo entries are refused rather than created. Extraction stages into <out>.part and is published only on success |
| AVB / vbmeta inspection | verified | RSA signature verification over the header + auxiliary block, matching avbtool verify_image |
Oppo/Realme .ozip (AES-128-ECB encrypted zip) |
synthetic | Decrypts to the inner zip using a per-model key table (adapted from B. Kerler's oppo_ozip_decrypt, MIT); tested with generated fixtures, not yet verified on real firmware |
Qualcomm rawprogram*.xml and MediaTek scatter.txt flash manifests |
synthetic | Parsed and checked against the image files present (partitions command); tested with generated fixtures |
Device tree / dtbo tables (dt) |
synthetic | Parses and displays the device tree blob and dtbo table headers; tested on AOSP mkdtboimg output |
| Other vendor containers (Amlogic, ...) | planned | Without real samples these will be marked unverified |
Spreadtrum/MediaTek .pac containers |
synthetic | Parser and extractor (adapted from SR Labs PacHandler, Apache-2.0); tested with generated fixtures, not yet verified on real firmware |
Incremental OTAs (*.patch.dat, delta payloads) |
no | Fails with a clear error |
| dm-verity hash tree and FEC regeneration for A/B partitions | planned | Needed to check the whole-image hash of partitions that declare those extents |
| f2fs | detected | Detected by magic, but not readable: the Linux kernel f2fs driver is GPL-licensed and no permissive Rust reader exists; commands that read a filesystem (files, ls, cat, audit, extract --files) fail with "f2fs is not supported" |
.ofp and other key-protected containers |
no |
Roadmap and issue list: see the milestones on GitHub.
What it will not tell you
- It never runs firmware. It reads bytes, so runtime behaviour (what a service actually does once booted) is out of scope.
- No APK or SELinux policy analysis beyond package names, signing certificates and label gaps.
- The whole-image hash of a partition with dm-verity/FEC extents is reported as "not checked"; the device adds those bytes at install time.
- f2fs is detected but not readable. Key-protected containers (
.ofp, encrypted Amlogic sections) are not decrypted. - Unsupported or unreadable input is reported as such, never as clean.
Exit codes
0 |
ran to completion |
1 |
doctor scan found an error-severity finding, or a failure: bad flag, unreadable or hostile input |
audit reports its findings in the output; it exits 0 unless it fails to read the input.
Privacy and telemetry
None. No network calls, no analytics. Everything runs locally on the files you point it at.
Build
Third-party code and references
See THIRD_PARTY.md. Code is adapted only from permissively licensed projects and credited there.
Not affiliated with or endorsed by Google. Android is a trademark of Google LLC.
License
MIT