Skip to content

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.

Published on 6 min read

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 is 2^numCyclesPower. A value of 19 means 2^19 = 524 288 SHA-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:

PositionValueMeaning
type0Stored — plaintext is not compressed after AES
cycles19KDF rounds = 2^19 = 524 288
saltlen0No salt
salt(empty)
ivlen1616-byte IV
ivbb4b54fc...Initialization vector
crc2622387940CRC-32 of the decrypted stream
datalen32Bytes of encrypted data included
unpacksize27Expected size after decryption
datac36b4ffeb9...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.

Related articles

A deep look at how Hash Extractor pulls crackable hashes from encrypted ZIPs — WinZip AES and legacy ZipCrypto — entirely in the browser.
Inside the adb backup text header: how PBKDF2 salts, round counts, and a master-key blob become a crackable $ab$ hash.
A technical walkthrough of how Hash Extractor parses Ansible Vault files to produce a crackable $ansible$ hash for hashcat and John the Ripper.