Skip to content

Add -m 37500, OpenSSH Private Keys (bcrypt-pbkdf) - #4767

Open
bishwabikash wants to merge 1 commit into
hashcat:masterfrom
bishwabikash:pr/m37500-openssh-bcrypt-pbkdf
Open

Add -m 37500, OpenSSH Private Keys (bcrypt-pbkdf)#4767
bishwabikash wants to merge 1 commit into
hashcat:masterfrom
bishwabikash:pr/m37500-openssh-bcrypt-pbkdf

Conversation

@bishwabikash

Copy link
Copy Markdown
Contributor

Motivation

ssh-keygen has written the openssh-key-v1 container with bcrypt-pbkdf key derivation by default since OpenSSH 7.8 (2018). The existing -m 22911..22951 handle only legacy PEM keys with the MD5-based KDF, so an SSH private key generated with default options today cannot be attacked with hashcat at all.

The gap is not a missing fast target — it is the opposite. The PEM formats hashcat implements run at ~2 GH/s on this card, while the format ssh-keygen actually produces has had no kernel. This adds one.

Algorithm

Per OpenBSD bcrypt_pbkdf.c:

sha2pass = SHA512 (password)
for each 32 byte output block:
    sha2salt = SHA512 (salt || BE32 (block))
    out = bcrypt_hash (sha2pass, sha2salt)
    repeat rounds-1 times:
        sha2salt = SHA512 (previous bcrypt_hash output)
        out ^= bcrypt_hash (sha2pass, sha2salt)
    scatter out into the key at stride

Two properties of the KDF drive the plugin's shape:

  • A bcrypt_hash() cannot be split across kernel invocations, so kernel_loops is pinned at 1 and the derivation runs inside _loop.
  • 48 bytes of output (32 byte AES key + 16 byte IV) means stride = 2, so two independent blocks each run the full round loop. Real work is 2 x rounds x 129 Blowfish key expansions — twice what a naive reading of bcrypt_pbkdf suggests.

Reuse, and one thing that could not be reused

blowfish_encrypt(), c_pbox and c_sbox0..3 from inc_cipher_blowfish.cl are used unchanged.

blowfish_set_key_salt() could not be: it hardcodes a 4 word salt, indexing salt_buf[(i & 2) + 0] with S-box loops that reference salt_buf[0..3] literally. bcrypt-pbkdf feeds a 64 byte sha2salt that the data stream cycles over 16 words at a time. A 16-word expandstate is therefore written in m37500-pure.cl.

I kept it local to this kernel rather than generalising the shared helper, since changing inc_cipher_blowfish.cl would affect -m 3200 and belongs in its own PR if it is wanted at all.

Hash format

john's $sshng$ encoding, so ssh2john.py works unmodified:

$sshng$<cipher>$16$<salt>$<datalen>$<data>$<rounds>$<ctoffset>

Eight tokens against six for the legacy PEM form, so the parsers stay separate rather than -m 22921 growing a second layout. Cipher id 6 is aes256-ctr and 2 is aes256-cbc; both occur in practice and both are handled.

Verification decrypts the first ciphertext block and compares the two check integers OpenSSH writes equal before encrypting — the same criterion john and -m 22921 use, at a 2^-32 false positive rate per guess. If a stricter check is preferred I can add a second stage parsing the decrypted key type string; say the word.

Performance

RTX 4050 Laptop, CUDA 13.3:

Speed.#01........:      592 H/s (810.43ms) @ Accel:1 Loops:1 Thr:24 Vec:1

Scaling -m 3200 (36,994 H/s at cost 5, 65 expansions) by the 4128 expansions this mode performs at the default 16 rounds predicts 583 H/s, so the measured figure lands within 1.5% of the cost model.

Thr:24 is the same occupancy ceiling bcrypt hits: Blowfish needs 4 KB of S-boxes per work item, which caps residency per SM regardless of core count. This mode inherits that exactly, so OPTS_TYPE_DYNAMIC_SHARED is handled the way -m 3200 handles it. The trade-off is inherent to the primitive — there is no arrangement that avoids the 4 KB.

For context, the same key on 16 CPU cores with john runs at 86.5 c/s, so this is roughly a 6.8x speedup. Real and worth having, but bcrypt-pbkdf does not collapse on a GPU and I would not want the mode to imply otherwise.

Testing

Keys from ssh-keygen at -a 8, 16 and 64, covering ed25519, RSA-2048 and ECDSA-256 across both ciphers:

  • all recover; a wrong password recovers none
  • every cracked line re-encodes byte identical to its input, so the potfile and --show round-trip
  • self-test passes with the included example hash
  • compiles with no warnings under -W -Wall -std=gnu99

The derived key was checked against an independent CPU implementation of bcrypt_pbkdf before the kernel was trusted, rather than inferring correctness from a successful crack.

Test module

tools/test_modules/m37500.pm implements bcrypt_pbkdf in pure Perl. No CPAN module provides it, and Crypt::Eksblowfish cannot substitute: its cost loop runs ExpandKey(key) before ExpandKey(salt) where bcrypt_pbkdf runs salt before key, and it fixes the salt at 16 bytes where this needs 64. The Blowfish tables in it are generated from OpenCL/inc_cipher_blowfish.cl so the two cannot drift apart.

It was cross-checked against the kernel in both directions: hashcat cracks Perl-generated hashes for both ciphers, and the Perl module verifies every key the GPU cracked.

Scope

One problem, one PR. I have a separate PR fixing an unrelated -m 24420 parser bug; the two do not overlap. The mode number 37500 is unused on master — happy to renumber if you have a different slot in mind.

ssh-keygen has written the openssh-key-v1 container with bcrypt-pbkdf
key derivation by default since OpenSSH 7.8 (2018). The existing
-m 22911..22951 handle only legacy PEM keys with the MD5-based KDF, so
an SSH private key generated with default options today cannot be
attacked with hashcat at all. This adds a kernel for the current
format.

Algorithm, per OpenBSD bcrypt_pbkdf.c:

  sha2pass = SHA512 (password)
  for each 32 byte output block:
      sha2salt = SHA512 (salt || BE32 (block))
      out = bcrypt_hash (sha2pass, sha2salt)
      repeat rounds-1 times:
          sha2salt = SHA512 (previous bcrypt_hash output)
          out ^= bcrypt_hash (sha2pass, sha2salt)
      scatter out into the key at stride

Two properties of the KDF drive the plugin's shape:

- A bcrypt_hash() cannot be split across kernel invocations, so
  kernel_loops is pinned at 1 and the derivation runs inside _loop.
- 48 bytes of output (32 byte AES key + 16 byte IV) means stride 2, so
  two independent blocks each run the full round loop. The real work is
  2 x rounds x 129 Blowfish key expansions, twice what a naive reading
  of bcrypt_pbkdf suggests.

inc_cipher_blowfish.cl's blowfish_set_key_salt() could not be reused:
it hardcodes a 4 word salt, indexing salt_buf[(i & 2) + 0] with S-box
loops that reference salt_buf[0..3] literally, whereas bcrypt-pbkdf
feeds a 64 byte sha2salt that the data stream cycles over 16 words at a
time. A 16-word expandstate is therefore written in the kernel. Its
blowfish_encrypt() core, c_pbox and c_sbox0..3 are reused unchanged.

Hash line is john's $sshng$ encoding, so the existing ssh2john.py
extractor works without modification:

  $sshng$<cipher>$16$<salt>$<datalen>$<data>$<rounds>$<ctoffset>

Eight tokens against six for the legacy PEM form, so the two parsers
stay separate rather than -m 22921 growing a second layout. Cipher id 6
is aes256-ctr and 2 is aes256-cbc; both occur and both are handled.

Verification decrypts the first ciphertext block and compares the two
check integers OpenSSH writes equal before encrypting, the same
criterion john and -m 22921 use, at a 2^-32 false positive rate per
guess.

Performance on an RTX 4050 Laptop, CUDA 13.3:

  Speed.hashcat#1........:      592 H/s (810.43ms) @ Accel:1 Loops:1 Thr:24 Vec:1

Scaling -m 3200 (36,994 H/s at cost 5, 65 expansions) by the 4128
expansions this mode performs at the default 16 rounds predicts 583 H/s,
so the measured figure is within 1.5% of the cost model. Thr:24 is the
same occupancy ceiling bcrypt hits: Blowfish needs 4 KB of S-boxes per
work item, which caps residency regardless of core count. This mode
inherits that exactly, so OPTS_TYPE_DYNAMIC_SHARED is handled the way
-m 3200 handles it.

Tested with keys from ssh-keygen at -a 8, 16 and 64, covering ed25519,
RSA-2048 and ECDSA-256 across both ciphers. All recover, a wrong
password recovers none, and every cracked line re-encodes byte
identical to its input. The derived key was checked against an
independent CPU implementation of bcrypt_pbkdf before the kernel was
trusted.

tools/test_modules/m37500.pm implements bcrypt_pbkdf in pure Perl.
No CPAN module provides it, and Crypt::Eksblowfish cannot substitute:
its cost loop runs ExpandKey(key) before ExpandKey(salt) where
bcrypt_pbkdf runs salt before key, and it fixes the salt at 16 bytes.
The Blowfish tables in it are generated from
OpenCL/inc_cipher_blowfish.cl so the two cannot drift apart.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant