Skip to content

Commit e3e9abf

Browse files
authored
🐛 detect OpenSSH 9.9 and force Key Exchange Algorithms (#5568)
OpenSSH 9.9 introduces hybrid post-quantum algorithms, and when users try to connect via SSH, they get a key signature compatibility issue, most likely due to the go package https://pkg.go.dev/golang.org/x/crypto/ssh not supporting it yet or misinterpreting it. Note that, there is also a reported bug that recommends upgrading to OpenSSH 9.9p2 https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1103392 We recommend users to upgrade their servers to that version. Regardless, this change introduces an extra step during an SSH connection that detects if the server's advertised key exchange methods include unsupported hybrid ones, and if so, we will force the key exchange algorithm to a compatible one. --------- Signed-off-by: Salim Afiune Maya <afiune@mondoo.com>
1 parent 1b3b0a7 commit e3e9abf

2 files changed

Lines changed: 113 additions & 16 deletions

File tree

providers/os/connection/ssh/ssh.go

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ package ssh
66
import (
77
"bytes"
88
"context"
9-
"errors"
109
"io"
1110
"net"
1211
"os"
@@ -16,6 +15,7 @@ import (
1615
"time"
1716

1817
awsconf "github.com/aws/aws-sdk-go-v2/config"
18+
"github.com/cockroachdb/errors"
1919
"github.com/kevinburke/ssh_config"
2020
"github.com/mitchellh/go-homedir"
2121
rawsftp "github.com/pkg/sftp"
@@ -33,7 +33,6 @@ import (
3333
"go.mondoo.com/cnquery/v11/providers/os/connection/ssh/signers"
3434
"go.mondoo.com/cnquery/v11/utils/multierr"
3535
"golang.org/x/crypto/ssh"
36-
"golang.org/x/crypto/ssh/agent"
3736
"golang.org/x/crypto/ssh/knownhosts"
3837
)
3938

@@ -493,27 +492,60 @@ func establishClientConnection(pCfg *inventory.Config, hostKeyCallback ssh.HostK
493492
}
494493
}
495494

496-
log.Debug().Int("methods", len(authMethods)).Str("user", user).Msg("connect to remote ssh")
497-
conn, err := ssh.Dial("tcp", pCfg.Host+":"+strconv.Itoa(int(pCfg.Port)), &ssh.ClientConfig{
495+
addr := pCfg.Host + ":" + strconv.Itoa(int(pCfg.Port))
496+
sshClientConfig := &ssh.ClientConfig{
498497
User: user,
499498
Auth: authMethods,
500499
HostKeyCallback: hostKeyCallback,
501-
})
500+
}
501+
502+
supportsHybrid, err := serverSupportsHybridKEX(addr)
503+
if err == nil && supportsHybrid {
504+
// force the Key Exchange Algorithm to a compatible one
505+
sshClientConfig.Config = ssh.Config{
506+
KeyExchanges: []string{
507+
"curve25519-sha256",
508+
"curve25519-sha256@libssh.org",
509+
"ecdh-sha2-nistp256",
510+
"diffie-hellman-group14-sha1",
511+
},
512+
}
513+
}
514+
515+
log.Debug().
516+
Int("methods", len(authMethods)).
517+
Str("user", user).
518+
Bool("hybrid_key_exchange", supportsHybrid).
519+
Msg("connect to remote ssh")
520+
conn, err := ssh.Dial("tcp", addr, sshClientConfig)
502521
return conn, closer, err
503522
}
504523

505-
// hasAgentLoadedKey returns if the ssh agent has loaded the key file
506-
// This may not be 100% accurate. The key can be stored in multiple locations with the
507-
// same fingerprint. We cannot determine the fingerprint without decoding the encrypted
508-
// key, `ssh-keygen -lf /Users/chartmann/.ssh/id_rsa` seems to use the ssh agent to
509-
// determine the fingerprint without prompting for the password
510-
func hasAgentLoadedKey(list []*agent.Key, filename string) bool {
511-
for i := range list {
512-
if list[i].Comment == filename {
513-
return true
514-
}
524+
// Detects if the remote server offers hybrid PQ KEX algorithms
525+
func serverSupportsHybridKEX(addr string) (bool, error) {
526+
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
527+
if err != nil {
528+
log.Debug().Err(err).Msg("fail to verify KEX algorithms")
529+
return false, err
515530
}
516-
return false
531+
defer conn.Close()
532+
533+
// Read the server's version string
534+
var buf [256]byte
535+
n, err := conn.Read(buf[:])
536+
if err != nil {
537+
return false, errors.Wrap(err, "failed to read banner")
538+
}
539+
banner := string(buf[:n])
540+
541+
// We'll stop here. Full KEXINIT parsing requires building a custom packet reader.
542+
// For now, assume OpenSSH 9.9+ includes PQ KEX unless we detect otherwise.
543+
if strings.Contains(banner, "OpenSSH_9.9") {
544+
// Naively assume 9.9+ offers hybrid KEX
545+
return true, nil
546+
}
547+
548+
return false, nil
517549
}
518550

519551
// prepareConnection determines the auth methods required for a ssh connection and also prepares any other

providers/os/connection/ssh/ssh_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
package ssh
55

66
import (
7+
"net"
78
"testing"
89

910
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
1012
"go.mondoo.com/cnquery/v11/providers-sdk/v1/inventory"
1113
"go.mondoo.com/cnquery/v11/providers/os/connection/shared"
1214
)
@@ -37,3 +39,66 @@ func TestSSHAuthError(t *testing.T) {
3739
// local testing without ssh agent
3840
err.Error() == "no authentication method defined")
3941
}
42+
43+
// helper to start a fake SSH server with a custom banner
44+
func startMockSSHServer(t *testing.T, banner string) (addr string, closeFn func()) {
45+
ln, err := net.Listen("tcp", "127.0.0.1:0")
46+
require.Nil(t, err)
47+
48+
go func() {
49+
conn, err := ln.Accept()
50+
if err != nil {
51+
return
52+
}
53+
defer conn.Close()
54+
// simulate SSH banner
55+
_, _ = conn.Write([]byte(banner + "\r\n"))
56+
}()
57+
58+
return ln.Addr().String(), func() { ln.Close() }
59+
}
60+
61+
func TestServerSupportsHybridKEX(t *testing.T) {
62+
tests := []struct {
63+
name string
64+
banner string
65+
expectHybrid bool
66+
}{
67+
{
68+
name: "OpenSSH 9.9 detected",
69+
banner: "SSH-2.0-OpenSSH_9.9",
70+
expectHybrid: true,
71+
},
72+
{
73+
name: "OpenSSH 9.7 (no hybrid)",
74+
banner: "SSH-2.0-OpenSSH_9.7",
75+
expectHybrid: false,
76+
},
77+
{
78+
name: "Non-OpenSSH server",
79+
banner: "SSH-2.0-CustomSSH_1.0",
80+
expectHybrid: false,
81+
},
82+
{
83+
name: "Malformed banner",
84+
banner: "garbage",
85+
expectHybrid: false,
86+
},
87+
}
88+
89+
for _, tt := range tests {
90+
t.Run(tt.name, func(t *testing.T) {
91+
addr, shutdown := startMockSSHServer(t, tt.banner)
92+
defer shutdown()
93+
94+
got, err := serverSupportsHybridKEX(addr)
95+
require.Nil(t, err)
96+
assert.Equal(t, tt.expectHybrid, got)
97+
})
98+
}
99+
}
100+
101+
func TestServerSupportsHybridKEX_ServerUnreachable(t *testing.T) {
102+
_, err := serverSupportsHybridKEX("127.0.0.1:9")
103+
require.NotNil(t, err)
104+
}

0 commit comments

Comments
 (0)