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 <drchase@google.com> Reviewed-by: Filippo Valsorda <filippo@golang.org> Reviewed-by: Junyang Shao <shaojunyang@google.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
This commit is contained in:
+11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user