Add -m 37500, OpenSSH Private Keys (bcrypt-pbkdf) - #4767
Open
bishwabikash wants to merge 1 commit into
Open
Conversation
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.
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
ssh-keygenhas written theopenssh-key-v1container with bcrypt-pbkdf key derivation by default since OpenSSH 7.8 (2018). The existing-m 22911..22951handle 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-keygenactually produces has had no kernel. This adds one.Algorithm
Per OpenBSD
bcrypt_pbkdf.c:Two properties of the KDF drive the plugin's shape:
bcrypt_hash()cannot be split across kernel invocations, sokernel_loopsis pinned at 1 and the derivation runs inside_loop.stride = 2, so two independent blocks each run the full round loop. Real work is2 x rounds x 129Blowfish key expansions — twice what a naive reading ofbcrypt_pbkdfsuggests.Reuse, and one thing that could not be reused
blowfish_encrypt(),c_pboxandc_sbox0..3frominc_cipher_blowfish.clare used unchanged.blowfish_set_key_salt()could not be: it hardcodes a 4 word salt, indexingsalt_buf[(i & 2) + 0]with S-box loops that referencesalt_buf[0..3]literally. bcrypt-pbkdf feeds a 64 bytesha2saltthat the data stream cycles over 16 words at a time. A 16-wordexpandstateis therefore written inm37500-pure.cl.I kept it local to this kernel rather than generalising the shared helper, since changing
inc_cipher_blowfish.clwould affect-m 3200and belongs in its own PR if it is wanted at all.Hash format
john's
$sshng$encoding, sossh2john.pyworks unmodified:Eight tokens against six for the legacy PEM form, so the parsers stay separate rather than
-m 22921growing a second layout. Cipher id6is aes256-ctr and2is 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 22921use, 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:
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:24is 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, soOPTS_TYPE_DYNAMIC_SHAREDis handled the way-m 3200handles 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-keygenat-a 8,16and64, covering ed25519, RSA-2048 and ECDSA-256 across both ciphers:--showround-trip-W -Wall -std=gnu99The derived key was checked against an independent CPU implementation of
bcrypt_pbkdfbefore the kernel was trusted, rather than inferring correctness from a successful crack.Test module
tools/test_modules/m37500.pmimplements bcrypt_pbkdf in pure Perl. No CPAN module provides it, andCrypt::Eksblowfishcannot substitute: its cost loop runsExpandKey(key)beforeExpandKey(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 fromOpenCL/inc_cipher_blowfish.clso 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 24420parser 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.