Skip to content

How SSH and PEM key hashes are extracted

A technical walk-through of how Hash Extractor pulls crackable hashes from encrypted SSH and PEM private keys — entirely in the browser.

Published on 3 min read

Encrypted SSH and PEM private keys protect sensitive credentials behind a password. When you're auditing your own infrastructure or working a CTF challenge, you need a crackable hash — not the key file itself. Hash Extractor parses these files client-side in WebAssembly and emits exactly what ssh2john and pem2john would produce, without uploading the file anywhere.

There are three distinct wire formats, each with a different parsing path.


Shape 1 — Traditional openssl PEM

What the file looks like

-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,000102030405060708090a0b0c0d0e0f

ESIzRFVmd4g=
-----END RSA PRIVATE KEY-----

The header line reads BEGIN RSA PRIVATE KEY (or EC PRIVATE KEY, DSA PRIVATE KEY). The presence of Proc-Type: 4,ENCRYPTED and a DEK-Info: line signals that the body is encrypted.

Parsing

Detection checks for -----BEGIN within the first 120 bytes and PRIVATE KEY within the first 400 bytes. For traditional PEM the extractor then:

  1. Locates the DEK-Info: line and splits on the comma to get the cipher name and the IV hex string.
  2. Decodes the IV from hex — this 16-byte value is the salt.
  3. Strips the PEM headers (Proc-Type:, DEK-Info:, and the blank separator line) and base64-decodes the remaining body to get the raw ciphertext blob.
  4. Maps the cipher name to an integer id: DES-EDE3-CBC → 0, AES-128-CBC → 1 (3 for EC keys), AES-192-CBC → 4, AES-256-CBC → 5.

Output format

$sshng$<cid>$<saltlen>$<salt_hex>$<datalen>$<data_hex>

Using the test vector from the source (16-byte IV 000102030405060708090a0b0c0d0e0f, 8-byte body 1122334455667788, AES-256-CBC):

$sshng$5$16$000102030405060708090a0b0c0d0e0f$8$1122334455667788

Fields: cipher id 5 (AES-256-CBC), salt length 16, the IV as the salt, ciphertext length 8, ciphertext hex.


Shape 2 — New OpenSSH format

What the file looks like

-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAA...
-----END OPENSSH PRIVATE KEY-----

ssh-keygen has produced this format by default since OpenSSH 6.5. There are no in-header metadata lines — everything lives inside the base64 blob.

Parsing

After base64-decoding the body, the extractor validates the magic prefix openssh-key-v1\0, then reads sequentially using SSH wire-string framing (each string is a big-endian uint32 length prefix followed by that many bytes):

  1. ciphername — e.g. aes256-cbc (id 2) or aes256-ctr (id 6).
  2. kdfname — must be bcrypt; none means unencrypted (rejected).
  3. kdfoptions — itself a wire-string whose content is another wire-string (the bcrypt salt) followed by a uint32 round count.
  4. num_keys (uint32) and the public key wire-string are consumed and discarded.
  5. The next uint32 is the length of the encrypted private section. The current byte position after reading that length is recorded as ciphertext_begin_offset.

The extractor emits the entire raw blob (not just the ciphertext) because John the Ripper needs it to locate the check bytes at ciphertext_begin_offset.

Output format

$sshng$<cid>$<saltlen>$<salt_hex>$<bloblen>$<blob_hex>$<rounds>$<offset>

The extra $<rounds>$<offset> fields distinguish OpenSSH entries from traditional ones.


Shape 3 — PKCS#8 PBES2

What the file looks like

-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIBtzBRBgkqhkiG9w0BBQ0wRDAj...
-----END ENCRYPTED PRIVATE KEY-----

openssl pkcs8 -topk8 produces this format. There are no plaintext header lines; all parameters are DER-encoded inside the blob.

Parsing — a DER walk

After base64-decoding, the extractor walks the ASN.1 DER structure of EncryptedPrivateKeyInfo:

SEQUENCE {
  SEQUENCE {                        -- AlgorithmIdentifier
    OID 1.2.840.113549.1.5.13       -- id-PBES2
    SEQUENCE {                      -- PBES2-params
      SEQUENCE {                    -- keyDerivationFunc
        OID 1.2.840.113549.1.5.12   -- id-PBKDF2
        SEQUENCE {                  -- PBKDF2-params
          OCTET STRING              -- salt
          INTEGER                  -- iterations
          SEQUENCE { OID ... }     -- optional PRF (absent = hmacWithSHA1)
        }
      }
      SEQUENCE {                    -- encryptionScheme
        OID ...                     -- cipher (AES-128/192/256-CBC or 3DES)
        OCTET STRING                -- IV
      }
    }
  }
  OCTET STRING                      -- ciphertext
}

The OID at the PRF position (last byte 0x070x0b) resolves to SHA-1 through SHA-512.

Output format

SHA-1 PRF ($PEM$1, hashcat 24410):

$PEM$1$<cid>$<salt_hex>$<iterations>$<iv_hex>$<ct_len>$<ct_hex>

SHA-256 (or other) PRF ($PEM$2, hashcat 24420):

$PEM$2$pbkdf2$<prf_name>$<cipher_name>$<cid>$<salt_hex>$<iterations>$<iv_hex>$<ct_len>$<ct_hex>

From the regression test (PBKDF2-HMAC-SHA1, AES-128-CBC, 2048 iterations):

$PEM$1$2$bb34848915ca58920b61819e6c14569b$2048$<iv_hex>$<ct_len>$<ct_hex>

Cipher id 2 = aes128_cbc. Salt bb34848915ca58920b61819e6c14569b (16 bytes), 2048 iterations.


Cracking the hash

hashcat

# Traditional / OpenSSH sshng
echo '$sshng$5$16$000102030405060708090a0b0c0d0e0f$8$1122334455667788' > hash.txt
hashcat -m 22911 hash.txt /usr/share/wordlists/rockyou.txt

# PKCS#8 PBKDF2-HMAC-SHA1
hashcat -m 24410 hash.txt /usr/share/wordlists/rockyou.txt

# PKCS#8 PBKDF2-HMAC-SHA256
hashcat -m 24420 hash.txt /usr/share/wordlists/rockyou.txt

John the Ripper

# Traditional and OpenSSH
john --format=ssh hash.txt --wordlist=/usr/share/wordlists/rockyou.txt

# PKCS#8
john --format=PEM hash.txt --wordlist=/usr/share/wordlists/rockyou.txt

John's ssh2john / pem2john scripts produce identical output — Hash Extractor mirrors their logic exactly.


Why client-side WASM matters

The key file never leaves your machine. The Rust extractor is compiled to WebAssembly and runs entirely in the browser: it reads the file from a local <input>, parses the PEM structure in memory, and returns the hash string. No file is uploaded, no server sees the plaintext key material. The hash itself contains no private key data — only the salt, IV, and ciphertext, which are already present on disk.

Related articles

Why 7z is the trickiest archive to crack: LZMA-compressed headers, AES coder property encoding, and the full $7z$ hash format explained.
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.