containers - 1-3-7/disrobe GitHub Wiki

Containers and archives

Before disrobe can decompile anything, it often has to get inside a container. The disrobe-binfmt layer detects every format below. Most use the generic member-byte extractor; LUKS1 uses a dedicated raw-volume-key route and reports a typed key wall when no key is supplied. Auto-detection, recursive chaining through nested layers, and shared zip-slip and decompression-bomb guards remain in effect. A committed input drives 30 generic extractors to member bytes that match an independent extractor (bsdtar, 7-Zip or the format's own decompressor), and the tracked LUKS1 fixture separately proves byte-exact decryption into the VHD extractor.

At a glance

Surface Support
Formats detected 105 archive, installer, filesystem, firmware, and encrypted-volume formats; 104 use the generic extraction entry point and LUKS1 uses its bounded raw-volume-key entry point
Formats exercised 30 of them write member bytes from an input this repository commits that match the members bsdtar 3.8.8, 7-Zip 26.02 or the format's own decompressor extracts from the same input (crates/disrobe-cli/tests/golden/container_breadth_reference.tsv, written by scripts/container_breadth_reference.py), measured by crates/disrobe-cli/tests/container_breadth.rs and pinned in crates/disrobe-cli/tests/golden/container_breadth.txt. 12 more write members that no installed reference tool confirms, the committed PAR2 input reaches detection only, and 61 generic routes have no committed input. The declared roster is a capability list rather than a measurement
Carve engine A recursive scan with the magic-signature table that detect_container uses. It carves eighteen formats: zip, 7z, RAR 4 and 5, CAB, xz, gzip, zstd, bzip2, tar, squashfs, minidump, ISO 9660, WIM, UnityFS, Scimitar Forge archives and bare Scimitar blocks, cpio and ar (including deb). Each hit ends where its decoder stops or where its headers place the end of the archive, so a stream followed by another container yields both; a zip is carved only when its end-of-central-directory record lies within the last 64 KiB of the input that follows it. InstallShield, RPM, xar, VHD, VHDX, lzip, lz4 and compress (.Z) signatures are detected but not carved, because the carver cannot compute where they end, and formats recognised by structure rather than a fixed signature (FAT, ext4 and MSI, for example) are not carved out of a larger blob. It recurses by depth and uses entropy to separate code from padding
Nesting Container-in-container chaining, governed by --max-depth (default 8)
Directory input Batch-processed recursively, bounded by --batch-max-depth
Guards Per-entry and aggregate size caps, recursion-depth cap, zip-slip path sanitization, on every format

Supported formats

Category Formats
Archives and installers ZIP (incl. ZIP64; an archive with encrypted members is refused, since no password can be supplied), tar.gz / tar.bz2 / tar.xz / tar.zst, 7z, RAR4 and RAR5 (stored members from both; RAR5 LZ "normal" method decoded in-tree; RAR 2.9/3.x LZ, PPMd, mixed LZ and PPMd blocks, and canonical delta and x86 filter programs decoded in-tree, with every decoded member checked against the CRC-32 its header declares; an encrypted member, a member split across volumes, and a stored member whose packed and unpacked sizes differ are each refused by name while the archive's other members still extract), .cab, MSI, MSIX / APPX, NSIS (each File entry's bytes from stored, zlib, BZip2 and LZMA data, solid and non-solid, with ANSI or Unicode names; installers that makensis 3.11 builds from corpus/installers/nsis/tide_viewer.nsi extract with every file's SHA-256 equal to its input in solid LZMA, zlib and BZip2, non-solid LZMA, zlib and BZip2, and ANSI solid LZMA builds; each file keeps the SetOutPath directory it is written to, with the installer variable at its root dropped; a second file written to a path already used gets a numbered name (readme~2.txt) and a repeat with the same bytes is written once, as builds of layered_members.nsi check; the install script's other instructions are not decompiled), Inno Setup 4.0.9 through setup-data profile 7.0.0.3 (stored, zlib, BZip2, LZMA1, and LZMA2 members, including solid groups), InstallShield legacy cabinets (ISc( majors 0 and 5: stored members, u16-length chunk-framed raw DEFLATE, and full-flush raw DEFLATE, with obfuscated members decoded before inflate), .deb, .rpm (metadata), AppImage Type 1 (ISO 9660, Rock Ridge, and zisofs) and Type 2 (SquashFS), Flatpak, Snap
Bare compression streams gzip, bzip2, xz, zstd, lzma, lzip, lz4-frame, zlib, .Z (Unix compress); concatenated gzip members, bzip2 streams, xz streams, and lzip members all decode, in order, both bare and under tar.gz / tar.bz2 / tar.xz
Legacy archives ar, arj (methods 0-4 decoded, with header and member CRC32 verified; split volumes refused by name), arc (methods 1-9 decoded; independent byte checks cover methods 2 and 5-9; methods 8-9 use grouped dynamic LZW with exact declared-size and CRC checks; methods 10-11 refused), LZH header levels 0-3 (-lh0- through -lh7-, -lhx-, -lz4-, -lz5-, -lzs-, and -pm0- decoded; -lhd- directories retained; -lhd- symbolic links refused; -pm1- and -pm2- decoded in tree and reaching extraction, each member checked against the CRC-16 its archiver stored; byte, code-page, and UTF-16 paths recovered; missing split volumes refused), lzop, FreeBSD uzip, Xamarin xalz, par2, ELF appended-overlay carve, StuffIt (classic stored and methods 2, 5 LZAH, 8 MW, and 13 decoded for data and resource forks, with record-header and fork CRC validation; StuffIt 5 containers parsed and their Arsenic and stored forks decoded in tree, though extraction still carves a StuffIt 5 archive rather than writing its forks; classic method 6 and StuffIt 5 method 14 are not implemented and refuse by name because no archive using them was found to grade a decoder against; encrypted forks refused), partclone (decoded)
Embedded-linux filesystems squashfs, cramfs, ext4, romfs, minixfs, jffs2, UBI + UBIFS, yaffs, erofs (full and compact indexes with lz4, deflate, zstd, and microlzma decoded), NTFS, android-sparse, btrfs-send
Disk images and partitions GPT and MBR (partition tables parsed; each partition carved and recursed in-tree), VHD (fixed and dynamic BAT), VHDX (region table + BAT; logical disk materialized from the block-allocation table, then partition-carved and FAT12 / 16 / 32 walked to pull individual stored files), WIM (header resources with XPRESS / LZX / LZMS chunk payloads decompressed in-tree), FAT12 / 16 / 32 (boot sector, FAT chain walk, root and subdirectory traversal)
Encrypted volumes LUKS1 with aes-cbc-plain, a 128-, 192-, or 256-bit raw volume key, and a SHA-1, SHA-256, or SHA-512 PBKDF2 header digest. The raw key is read from a bounded file or standard input, verified against the header digest, and zeroized after use before the decrypted payload enters the normal container pipeline. Keyless extraction succeeds with a typed wall that names the cipher, mode, digest, iteration count, and missing raw volume key
Apple .dmg (UDIF: koly trailer + blkx mish chunks; ADC / zlib / bzip2 / LZFSE / LZMA chunk decoders; then HFS+ catalog walk extracts individual files, all in-tree), .pkg (xar TOC + gzip / bzip2 heap, extracted in-tree)
Vendor firmware D-Link (SHRS / encrypted-img AES / alpha / fpkg), EnGenius XOR, Autel ECC table, QNAP PC1, plus CRC-verified Netgear (chk / trx), Xiaomi, Tesla, HP, Moxa, INSTAR, and Airoha carves; OTP-AES Airoha firmware is an information-theoretic wall and is carved verbatim
Standalone executables Bun --compile binaries for Windows, Linux and macOS (the embedded module graph and sourcemaps; each module's bundled source is recovered as Bun embedded it, and module names lose the /$bunfs/root/ or B:/~BUN/root/ prefix), Unity AssetBundle (UnityFS), .NET single-file bundles (majors 1, 2 and 6; embedded assemblies routed to the CIL decompiler, native entries to the native pass), Enigma Virtual Box 10.70 Build 20240522 x86 built-in stored-file bundles with literal .enigma1 and .enigma2 section names
App / runtime Electron .asar, Docker image tarball, OCI image manifest + layers, ISO 9660 with Joliet fallback, Rock Ridge names, ordered multi-extent files, and zisofs v1 (extracted in-tree)
Game engine archives Ubisoft Scimitar/Anvil .forge archives (header versions 27 to 30 by the layout public readers describe, and the current 33 and 34 with their scrambled file table) and the bare CompressedFileData block files beside them (.depgraphbin sidecars and exported two-block .data entries), with stored, zstd and Oodle Kraken, Mermaid and Leviathan chunks decoded in tree; see Scimitar Forge archives

Commands

Most extraction happens implicitly inside disrobe auto, which detects a container, extracts it, and recurses into the contents. Archive-shaped inputs are also available directly:

disrobe py extract package.whl --out extracted/
disrobe auto firmware-dir/ --out extracted/ --batch-max-depth 6
disrobe extract crash.dmp --out carved/
disrobe extract volume.luks --luks1-raw-volume-key-file volume.key --out decrypted/
disrobe extract volume.luks --luks1-raw-volume-key-file - --out decrypted/

Directory inputs are batch-processed recursively; --batch-max-depth limits directory descent. Container nesting inside a detected artifact is governed by --max-depth (default 8).

For ZIP, TAR, ARC, RAR, ARJ, LZH, StuffIt, Inno Setup, InstallShield, RPM, AppImage, .NET single-file, EROFS, and UEFI firmware-volume inputs, disrobe extract and disrobe auto apply the same default quota policy and retain the same ordered, byte-identical archive members. Extractor-generated sidecars are tagged separately and remain direct-only; an archive-controlled name such as .disrobe-user.bin remains an archive member. When an independently indexed member has an unsafe path, exceeds a quota, or cannot be decoded, extraction skips it and continues with trustworthy siblings. The direct result records the ordered reasons in integrity_violations; the automatic chain writes the ordered prefix that fits the 64 KiB container.refusals bound. container.refusals_total, container.refusals_omitted, and container.refusals_truncated make any truncation explicit. A malformed directory or table that cannot identify the next member safely still refuses the whole container.

Coverage and fidelity

Windows crash dumps

disrobe extract crash.dmp (or disrobe auto crash.dmp) carves the loaded PE modules out of a Windows minidump. It parses the stream directory, reads the module and memory lists, and for each module rebuilds an in-memory PE image by copying whatever memory the dump actually captured into a buffer at the correct RVAs, rewriting each section's file-offset field to match its virtual address so downstream PE parsers read the result as a well-formed image. Coverage is reported per page: the summary records how many bytes were recovered, which ranges the dump truncated, and which it never captured, with a reason for each gap, so a partially captured module is never presented as complete. The carve is graded by wrapping a real on-disk PE into a minidump and confirming the carved .text comes back byte-identical (minidump_real_pe.rs); each carved module and a .disrobe-minidump.json coverage summary land in the output directory.

Deno eszip archives

The disrobe-binfmt eszip reader (disrobe_binfmt::containers::eszip) parses a Deno eszip module-graph archive, versions 2 through 2.3, including one embedded inside a deno compile standalone executable, and reconstructs the module graph: each module's specifier, kind, and source bytes, plus redirects and npm specifiers, with per-module source-hash verification that drops any module whose stored hash does not match. It is exercised by a build-then-parse round-trip that also confirms a corrupted source hash is rejected.

Safety guards

Every extractor shares the quota machinery in crates/disrobe-binfmt/src/quota.rs:

  • Per-entry size cap and aggregate size cap defuse decompression bombs.
  • Recursion-depth cap defuses container-in-container bombs.
  • Zip-slip path sanitization (sanitize_entry_path): every entry path is sanitized so no extraction can escape the output directory, on every format.

Bypasses of any of these are treated as security issues; see the security policy.

Scimitar Forge archives

A .forge archive (magic scimitar followed by a zero byte) holds a chain of file allocation tables with 20-byte records, a 320-byte metadata record per slot and one entry container per record. The header layout is chosen per version: versions 27 to 30 follow the layout public readers describe, version 33 matches it, and version 34 carries one more reserved word; the reader refuses every other version by name. Version 34 stores each record's offset, uid and size through a fixed subtraction and exclusive-or per field; the reader reverses it and checks every decoded offset and size against the file before trusting it. Version 33 stores the uid through a per-file transform that is not known, so the layout marks those uids opaque and names the entry from its sub-file index instead.

disrobe extract writes .disrobe-forge-layout.json (header, table chain, every slot with its uid, offset, size, kind, type id, blocks, sub-file index and the files written for it) and one file per entry: the descriptor as <uid>.globalmeta.json, the fast-load table of contents as <uid>.fastload-toc.json, raw Wwise entries as <uid>.wem, and each two-block file container as <uid>.meta.bin (the sub-file index), the reassembled payload under the archive-supplied name when the metadata table carries one or else <uid>.<Type>.bin, plus one file per flat-archive record under <uid>/. Every entry name passes the same path sanitiser as the other extractors. Type ids are the CRC-32 of the engine class name and resolve against a shipped list of class names; unknown ids are written as 0x%08X. A record whose name field carries a flag word (recent archive versions set flag 2) keeps its bytes but its name is withheld, because the name key is not known; the layout counts these per entry. A bare block file (.depgraphbin, or an exported two-block .data entry) extracts as block<n>.bin per block, depgraph.json when the payload is a dependency graph, and .disrobe-scimitar-block-layout.json.

Each block is a chunk table; every chunk carries an Adler-32 seeded with zero over its stored bytes, checked before decoding. Stored chunks copy, zstd chunks decode through the in-tree zstd decoder, and Oodle chunks (pack methods 13 and 15) decode through disrobe's own Oodle decoder, written in Rust from the format description: Kraken, Mermaid and Selkie (one bitstream) and Leviathan with all six literal modes and multi-stream commands, over stored, Huffman (both header encodings, sparse, single-symbol and two-half forms), TANS, RLE, recursive and multi-array entropy arrays. Every chunk must decode to exactly its declared length.

Refused by name: header versions outside 27 to 30, 33 and 34 (DR-BINFMT-0087); malformed tables, table chains that loop or exceed 64 tables, records pointing past the file, declared chunk counts, chunk sizes or sub-file counts above their caps (DR-BINFMT-0088); the AC1/ACU/GRB AA33 block layout, whose header has no verified reference (DR-BINFMT-0089); pack methods without a decoder, including LZO and XMemLZX codecs of older titles and a streaming block that claims compression (DR-BINFMT-0090); Oodle streams the decoder refuses (DR-BINFMT-0091): LZNA and BitKnit streams, decoder ids outside the Oodle 2 family (the legacy LZH, LZHLW, LZNIB, LZB16, LZBLW, LZA and LZQ1 codecs have no open specification), per-quantum checksums (their algorithm is undocumented), Kraken excess-bytes sub-blocks and any malformed stream; an Adler-32 mismatch on any chunk (DR-BINFMT-0092); and a chunk whose declared sizes contradict each other (DR-BINFMT-0093). A refused entry stays listed with extracted: false and its reason, and the other entries still extract.

Check Evidence
Archive and block layout Authored archives for versions 30, 33 and 34 (stored, zstd, Oodle Kraken and Oodle Leviathan chunks, raw Wwise entries, chained tables, named metadata) and three .depgraphbin sidecars in corpus/binfmt/forge/, written by cargo run -p disrobe-binfmt --example gen_forge_fixture from the test-tree writer; each parses, extracts and rewrites byte for byte (crates/disrobe-binfmt/tests/forge.rs)
Oodle decoder 26 authored streams in corpus/binfmt/forge/oodle/ from disrobe's own test-tree encoder, one per decoder path; each decodes to its seeded payload through the path it names, and every one of their bytes, flipped, is refused or changes the output (crates/disrobe-binfmt/tests/oodle.rs)
Oodle against game data [local] DISROBE_FORGE_DIR=<install> cargo nextest run -p disrobe-binfmt --test it -E 'test(every_local_forge)' (crates/disrobe-binfmt/tests/real_forge_local.rs) over a locally installed game's Scimitar archives: all 259 archives of the 2025 build (versions 33 and 34) and its 45 .depgraphbin sidecars parse, every chunk Adler-32 matches, all 1,363,976 Oodle Leviathan chunks decode to their declared sizes (193.6 GB in 840,803 file containers, through every Leviathan literal mode, multi-stream commands and every entropy array form), every flat-archive record starts with its own type id, and every texture, sound-bank and sound-media record carries the header its type predicts. CI does not provision the game and its files are never committed

No independent Oodle decoder runs on CI: no open encoder or decoder with a usable licence exists, so the authored streams come from disrobe's own encoder, and only the local run compares the decoder against output of the real Oodle compressor. Archives are read from a byte slice by the generic extractor, so a multi-gigabyte game archive is parsed by the oracle through a seekable file source rather than by disrobe extract.

Limits

Where a format's payload is not decoded in-tree, the table above names it per entry rather than implying full extraction:

  • LUKS1 recovery is limited to aes-cbc-plain with a caller-supplied raw volume key. Passphrases and keyslot unlocking, XTS modes, and LUKS2 are not accepted by this route. A detected keyless LUKS1 header produces a successful typed wall. A detached LUKS1 header is refused until its separately stored encrypted payload is supplied. VeraCrypt and TrueCrypt may not be identifiable without a key, and headerless dm-crypt has no on-disk header to detect.
  • RAR 2.9/3.x members carry their transforms as RARVM programs. disrobe identifies a program by exact length and CRC-32 and runs a native transform for the canonical delta, x86 e8, x86 e8/e9, itanium, rgb and audio programs. It does not interpret RARVM bytecode, so a member carrying any other program is refused by name. The corpus grades the delta and x86 e8/e9 transforms against a real archive. The plain x86 e8, itanium, rgb and audio transforms have no real archive in the corpus yet, so their output rests on the CRC-32 check every decoded member passes and on known-answer tests rather than on a graded fixture. The crate publishes this gap as RAR3_FILTERS_WITHOUT_REAL_ARTIFACT and RAR3_FILTER_COVERAGE_NOTE in disrobe_binfmt::containers::rar. Encrypted members (the RAR4 LHD_PASSWORD flag or a RAR5 file-encryption record), members split across volumes (the RAR4 LHD_SPLIT_BEFORE and LHD_SPLIT_AFTER flags or the RAR5 volume-continuation header flags), and solid state carried from an earlier entry are refused by name, and the other members still extract. A stored member whose packed size differs from its declared unpacked size, and any member whose recovered length differs from its header, is refused rather than written short.
  • Cabinet (.cab and MSI-embedded) folders are decoded once each, in member-offset order, for stored, MSZIP, LZX, and LZMS folders, and each data block's checksum is verified when the cabinet sets one. Quantum folders, members that continue into another cabinet of a set, and members whose name appears more than once in the cabinet are refused by name.
  • In a solid 7z block, a member refused for its path is still read past, so the members after it keep their own bytes.
  • Inno Setup follows finite version profiles from the loader through both metadata blocks, file and data records, solid groups, filters, and checksums. Unsupported profiles and encrypted content without a secret refuse by name.
  • ARC methods 10-11 are refused.
  • StuffIt 5 containers are parsed and their forks decode in tree, but extraction still carves the archive verbatim rather than writing each fork, so disrobe auto recovers a StuffIt 5 archive as one blob. Classic StuffIt forks are unaffected and extract per fork.
  • OTP-AES Airoha firmware is an information-theoretic wall and is carved verbatim.
  • Forge archives of versions 27 to 30 follow the layout public readers describe and have no reference archive in the corpus; their metadata-record name decoding is graded on authored fixtures only. Record names that carry a flag word are withheld because their key is not known, and the version 33 uid transform is not known. The AA33 block layout and the LZO2A, LZO1C and XMemLZX codecs of older titles, and Oodle LZNA and BitKnit streams, are refused by name.
  • A minidump only contains the memory it captured. Truncated and never-captured ranges are reported with a reason per gap instead of being filled in.
⚠️ **GitHub.com Fallback** ⚠️