From b315afd69a138d4d9d8bd44a9524bf78a37cf479 Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sun, 24 May 2026 16:48:48 +0200 Subject: [PATCH] ssh: limit bcrypt KDF rounds in OpenSSH private key decryption passphraseProtectedOpenSSHKey decodes the bcrypt round count from the key file and passes it directly to bcrypt_pbkdf.Key, whose running time is linear in that count. Files specifying very large round values cause the decryption to consume excessive CPU time: a uint32 maximum is several months of work on commodity hardware. Cap the accepted round count at 2048 (128x the default of 16, a few seconds of CPU on a modern core). OpenSSH itself does not impose an upper bound, but accepting arbitrary values turns key loading into a resource-exhaustion footgun for any code that processes files supplied by end users. This issue was found during a security audit by NCC Group Cryptography Services, sponsored by Teleport, and was assessed and is being fixed as a non-security bug. Change-Id: I01112bdf1b484ae4fab5dc8841d1a272f112df74 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/782422 Reviewed-by: David Chase Reviewed-by: Filippo Valsorda Reviewed-by: Junyang Shao LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com --- ssh/keys.go | 11 +++++++++++ ssh/keys_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/ssh/keys.go b/ssh/keys.go index 78f0963..5deada7 100644 --- a/ssh/keys.go +++ b/ssh/keys.go @@ -1466,6 +1466,17 @@ func passphraseProtectedOpenSSHKey(passphrase []byte) openSSHDecryptFunc { return nil, err } + // OpenSSH does not impose an upper bound on the bcrypt round count + // stored in the key file, but bcrypt_pbkdf cost is linear in rounds: + // the default is 16, ssh-keygen lets users pick anything up to + // INT_MAX. Cap at 2048 (128x the default, a few seconds of CPU) so + // that an oversized value in the file cannot tie up the caller for + // months. + const maxRounds = 1 << 11 + if opts.Rounds > maxRounds { + return nil, fmt.Errorf("ssh: bcrypt KDF rounds %d exceed maximum %d", opts.Rounds, maxRounds) + } + k, err := bcrypt_pbkdf.Key(passphrase, []byte(opts.Salt), int(opts.Rounds), 32+16) if err != nil { return nil, err diff --git a/ssh/keys_test.go b/ssh/keys_test.go index c973c8c..9bd90a0 100644 --- a/ssh/keys_test.go +++ b/ssh/keys_test.go @@ -506,6 +506,38 @@ func TestParseEncryptedPrivateKeysWithIncorrectPassphrase(t *testing.T) { } } +func TestParseEncryptedPrivateKeyExcessiveBcryptRounds(t *testing.T) { + // Craft a minimal openssh-key-v1 blob whose KdfOpts declares a bcrypt + // round count above the accepted maximum. The check must reject the file + // before bcrypt_pbkdf is invoked, so the rest of the blob (public key, + // encrypted body) can be empty. + kdfOpts := Marshal(struct { + Salt []byte + Rounds uint32 + }{ + Salt: []byte("salt-not-used"), + Rounds: (1 << 11) + 1, + }) + header := Marshal(openSSHEncryptedPrivateKey{ + CipherName: "aes256-ctr", + KdfName: "bcrypt", + KdfOpts: string(kdfOpts), + NumKeys: 1, + }) + pemBytes := pem.EncodeToMemory(&pem.Block{ + Type: "OPENSSH PRIVATE KEY", + Bytes: append([]byte(privateKeyAuthMagic), header...), + }) + + _, err := ParseRawPrivateKeyWithPassphrase(pemBytes, []byte("password")) + if err == nil { + t.Fatal("expected error for excessive bcrypt rounds, got nil") + } + if !strings.Contains(err.Error(), "bcrypt KDF rounds") { + t.Errorf("got error %q, want substring %q", err.Error(), "bcrypt KDF rounds") + } +} + func TestParseDSA(t *testing.T) { // We actually exercise the ParsePrivateKey codepath here, as opposed to // using the ParseRawPrivateKey+NewSignerFromKey path that testdata_test.go