Skip to content

Commit 6ead910

Browse files
committed
Harden the SSH key exchanfe process
This commit makes AsyncSSH more strict when accepting messages during the key exchange process. In particular, requesting or sending group information more than once during group exchange is no longer allowed and sending host key information more than once during a GSS exchange is no longer allowed. Thanks go to Fabian Bäumer and Marcus Brinkmann for identifying potential issues here.
1 parent 6d7d834 commit 6ead910

File tree

2 files changed

+61
-12
lines changed

2 files changed

+61
-12
lines changed

asyncssh/kex_dh.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2013-2024 by Ron Frederick <[email protected]> and others.
1+
# Copyright (c) 2013-2025 by Ron Frederick <[email protected]> and others.
22
#
33
# This program and the accompanying materials are made available under
44
# the terms of the Eclipse Public License v2.0 which accompanies this
@@ -343,6 +343,9 @@ def _process_request(self, pkttype: int, _pktid: int,
343343
if self._conn.is_client():
344344
raise ProtocolError('Unexpected kex request msg')
345345

346+
if self._p:
347+
raise ProtocolError('Kex DH group already requested')
348+
346349
self._gex_data = packet.get_remaining_payload()
347350

348351
if pkttype == MSG_KEX_DH_GEX_REQUEST_OLD:
@@ -377,6 +380,9 @@ def _process_group(self, _pkttype: int, _pktid: int,
377380
if self._conn.is_server():
378381
raise ProtocolError('Unexpected kex group msg')
379382

383+
if self._p:
384+
raise ProtocolError('Kex DH group already sent')
385+
380386
p = packet.get_mpint()
381387
g = packet.get_mpint()
382388
packet.check_end()
@@ -529,6 +535,7 @@ def __init__(self, alg: bytes, conn: 'SSHConnection',
529535

530536
self._gss = conn.get_gss_context()
531537
self._token: Optional[bytes] = None
538+
self._host_key_msg_ok = False
532539
self._host_key_data = b''
533540

534541
def _check_secure(self) -> None:
@@ -621,6 +628,8 @@ async def _process_continue(self, _pkttype: int, _pktid: int,
621628
if self._conn.is_client() and self._gss.complete:
622629
raise ProtocolError('Unexpected kexgss continue msg')
623630

631+
self._host_key_msg_ok = False
632+
624633
await self._process_token(token)
625634

626635
if self._conn.is_server() and self._gss.complete:
@@ -636,6 +645,8 @@ async def _process_complete(self, _pkttype: int, _pktid: int,
636645
if self._conn.is_server():
637646
raise ProtocolError('Unexpected kexgss complete msg')
638647

648+
self._host_key_msg_ok = False
649+
639650
self._parse_server_key(packet)
640651
mic = packet.get_string()
641652
token_present = packet.get_boolean()
@@ -662,6 +673,10 @@ def _process_hostkey(self, _pkttype: int, _pktid: int,
662673
packet: SSHPacket) -> None:
663674
"""Process a GSS hostkey message"""
664675

676+
if not self._host_key_msg_ok:
677+
raise ProtocolError('Unexpected kexgss hostkey msg')
678+
679+
self._host_key_msg_ok = False
665680
self._host_key_data = packet.get_string()
666681
packet.check_end()
667682

@@ -685,6 +700,7 @@ async def start(self) -> None:
685700
"""Start GSS key exchange"""
686701

687702
if self._conn.is_client():
703+
self._host_key_msg_ok = True
688704
await self._process_token()
689705
await super().start()
690706

tests/test_kex.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2015-2020 by Ron Frederick <[email protected]> and others.
1+
# Copyright (c) 2015-2025 by Ron Frederick <[email protected]> and others.
22
#
33
# This program and the accompanying materials are made available under
44
# the terms of the Eclipse Public License v2.0 which accompanies this
@@ -35,8 +35,8 @@
3535
from asyncssh.kex_dh import MSG_KEX_DH_GEX_REQUEST, MSG_KEX_DH_GEX_GROUP
3636
from asyncssh.kex_dh import MSG_KEX_DH_GEX_INIT, MSG_KEX_DH_GEX_REPLY, _KexDHGex
3737
from asyncssh.kex_dh import MSG_KEX_ECDH_INIT, MSG_KEX_ECDH_REPLY
38-
from asyncssh.kex_dh import MSG_KEXGSS_INIT, MSG_KEXGSS_COMPLETE
39-
from asyncssh.kex_dh import MSG_KEXGSS_ERROR
38+
from asyncssh.kex_dh import MSG_KEXGSS_INIT, MSG_KEXGSS_HOSTKEY
39+
from asyncssh.kex_dh import MSG_KEXGSS_COMPLETE, MSG_KEXGSS_ERROR
4040
from asyncssh.kex_rsa import MSG_KEXRSA_PUBKEY, MSG_KEXRSA_SECRET
4141
from asyncssh.kex_rsa import MSG_KEXRSA_DONE
4242
from asyncssh.gss import GSSClient, GSSServer
@@ -51,12 +51,13 @@
5151
class _KexConnectionStub(ConnectionStub):
5252
"""Connection stub class to test key exchange"""
5353

54-
def __init__(self, alg, gss, peer, server=False):
54+
def __init__(self, alg, gss, duplicate=0, peer=None, server=False):
5555
super().__init__(peer, server)
5656

5757
self._gss = gss
5858
self._key_waiter = asyncio.Future()
5959

60+
self._duplicate = duplicate
6061
self._kex = get_kex(self, alg)
6162

6263
async def start(self):
@@ -104,6 +105,14 @@ def get_gss_context(self):
104105

105106
return self._gss
106107

108+
def send_packet(self, pkttype, *args, **kwargs):
109+
"""Duplicate sending packets of a specific type"""
110+
111+
super().send_packet(pkttype, *args)
112+
113+
if pkttype == self._duplicate:
114+
super().send_packet(pkttype, *args, **kwargs)
115+
107116
async def simulate_dh_init(self, e):
108117
"""Simulate receiving a DH init packet"""
109118

@@ -176,21 +185,21 @@ class _KexClientStub(_KexConnectionStub):
176185
"""Stub class for client connection"""
177186

178187
@classmethod
179-
def make_pair(cls, alg, gss_host=None):
188+
def make_pair(cls, alg, gss_host=None, duplicate=0):
180189
"""Make a client and server connection pair to test key exchange"""
181190

182-
client_conn = cls(alg, gss_host)
191+
client_conn = cls(alg, gss_host, duplicate)
183192
return client_conn, client_conn.get_peer()
184193

185-
def __init__(self, alg, gss_host):
186-
server_conn = _KexServerStub(alg, gss_host, self)
194+
def __init__(self, alg, gss_host, duplicate):
195+
server_conn = _KexServerStub(alg, gss_host, duplicate, peer=self)
187196

188197
if gss_host:
189198
gss = GSSClient(gss_host, None, 'delegate' in gss_host)
190199
else:
191200
gss = None
192201

193-
super().__init__(alg, gss, server_conn)
202+
super().__init__(alg, gss, duplicate, peer=server_conn)
194203

195204
def connection_lost(self, exc):
196205
"""Handle the closing of a connection"""
@@ -211,9 +220,9 @@ def validate_server_host_key(self, host_key_data):
211220
class _KexServerStub(_KexConnectionStub):
212221
"""Stub class for server connection"""
213222

214-
def __init__(self, alg, gss_host, peer):
223+
def __init__(self, alg, gss_host, duplicate, peer):
215224
gss = GSSServer(gss_host, None) if gss_host else None
216-
super().__init__(alg, gss, peer, True)
225+
super().__init__(alg, gss, duplicate, peer, True)
217226

218227
if gss_host and 'no_host_key' in gss_host:
219228
self._server_host_key = None
@@ -381,6 +390,21 @@ async def test_dh_gex_errors(self):
381390
client_conn.close()
382391
server_conn.close()
383392

393+
@asynctest
394+
async def test_dh_gex_multiple_messages(self):
395+
"""Unit test duplicate messages in DH group exchange"""
396+
397+
for pkttype in (MSG_KEX_DH_GEX_REQUEST, MSG_KEX_DH_GEX_GROUP):
398+
client_conn, server_conn = _KexClientStub.make_pair(
399+
b'diffie-hellman-group-exchange-sha1', duplicate=pkttype)
400+
401+
with self.assertRaises(asyncssh.ProtocolError):
402+
await client_conn.start()
403+
await client_conn.get_key()
404+
405+
client_conn.close()
406+
server_conn.close()
407+
384408
@unittest.skipUnless(gss_available, 'GSS not available')
385409
@asynctest
386410
async def test_gss_errors(self):
@@ -393,6 +417,15 @@ async def test_gss_errors(self):
393417
with self.assertRaises(asyncssh.ProtocolError):
394418
await client_conn.process_packet(Byte(MSG_KEXGSS_INIT))
395419

420+
with self.subTest('Host key sent to server'):
421+
with self.assertRaises(asyncssh.ProtocolError):
422+
await server_conn.process_packet(Byte(MSG_KEXGSS_HOSTKEY))
423+
424+
with self.subTest('Host key sent twice to client'):
425+
with self.assertRaises(asyncssh.ProtocolError):
426+
await client_conn.process_packet(Byte(MSG_KEXGSS_HOSTKEY))
427+
await client_conn.process_packet(Byte(MSG_KEXGSS_HOSTKEY))
428+
396429
with self.subTest('Complete sent to server'):
397430
with self.assertRaises(asyncssh.ProtocolError):
398431
await server_conn.process_packet(Byte(MSG_KEXGSS_COMPLETE))

0 commit comments

Comments
 (0)