How 7-Zip password hashes are extracted
Why 7z is the trickiest archive to crack: LZMA-compressed headers, AES coder property encoding, and the full $7z$ hash format explained.
Of all the archive formats Hash Extractor supports, 7-Zip is the one that requires the most work before you can even see the encryption parameters. Most formats let you locate a header, read a salt and IV, and assemble a hash string. 7-Zip forces you to decompress its own header first — in pure Rust, inside a WebAssembly module, with no native binaries — before you can read a single AES parameter.
This post walks through every step: the signature header, the encoded-header LZMA decompression, the AES coder property encoding, the $7z$ hash format, and the shortening heuristic that keeps hashes manageable. All claims are grounded in the extractor's Rust source.
The signature header and NextHeader pointer
A 7z file always begins with a six-byte magic signature (37 7A BC AF 27 1C). Immediately after it sits two version bytes, a four-byte start CRC, then two eight-byte little-endian values: NextHeaderOffset and NextHeaderSize. The extractor reads them at byte offsets 12 and 20, then jumps to 32 + NextHeaderOffset to find the first header block.
The first byte of that block is a property ID that determines what follows:
0x01(kHeader) — the header is stored in plain text. Parse it directly.0x17(kEncodedHeader) — the header has been LZMA-compressed. This is the default for almost every real-world 7z file.
Why the header is LZMA-compressed
The 7z container stores file metadata (names, sizes, timestamps, checksums) in the header. For archives with thousands of small files that metadata can be substantial. Compressing it is sensible — but it adds a mandatory decompression step before any cracking-related information is accessible.
When the first property ID is kEncodedHeader, the parser reads a StreamsInfo block that describes the compressed header stream: a PackInfo (where the packed bytes start, how large they are) and an UnpackInfo (a folder of coders describing how to decode them). The extractor checks whether the first coder in the first folder carries the LZMA1 codec ID (03 01 01). If it does, it calls lzma_decompress, which wraps the raw 7z LZMA stream in an .lzma (standalone) container and hands it to the lzma-rs crate.
signature header
└─ NextHeader → kEncodedHeader
└─ StreamsInfo (pack + unpack)
└─ first coder: LZMA1 (03 01 01)
└─ decompress → real kHeader
└─ MainStreamsInfo
└─ folders / coders
└─ AES coder (06 F1 07 01) ← this is what we need
The decompressed bytes are then reparsed as a raw kHeader, giving the real MainStreamsInfo that describes the encrypted file data.
Header encryption (-mhe)
When 7-Zip is invoked with -mhe=on, it also encrypts the header. In that case the StreamsInfo for the encoded header itself contains an AES coder rather than an LZMA coder — the parser finds codec ID 06 F1 07 01 at the kEncodedHeader level. The extractor handles this by walking the same folder/coder structure: any coder with codec ID 06 F1 07 01 is treated as the AES stage regardless of which layer it appears at.
Decoding the AES coder properties
Once the extractor has located the AES coder (codec 06 F1 07 01) in the folder chain, it reads the coder's attribute bytes. The encoding is compact:
- Byte 0, bits 0–5:
numCyclesPower. The actual KDF iteration count is2^numCyclesPower. A value of19means2^19 = 524 288SHA-256 rounds. - Byte 0, bit 6: set if the IV length needs a bias byte.
- Byte 0, bit 7: set if the salt length needs a bias byte.
- Byte 1, high nibble: salt length extension.
- Byte 1, low nibble: IV length extension.
- Bytes 2 onward: salt bytes (if any), then IV bytes (if any), zero-padded to 16 bytes.
A numCyclesPower of 19 is 7-Zip's default. Higher values mean more GPU time per guess.
The $7z$ hash format
With salt, IV, cycles, and the encrypted data payload in hand, the extractor formats the hash:
$7z$<type>$<cycles>$<saltlen>$<salt>$<ivlen>$<iv>$<crc>$<datalen>$<unpacksize>$<data>
For compressed payloads, a trailer is appended:
...$<crc_len>$<coder_attrs>
type encodes the compression applied to the plaintext after AES decryption. 0 means stored (no compression). 1 means LZMA1. 2 means LZMA2. The value (preprocessor_type << 4) | compression_type is used when a BCJ or similar filter precedes the compressor. The coder_attrs field carries the compressor's attribute bytes as a hex string so hashcat can reproduce the full decode pipeline when verifying a candidate password.
Real example from the test suite
The extractor's test fixture is a password-protected (pw) stored 7z archive. The expected output is:
$7z$0$19$0$$16$bb4b54fc7e9667eb9ce95a24d87aff61$2622387940$32$27$c36b4ffeb9269c63ae7796a729de7959554012cc426a7a1b410c7d9fb15076c0
Breaking it down field by field:
| Position | Value | Meaning |
|---|---|---|
type | 0 | Stored — plaintext is not compressed after AES |
cycles | 19 | KDF rounds = 2^19 = 524 288 |
saltlen | 0 | No salt |
salt | (empty) | — |
ivlen | 16 | 16-byte IV |
iv | bb4b54fc... | Initialization vector |
crc | 2622387940 | CRC-32 of the decrypted stream |
datalen | 32 | Bytes of encrypted data included |
unpacksize | 27 | Expected size after decryption |
data | c36b4ffeb9... | The raw ciphertext bytes |
Because type = 0 (uncompressed), there is no trailer. The hash carries just enough ciphertext for hashcat to attempt decryption and verify the CRC on a candidate password — it never needs the full file payload.
The shortening heuristic
For archives with large compressed payloads, including the entire ciphertext in the hash would produce unwieldy strings. The extractor mirrors the 7z2john.pl approach: when the payload is compressed (type != 0), it estimates how many AES blocks cover roughly the first file using the formula
approx = 32.5 + crc_len + 0.05 * crc_len
aes_len = round_up_to_16(approx)
where crc_len is the first substream's unpack size. If aes_len is smaller than the full packed size, datalen and unpacksize are both clamped to aes_len. This keeps the hash compact while preserving enough ciphertext for the cracker to work with.
Cracking the hash
Both hashcat and John the Ripper support $7z$ natively.
hashcat (-m 11600):
echo '$7z$0$19$0$$16$bb4b54fc7e9667eb9ce95a24d87aff61$2622387940$32$27$c36b4ffeb9269c63ae7796a729de7959554012cc426a7a1b410c7d9fb15076c0' > hash.txt
hashcat -m 11600 hash.txt /usr/share/wordlists/rockyou.txt
John the Ripper (--format=7z):
john --format=7z --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
GPU throughput on $7z$ is dramatically lower than on simpler formats. At numCyclesPower=19 each candidate requires over half a million SHA-256 invocations. Expect rates in the tens of thousands of candidates per second on a mid-range GPU rather than the billions you see with raw MD5. Higher numCyclesPower values (20, 21) reduce that further by design.
Client-side WASM, archive never uploaded
The LZMA decompression that makes all of this possible — the lzma-rs call that turns a compressed 7z header into a parseable byte slice — runs entirely inside a Web Worker in the browser. The archive bytes go from FileReader into the WASM module and never touch a network socket. The hash that comes back is the only output, and it contains no file content: only the salt, IV, KDF parameters, and a small ciphertext window the cracker needs.
This is the same guarantee the 7z2john.pl script provides when you run it locally, except the extraction happens in the browser with no dependencies to install.