summaryrefslogtreecommitdiffhomepage
path: root/paramiko/transport.py
diff options
context:
space:
mode:
Diffstat (limited to 'paramiko/transport.py')
-rw-r--r--paramiko/transport.py102
1 files changed, 94 insertions, 8 deletions
diff --git a/paramiko/transport.py b/paramiko/transport.py
index 14a26333..83019170 100644
--- a/paramiko/transport.py
+++ b/paramiko/transport.py
@@ -107,11 +107,12 @@ from paramiko.ecdsakey import ECDSAKey
from paramiko.server import ServerInterface
from paramiko.sftp_client import SFTPClient
from paramiko.ssh_exception import (
- SSHException,
BadAuthenticationType,
ChannelException,
IncompatiblePeer,
+ MessageOrderError,
ProxyCommandFailure,
+ SSHException,
)
from paramiko.util import (
ClosingContextManager,
@@ -334,6 +335,8 @@ class Transport(threading.Thread, ClosingContextManager):
gss_deleg_creds=True,
disabled_algorithms=None,
server_sig_algs=True,
+ strict_kex=True,
+ packetizer_class=None,
):
"""
Create a new SSH session over an existing socket, or socket-like
@@ -400,6 +403,13 @@ class Transport(threading.Thread, ClosingContextManager):
Whether to send an extra message to compatible clients, in server
mode, with a list of supported pubkey algorithms. Default:
``True``.
+ :param bool strict_kex:
+ Whether to advertise (and implement, if client also advertises
+ support for) a "strict kex" mode for safer handshaking. Default:
+ ``True``.
+ :param packetizer_class:
+ Which class to use for instantiating the internal packet handler.
+ Default: ``None`` (i.e.: use `Packetizer` as normal).
.. versionchanged:: 1.15
Added the ``default_window_size`` and ``default_max_packet_size``
@@ -410,10 +420,16 @@ class Transport(threading.Thread, ClosingContextManager):
Added the ``disabled_algorithms`` kwarg.
.. versionchanged:: 2.9
Added the ``server_sig_algs`` kwarg.
+ .. versionchanged:: 3.4
+ Added the ``strict_kex`` kwarg.
+ .. versionchanged:: 3.4
+ Added the ``packetizer_class`` kwarg.
"""
self.active = False
self.hostname = None
self.server_extensions = {}
+ self.advertise_strict_kex = strict_kex
+ self.agreed_on_strict_kex = False
# TODO: these two overrides on sock's type should go away sometime, too
# many ways to do it!
@@ -457,7 +473,7 @@ class Transport(threading.Thread, ClosingContextManager):
self.sock.settimeout(self._active_check_timeout)
# negotiated crypto parameters
- self.packetizer = Packetizer(sock)
+ self.packetizer = (packetizer_class or Packetizer)(sock)
self.local_version = "SSH-" + self._PROTO_ID + "-" + self._CLIENT_ID
self.remote_version = ""
self.local_cipher = self.remote_cipher = ""
@@ -2086,6 +2102,20 @@ class Transport(threading.Thread, ClosingContextManager):
# be empty.)
return reply
+ def _enforce_strict_kex(self, ptype):
+ """
+ Conditionally raise `MessageOrderError` during strict initial kex.
+
+ This method should only be called inside code that handles non-KEXINIT
+ messages; it does not interrogate ``ptype`` besides using it to log
+ more accurately.
+ """
+ if self.agreed_on_strict_kex and not self.initial_kex_done:
+ name = MSG_NAMES.get(ptype, f"msg {ptype}")
+ raise MessageOrderError(
+ f"In strict-kex mode, but was sent {name!r}!"
+ )
+
def run(self):
# (use the exposed "run" method, because if we specify a thread target
# of a private method, threading.Thread will keep a reference to it
@@ -2130,16 +2160,21 @@ class Transport(threading.Thread, ClosingContextManager):
except NeedRekeyException:
continue
if ptype == MSG_IGNORE:
+ self._enforce_strict_kex(ptype)
continue
elif ptype == MSG_DISCONNECT:
self._parse_disconnect(m)
break
elif ptype == MSG_DEBUG:
+ self._enforce_strict_kex(ptype)
self._parse_debug(m)
continue
if len(self._expected_packet) > 0:
if ptype not in self._expected_packet:
- raise SSHException(
+ exc_class = SSHException
+ if self.agreed_on_strict_kex:
+ exc_class = MessageOrderError
+ raise exc_class(
"Expecting packet from {!r}, got {:d}".format(
self._expected_packet, ptype
)
@@ -2363,12 +2398,18 @@ class Transport(threading.Thread, ClosingContextManager):
)
else:
available_server_keys = self.preferred_keys
- # Signal support for MSG_EXT_INFO.
+ # Signal support for MSG_EXT_INFO so server will send it to us.
# NOTE: doing this here handily means we don't even consider this
# value when agreeing on real kex algo to use (which is a common
# pitfall when adding this apparently).
kex_algos.append("ext-info-c")
+ # Similar to ext-info, but used in both server modes, so done outside
+ # of above if/else.
+ if self.advertise_strict_kex:
+ which = "s" if self.server_mode else "c"
+ kex_algos.append(f"kex-strict-{which}-v00@openssh.com")
+
m = Message()
m.add_byte(cMSG_KEXINIT)
m.add_bytes(os.urandom(16))
@@ -2409,7 +2450,8 @@ class Transport(threading.Thread, ClosingContextManager):
def _get_latest_kex_init(self):
return self._really_parse_kex_init(
- Message(self._latest_kex_init), ignore_first_byte=True
+ Message(self._latest_kex_init),
+ ignore_first_byte=True,
)
def _parse_kex_init(self, m):
@@ -2448,11 +2490,39 @@ class Transport(threading.Thread, ClosingContextManager):
self._log(DEBUG, "kex follows: {}".format(kex_follows))
self._log(DEBUG, "=== Key exchange agreements ===")
- # Strip out ext-info "kex algo"
+ # Record, and strip out, ext-info and/or strict-kex non-algorithms
self._remote_ext_info = None
+ self._remote_strict_kex = None
+ to_pop = []
for i, algo in enumerate(kex_algo_list):
if algo.startswith("ext-info-"):
- self._remote_ext_info = kex_algo_list.pop(i)
+ self._remote_ext_info = algo
+ to_pop.insert(0, i)
+ elif algo.startswith("kex-strict-"):
+ # NOTE: this is what we are expecting from the /remote/ end.
+ which = "c" if self.server_mode else "s"
+ expected = f"kex-strict-{which}-v00@openssh.com"
+ # Set strict mode if agreed.
+ self.agreed_on_strict_kex = (
+ algo == expected and self.advertise_strict_kex
+ )
+ self._log(
+ DEBUG, f"Strict kex mode: {self.agreed_on_strict_kex}"
+ )
+ to_pop.insert(0, i)
+ for i in to_pop:
+ kex_algo_list.pop(i)
+
+ # CVE mitigation: expect zeroed-out seqno anytime we are performing kex
+ # init phase, if strict mode was negotiated.
+ if (
+ self.agreed_on_strict_kex
+ and not self.initial_kex_done
+ and m.seqno != 0
+ ):
+ raise MessageOrderError(
+ "In strict-kex mode, but KEXINIT was not the first packet!"
+ )
# as a server, we pick the first item in the client's list that we
# support.
@@ -2653,6 +2723,13 @@ class Transport(threading.Thread, ClosingContextManager):
):
self._log(DEBUG, "Switching on inbound compression ...")
self.packetizer.set_inbound_compressor(compress_in())
+ # Reset inbound sequence number if strict mode.
+ if self.agreed_on_strict_kex:
+ self._log(
+ DEBUG,
+ "Resetting inbound seqno after NEWKEYS due to strict mode",
+ )
+ self.packetizer.reset_seqno_in()
def _activate_outbound(self):
"""switch on newly negotiated encryption parameters for
@@ -2660,6 +2737,13 @@ class Transport(threading.Thread, ClosingContextManager):
m = Message()
m.add_byte(cMSG_NEWKEYS)
self._send_message(m)
+ # Reset outbound sequence number if strict mode.
+ if self.agreed_on_strict_kex:
+ self._log(
+ DEBUG,
+ "Resetting outbound seqno after NEWKEYS due to strict mode",
+ )
+ self.packetizer.reset_seqno_out()
block_size = self._cipher_info[self.local_cipher]["block-size"]
if self.server_mode:
IV_out = self._compute_key("B", block_size)
@@ -2750,7 +2834,9 @@ class Transport(threading.Thread, ClosingContextManager):
self.auth_handler = AuthHandler(self)
if not self.initial_kex_done:
# this was the first key exchange
- self.initial_kex_done = True
+ # (also signal to packetizer as it sometimes wants to know this
+ # status as well, eg when seqnos rollover)
+ self.initial_kex_done = self.packetizer._initial_kex_done = True
# send an event?
if self.completion_event is not None:
self.completion_event.set()