diff --git a/backport-0001-CVE-2023-48795.patch b/backport-0001-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..20d3896f2c284f0fe9bc18d82cc9fc1655d39f48 --- /dev/null +++ b/backport-0001-CVE-2023-48795.patch @@ -0,0 +1,67 @@ +From be3ffc18cc466e0b0a877d716721353c12561bcc Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Fri, 15 Dec 2023 22:14:48 -0500 +Subject: [PATCH] Make ext-info faux-KexAlgorithm detection more robust + +Reference:https://github.com/paramiko/paramiko/commit/be3ffc18cc466e0b0a877d716721353c12561bcc +Conflict:The context of the changelog is adapted due to different versions + +--- + paramiko/transport.py | 5 +++-- + sites/www/changelog.rst | 3 +++ + tests/test_transport.py | 8 ++++++-- + 3 files changed, 12 insertions(+), 4 deletions(-) + +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 68cc195..fd26371 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -2429,8 +2429,9 @@ class Transport(threading.Thread, ClosingContextManager): + + # Strip out ext-info "kex algo" + self._remote_ext_info = None +- if kex_algo_list[-1].startswith("ext-info-"): +- self._remote_ext_info = kex_algo_list.pop() ++ for i, algo in enumerate(kex_algo_list): ++ if algo.startswith("ext-info-"): ++ self._remote_ext_info = kex_algo_list.pop(i) + + # as a server, we pick the first item in the client's list that we + # support. +diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst +index 29754bc..f180e77 100644 +--- a/sites/www/changelog.rst ++++ b/sites/www/changelog.rst +@@ -2,6 +2,9 @@ + Changelog + ========= + ++- :bug:`-` Tweak ``ext-info-(c|s)`` detection during KEXINIT protocol phase; ++ the original implementation made assumptions based on an OpenSSH ++ implementation detail. + - :release:`2.11.0 <2022-05-16>` + - :release:`2.10.5 <2022-05-16>` + - :release:`2.9.5 <2022-05-16>` +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 98a7d30..6bc0be8 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -1350,10 +1350,14 @@ class TestSHA2SignatureKeyExchange(unittest.TestCase): + + + class TestExtInfo(unittest.TestCase): +- def test_ext_info_handshake(self): ++ def test_ext_info_handshake_exposed_in_client_kexinit(self): + with server() as (tc, _): ++ # NOTE: this is latest KEXINIT /sent by us/ (Transport retains it) + kex = tc._get_latest_kex_init() +- assert kex["kex_algo_list"][-1] == "ext-info-c" ++ # flag in KexAlgorithms list ++ assert "ext-info-c" in kex["kex_algo_list"] ++ # data stored on Transport after hearing back from a compatible ++ # server (such as ourselves in server mode) + assert tc.server_extensions == { + "server-sig-algs": b"ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,rsa-sha2-512,rsa-sha2-256,ssh-rsa,ssh-dss" # noqa + } +-- +2.33.0 diff --git a/backport-0002-CVE-2023-48795.patch b/backport-0002-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..a02e8c183d6c50c46884e9abca84d65c97fbabd7 --- /dev/null +++ b/backport-0002-CVE-2023-48795.patch @@ -0,0 +1,171 @@ +From 773a174fb1e40e1d18dbe2625e16337ea401119e Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Fri, 15 Dec 2023 23:59:12 -0500 +Subject: [PATCH] Basic strict-kex-mode agreement mechanics work + +Reference:https://github.com/paramiko/paramiko/commit/773a174fb1e40e1d18dbe2625e16337ea401119e +Conflict:The comments are different. Therefore, the transport.py file is adapted + Due to different versions, some test cases do not exist. Therefore, context adaptation is required when new test cases are added + +--- + paramiko/transport.py | 38 +++++++++++++++++++++++++++++++++--- + tests/test_transport.py | 43 +++++++++++++++++++++++++++++++++++++++++ + 2 files changed, 78 insertions(+), 3 deletions(-) + +diff --git a/paramiko/transport.py b/paramiko/transport.py +index fd26371..2d6d581 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -329,6 +329,7 @@ class Transport(threading.Thread, ClosingContextManager): + gss_deleg_creds=True, + disabled_algorithms=None, + server_sig_algs=True, ++ strict_kex=True, + ): + """ + Create a new SSH session over an existing socket, or socket-like +@@ -395,6 +396,10 @@ 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``. + + .. versionchanged:: 1.15 + Added the ``default_window_size`` and ``default_max_packet_size`` +@@ -405,10 +410,14 @@ 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. + """ + self.active = False + self.hostname = None + self.server_extensions = {} ++ self.advertise_strict_kex = strict_kex ++ self.agreed_on_strict_kex = False + + if isinstance(sock, string_types): + # convert "host:port" into (host, port) +@@ -2342,12 +2351,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)) +@@ -2427,11 +2442,28 @@ 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) + + # as a server, we pick the first item in the client's list that we + # support. +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 6bc0be8..c8cd498 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -24,6 +24,7 @@ from __future__ import with_statement + + from binascii import hexlify + from contextlib import contextmanager ++import itertools + import select + import socket + import time +@@ -63,6 +64,7 @@ from paramiko.message import Message + + from .util import needs_builtin, _support, requires_sha1_signing, slow + from .loop import LoopSocket ++from pytest import skip, mark + + + LONG_BANNER = """\ +@@ -1463,3 +1465,44 @@ class TestSHA2SignaturePubkeys(unittest.TestCase): + ) as (tc, ts): + assert tc.is_authenticated() + assert tc._agreed_pubkey_algorithm == "rsa-sha2-256" ++ ++ ++class TestStrictKex: ++ def test_kex_algos_includes_kex_strict_c(self): ++ with server() as (tc, _): ++ kex = tc._get_latest_kex_init() ++ assert "kex-strict-c-v00@openssh.com" in kex["kex_algo_list"] ++ ++ @mark.parametrize( ++ "server_active,client_active", ++ itertools.product([True, False], repeat=2), ++ ) ++ def test_mode_agreement(self, server_active, client_active): ++ with server( ++ server_init=dict(strict_kex=server_active), ++ client_init=dict(strict_kex=client_active), ++ ) as (tc, ts): ++ if server_active and client_active: ++ assert tc.agreed_on_strict_kex is True ++ assert ts.agreed_on_strict_kex is True ++ else: ++ assert tc.agreed_on_strict_kex is False ++ assert ts.agreed_on_strict_kex is False ++ ++ def test_mode_advertised_by_default(self): ++ # NOTE: no explicit strict_kex overrides... ++ with server() as (tc, ts): ++ assert all( ++ ( ++ tc.advertise_strict_kex, ++ tc.agreed_on_strict_kex, ++ ts.advertise_strict_kex, ++ ts.agreed_on_strict_kex, ++ ) ++ ) ++ ++ def test_sequence_numbers_reset_on_newkeys(self): ++ skip() ++ ++ def test_error_raised_on_out_of_order_handshakes(self): ++ skip() +-- +2.33.0 + diff --git a/backport-0003-CVE-2023-48795.patch b/backport-0003-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..f3822d91cee4a5bf1d24b2f92f7a3db216b09876 --- /dev/null +++ b/backport-0003-CVE-2023-48795.patch @@ -0,0 +1,115 @@ +From f4dedacb9040d27d9844f51c81c28e0247d3e4a3 Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Sat, 16 Dec 2023 13:02:05 -0500 +Subject: [PATCH] Raise new exception type when unexpected messages appear + +Reference:https://github.com/paramiko/paramiko/commit/f4dedacb9040d27d9844f51c81c28e0247d3e4a3 +Conflict:The changlog file is adapted for different versions. The context of the test case import module is adapted. + +--- + paramiko/__init__.py | 1 + + paramiko/ssh_exception.py | 9 +++++++++ + paramiko/transport.py | 6 +++++- + tests/test_transport.py | 22 +++++++++++++++++++--- + 4 files changed, 34 insertions(+), 4 deletions(-) + +diff --git a/paramiko/__init__.py b/paramiko/__init__.py +index cbc240a..1bc91d0 100644 +--- a/paramiko/__init__.py ++++ b/paramiko/__init__.py +@@ -43,6 +43,7 @@ from paramiko.ssh_exception import ( + ConfigParseError, + CouldNotCanonicalize, + IncompatiblePeer, ++ MessageOrderError, + PasswordRequiredException, + ProxyCommandFailure, + SSHException, +diff --git a/paramiko/ssh_exception.py b/paramiko/ssh_exception.py +index 620ab25..8a1413b 100644 +--- a/paramiko/ssh_exception.py ++++ b/paramiko/ssh_exception.py +@@ -235,3 +235,12 @@ class ConfigParseError(SSHException): + """ + + pass ++ ++ ++class MessageOrderError(SSHException): ++ """ ++ Out-of-order protocol messages were received, violating "strict kex" mode. ++ .. versionadded:: 3.4 ++ """ ++ ++ pass +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 2d6d581..eb1bcd6 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -110,6 +110,7 @@ from paramiko.ssh_exception import ( + BadAuthenticationType, + ChannelException, + IncompatiblePeer, ++ MessageOrderError, + ProxyCommandFailure, + ) + from paramiko.util import retry_on_signal, ClosingContextManager, clamp_value +@@ -2129,7 +2130,10 @@ class Transport(threading.Thread, ClosingContextManager): + 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 + ) +diff --git a/tests/test_transport.py b/tests/test_transport.py +index c8cd498..19023eb 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -42,6 +42,7 @@ from paramiko import ( + SSHException, + AuthenticationException, + IncompatiblePeer, ++ MessageOrderError, + SecurityOptions, + ServerInterface, + Transport, +@@ -64,7 +65,7 @@ from paramiko.message import Message + + from .util import needs_builtin, _support, requires_sha1_signing, slow + from .loop import LoopSocket +-from pytest import skip, mark ++from pytest import skip, mark, raises + + + LONG_BANNER = """\ +@@ -1504,5 +1505,20 @@ class TestStrictKex: + def test_sequence_numbers_reset_on_newkeys(self): + skip() + +- def test_error_raised_on_out_of_order_handshakes(self): +- skip() ++ def test_MessageOrderError_raised_on_out_of_order_messages(self): ++ with raises(MessageOrderError): ++ with server() as (tc, _): ++ # A bit artificial as it's outside kexinit/handshake, but much ++ # easier to trigger and still in line with behavior under test ++ tc._expect_packet(MSG_KEXINIT) ++ tc.open_session() ++ ++ def test_SSHException_raised_on_out_of_order_messages_when_not_strict(self): ++ # This is kind of dumb (either situation is still fatal!) but whatever, ++ # may as well be strict with our new strict flag... ++ with raises(SSHException) as info: # would be true either way, but ++ with server(client_init=dict(strict_kex=False), ++ ) as (tc, _): ++ tc._expect_packet(MSG_KEXINIT) ++ tc.open_session() ++ assert info.type is SSHException # NOT MessageOrderError! +-- +2.33.0 + diff --git a/backport-0004-CVE-2023-48795.patch b/backport-0004-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..2c7d046eca584989473245a77030de0d948606f1 --- /dev/null +++ b/backport-0004-CVE-2023-48795.patch @@ -0,0 +1,190 @@ +From 75e311d3c0845a316b6e7b3fae2488d86ad5a270 Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Sat, 16 Dec 2023 16:17:58 -0500 +Subject: [PATCH] Enforce zero seqno on kexinit + +Reference:https://github.com/paramiko/paramiko/commit/75e311d3c0845a316b6e7b3fae2488d86ad5a270 +Conflict:Context adaptation exists in the changelog.rst file due to version inconsistency. + +--- + paramiko/transport.py | 18 ++++++++++-- + sites/www/changelog.rst | 3 ++ + tests/test_transport.py | 61 +++++++++++++++++++++++++++++++++++++---- + 3 files changed, 74 insertions(+), 8 deletions(-) + +diff --git a/paramiko/transport.py b/paramiko/transport.py +index eb1bcd6..9f976a2 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -331,6 +331,7 @@ class Transport(threading.Thread, ClosingContextManager): + 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 +@@ -401,6 +402,9 @@ class Transport(threading.Thread, ClosingContextManager): + 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`` +@@ -413,6 +417,8 @@ class Transport(threading.Thread, ClosingContextManager): + 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 +@@ -460,7 +466,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 = "" +@@ -2407,7 +2413,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): +@@ -2469,6 +2476,13 @@ class Transport(threading.Thread, ClosingContextManager): + 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 m.seqno != 0: ++ raise MessageOrderError( ++ f"Got nonzero seqno ({m.seqno}) during strict KEXINIT!" ++ ) ++ + # as a server, we pick the first item in the client's list that we + # support. + # as a client, we pick the first item in our list that the server +diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst +index f180e77..aeddee0 100644 +--- a/sites/www/changelog.rst ++++ b/sites/www/changelog.rst +@@ -2,6 +2,9 @@ + Changelog + ========= + ++- :feature:`-` `Transport` grew a new ``packetizer_class`` kwarg for overriding ++ the packet-handler class used internally. Mostly for testing, but advanced ++ users may find this useful when doing deep hacks. + - :bug:`-` Tweak ``ext-info-(c|s)`` detection during KEXINIT protocol phase; + the original implementation made assumptions based on an OpenSSH + implementation detail. +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 19023eb..de26231 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -1118,6 +1118,16 @@ class TransportTest(unittest.TestCase): + # Real fix's behavior + self._expect_unimplemented() + ++ def test_can_override_packetizer_used(self): ++ class MyPacketizer(Packetizer): ++ pass ++ ++ # control case ++ assert Transport(sock=LoopSocket()).packetizer.__class__ is Packetizer ++ # overridden case ++ tweaked = Transport(sock=LoopSocket(), packetizer_class=MyPacketizer) ++ assert tweaked.packetizer.__class__ is MyPacketizer ++ + + class AlgorithmDisablingTests(unittest.TestCase): + def test_preferred_lists_default_to_private_attribute_contents(self): +@@ -1467,6 +1477,19 @@ class TestSHA2SignaturePubkeys(unittest.TestCase): + assert tc.is_authenticated() + assert tc._agreed_pubkey_algorithm == "rsa-sha2-256" + ++class BadSeqPacketizer(Packetizer): ++ def read_message(self): ++ cmd, msg = super().read_message() ++ # Only mess w/ seqno if kexinit. ++ if cmd is MSG_KEXINIT: ++ # NOTE: this is /only/ the copy of the seqno which gets ++ # transmitted up from Packetizer; it's not modifying ++ # Packetizer's own internal seqno. For these tests, ++ # modifying the latter isn't required, and is also harder ++ # to do w/o triggering MAC mismatches. ++ msg.seqno = 17 # arbitrary nonzero int ++ return cmd, msg ++ + + class TestStrictKex: + def test_kex_algos_includes_kex_strict_c(self): +@@ -1502,9 +1525,6 @@ class TestStrictKex: + ) + ) + +- def test_sequence_numbers_reset_on_newkeys(self): +- skip() +- + def test_MessageOrderError_raised_on_out_of_order_messages(self): + with raises(MessageOrderError): + with server() as (tc, _): +@@ -1513,12 +1533,41 @@ class TestStrictKex: + tc._expect_packet(MSG_KEXINIT) + tc.open_session() + +- def test_SSHException_raised_on_out_of_order_messages_when_not_strict(self): ++ def test_SSHException_raised_on_out_of_order_messages_when_not_strict( ++ self, ++ ): + # This is kind of dumb (either situation is still fatal!) but whatever, + # may as well be strict with our new strict flag... + with raises(SSHException) as info: # would be true either way, but +- with server(client_init=dict(strict_kex=False), +- ) as (tc, _): ++ with server( ++ client_init=dict(strict_kex=False), ++ ) as (tc, _): + tc._expect_packet(MSG_KEXINIT) + tc.open_session() + assert info.type is SSHException # NOT MessageOrderError! ++ ++ def test_error_not_raised_when_kexinit_not_seq_0_but_unstrict(self): ++ with server( ++ client_init=dict( ++ # Disable strict kex ++ strict_kex=False, ++ # Give our clientside a packetizer that sets all kexinit ++ # Message objects to have .seqno==17, which would trigger the ++ # new logic if we'd forgotten to wrap it in strict-kex check ++ packetizer_class=BadSeqPacketizer, ++ ), ++ ): ++ pass # kexinit happens at connect... ++ ++ def test_MessageOrderError_raised_when_kexinit_not_seq_0_and_strict(self): ++ with raises(MessageOrderError): ++ with server( ++ # Give our clientside a packetizer that sets all kexinit ++ # Message objects to have .seqno==17, which should trigger the ++ # new logic (given we are NOT disabling strict-mode) ++ client_init=dict(packetizer_class=BadSeqPacketizer), ++ ): ++ pass # kexinit happens at connect... ++ ++ def test_sequence_numbers_reset_on_newkeys(self): ++ skip() +-- +2.33.0 diff --git a/backport-0005-CVE-2023-48795.patch b/backport-0005-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..9a4655d9fb3ba49a8b2a0bbf509c3b2e893b40ee --- /dev/null +++ b/backport-0005-CVE-2023-48795.patch @@ -0,0 +1,114 @@ +From fa46de7feeeb8a01dc471581a0258252ce4f2db6 Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Sat, 16 Dec 2023 17:12:42 -0500 +Subject: [PATCH] Reset sequence numbers on rekey + +Reference:https://github.com/paramiko/paramiko/commit/fa46de7feeeb8a01dc471581a0258252ce4f2db6 +Conflict:NA + +--- + paramiko/packet.py | 6 ++++++ + paramiko/transport.py | 22 ++++++++++++++++++++-- + tests/test_transport.py | 25 +++++++++++++++++++++++-- + 3 files changed, 49 insertions(+), 4 deletions(-) + +diff --git a/paramiko/packet.py b/paramiko/packet.py +index 1266316..1fc06d9 100644 +--- a/paramiko/packet.py ++++ b/paramiko/packet.py +@@ -130,6 +130,12 @@ class Packetizer(object): + def closed(self): + return self.__closed + ++ def reset_seqno_out(self): ++ self.__sequence_number_out = 0 ++ ++ def reset_seqno_in(self): ++ self.__sequence_number_in = 0 ++ + def set_log(self, log): + """ + Set the Python log object to use for logging. +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 83b1c81..0c68668 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -2469,9 +2469,13 @@ class Transport(threading.Thread, ClosingContextManager): + + # 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 m.seqno != 0: ++ if ( ++ self.agreed_on_strict_kex ++ and not self.initial_kex_done ++ and m.seqno != 0 ++ ): + raise MessageOrderError( +- f"Got nonzero seqno ({m.seqno}) during strict KEXINIT!" ++ "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 +@@ -2670,6 +2674,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, ++ f"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 +@@ -2677,6 +2688,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, ++ f"Resetting outbound sequence number 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) +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 7440e88..9c3e8f5 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -1548,5 +1548,26 @@ class TestStrictKex: + ): + pass # kexinit happens at connect... + +- def test_sequence_numbers_reset_on_newkeys(self): +- skip() ++ def test_sequence_numbers_reset_on_newkeys_when_strict(self): ++ with server(defer=True) as (tc, ts): ++ # When in strict mode, these should all be zero or close to it ++ # (post-kexinit, pre-auth). ++ # Server->client will be 1 (EXT_INFO got sent after NEWKEYS) ++ assert tc.packetizer._Packetizer__sequence_number_in == 1 ++ assert ts.packetizer._Packetizer__sequence_number_out == 1 ++ # Client->server will be 0 ++ assert tc.packetizer._Packetizer__sequence_number_out == 0 ++ assert ts.packetizer._Packetizer__sequence_number_in == 0 ++ ++ def test_sequence_numbers_not_reset_on_newkeys_when_not_strict(self): ++ with server(defer=True, client_init=dict(strict_kex=False)) as ( ++ tc, ++ ts, ++ ): ++ # When not in strict mode, these will all be ~3-4 or so ++ # (post-kexinit, pre-auth). Not encoding exact values as it will ++ # change anytime we mess with the test harness... ++ assert tc.packetizer._Packetizer__sequence_number_in != 0 ++ assert tc.packetizer._Packetizer__sequence_number_out != 0 ++ assert ts.packetizer._Packetizer__sequence_number_in != 0 ++ assert ts.packetizer._Packetizer__sequence_number_out != 0 +-- +2.33.0 diff --git a/backport-0006-CVE-2023-48795.patch b/backport-0006-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..ef2ac8b0eae3bb6146962c55d9390e5872eb55ed --- /dev/null +++ b/backport-0006-CVE-2023-48795.patch @@ -0,0 +1,161 @@ +From 96db1e2be856eac66631761bae41167a1ebd2b4e Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Sun, 17 Dec 2023 17:13:53 -0500 +Subject: [PATCH] Raise exception when sequence numbers rollover during initial + kex + +Reference:https://github.com/paramiko/paramiko/commit/96db1e2be856eac66631761bae41167a1ebd2b4e +Conflict:Context adaptation exists in the changelog.rst file due to version inconsistency. + +--- + paramiko/packet.py | 17 +++++++++++++---- + paramiko/transport.py | 4 +++- + sites/www/changelog.rst | 28 ++++++++++++++++++++++++++++ + tests/test_transport.py | 32 ++++++++++++++++++++++++++++++++ + 4 files changed, 76 insertions(+), 5 deletions(-) + +diff --git a/paramiko/packet.py b/paramiko/packet.py +index 1fc06d9..8b9e6d6 100644 +--- a/paramiko/packet.py ++++ b/paramiko/packet.py +@@ -86,6 +86,7 @@ class Packetizer(object): + self.__need_rekey = False + self.__init_count = 0 + self.__remainder = bytes() ++ self._initial_kex_done = False + + # used for noticing when to re-key: + self.__sent_bytes = 0 +@@ -431,9 +432,12 @@ class Packetizer(object): + out += compute_hmac( + self.__mac_key_out, payload, self.__mac_engine_out + )[: self.__mac_size_out] +- self.__sequence_number_out = ( +- self.__sequence_number_out + 1 +- ) & xffffffff ++ next_seq = (self.__sequence_number_out + 1) & xffffffff ++ if next_seq == 0 and not self._initial_kex_done: ++ raise SSHException( ++ "Sequence number rolled over during initial kex!" ++ ) ++ self.__sequence_number_out = next_seq + self.write_all(out) + + self.__sent_bytes += len(out) +@@ -537,7 +541,12 @@ class Packetizer(object): + + msg = Message(payload[1:]) + msg.seqno = self.__sequence_number_in +- self.__sequence_number_in = (self.__sequence_number_in + 1) & xffffffff ++ next_seq = (self.__sequence_number_in + 1) & xffffffff ++ if next_seq == 0 and not self._initial_kex_done: ++ raise SSHException( ++ "Sequence number rolled over during initial kex!" ++ ) ++ self.__sequence_number_in = next_seq + + # check for rekey + raw_packet_size = packet_size + self.__mac_size_in + 4 +diff --git a/paramiko/transport.py b/paramiko/transport.py +index 0c68668..750f9b4 100644 +--- a/paramiko/transport.py ++++ b/paramiko/transport.py +@@ -2785,7 +2785,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 ++ # staus 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() +diff --git a/sites/www/changelog.rst b/sites/www/changelog.rst +index 0675d04..6a2e4c0 100644 +--- a/sites/www/changelog.rst ++++ b/sites/www/changelog.rst +@@ -5,6 +5,34 @@ Changelog + - :feature:`-` `Transport` grew a new ``packetizer_class`` kwarg for overriding + the packet-handler class used internally. Mostly for testing, but advanced + users may find this useful when doing deep hacks. ++- :bug:`-` Address `CVE 2023-48795`_ (aka the ++ "Terrapin Attack", a vulnerability found in the SSH protocol re: treatment of ++ packet sequence numbers) as follows: ++ - The vulnerability only impacts encrypt-then-MAC digest algorithms in ++ tandem with CBC ciphers, and ChaCha20-poly1305; of these, Paramiko ++ currently only implements ``hmac-sha2-(256|512)-etm`` in tandem with ++ ``AES-CBC``. If you are unable to upgrade to Paramiko versions containing ++ the below fixes right away, you may instead use the ++ ``disabled_algorithms`` connection option to disable the ETM MACs and/or ++ the CBC ciphers (this option is present in Paramiko >=2.6). ++ - As the fix for the vulnerability requires both ends of the connection to ++ cooperate, the below changes will only take effect when the remote end is ++ OpenSSH >= TK (or equivalent, such as Paramiko in server mode, as of this ++ patch version) and configured to use the new "strict kex" mode. Paramiko ++ will always attempt to use "strict kex" mode if offered by the server, ++ unless you override this by specifying ``strict_kex=False`` in ++ `Transport.__init__`. ++ - Paramiko will now raise an `SSHException` subclass (`MessageOrderError`) ++ when protocol messages are received in unexpected order. (This is not ++ *really* a change in behavior, as most such cases already raised vanilla ++ `SSHException` anyways.) ++ - Key (re)negotiation -- i.e. ``MSG_NEWKEYS``, whenever it is encountered ++ -- now resets packet sequence numbers. (This should be invisible to users ++ during normal operation, only causing exceptions if the exploit is ++ encountered, which will usually result in, again, `MessageOrderError`.) ++ - Sequence number rollover will now raise `SSHException` if it occurs ++ during initial key exchange (regardless of strict mode status). ++ + - :bug:`-` Tweak ``ext-info-(c|s)`` detection during KEXINIT protocol phase; + the original implementation made assumptions based on an OpenSSH + implementation detail. +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 9c3e8f5..4ed712e 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -30,6 +30,7 @@ import socket + import time + import threading + import random ++import sys + import unittest + from mock import Mock + +@@ -1571,3 +1572,34 @@ class TestStrictKex: + assert tc.packetizer._Packetizer__sequence_number_out != 0 + assert ts.packetizer._Packetizer__sequence_number_in != 0 + assert ts.packetizer._Packetizer__sequence_number_out != 0 ++ ++ def test_sequence_number_rollover_detected(self): ++ class RolloverTransport(Transport): ++ def __init__(self, *args, **kwargs): ++ super().__init__(*args, **kwargs) ++ # Induce an about-to-rollover seqno, such that it rolls over ++ # during initial kex. ++ setattr( ++ self.packetizer, ++ f"_Packetizer__sequence_number_in", ++ sys.maxsize, ++ ) ++ setattr( ++ self.packetizer, ++ f"_Packetizer__sequence_number_out", ++ sys.maxsize, ++ ) ++ ++ with raises( ++ SSHException, ++ match=r"Sequence number rolled over during initial kex!", ++ ): ++ with server( ++ client_init=dict( ++ # Disable strict kex - this should happen always ++ strict_kex=False, ++ ), ++ # Transport which tickles its packetizer seqno's ++ transport_factory=RolloverTransport, ++ ): ++ pass # kexinit happens at connect... +-- +2.33.0 diff --git a/backport-0007-CVE-2023-48795.patch b/backport-0007-CVE-2023-48795.patch new file mode 100644 index 0000000000000000000000000000000000000000..ecd2d7067ab1ce5150e2ca46fcc44efc3d7497dd --- /dev/null +++ b/backport-0007-CVE-2023-48795.patch @@ -0,0 +1,285 @@ +From e22c5ea330814801d8487dc3da347f987bafe5ec Mon Sep 17 00:00:00 2001 +From: Jeff Forcier +Date: Thu, 4 May 2023 13:52:40 -0400 +Subject: [PATCH] Start consolidating test server nonsense + +Reference:https://github.com/paramiko/paramiko/commit/e22c5ea330814801d8487dc3da347f987bafe5ec +Conflict:Currently, _util.py does not exist due to different versions. Therefore, the reconstruction code of test_transport.py is still stored in this file + The key name must be the same as the current one. + +--- + tests/test_transport.py | 198 ++++++++++++++++++++++++++++++++++++---- + 1 file changed, 181 insertions(+), 17 deletions(-) + +diff --git a/tests/test_transport.py b/tests/test_transport.py +index 4ed712e..6cdbfd6 100644 +--- a/tests/test_transport.py ++++ b/tests/test_transport.py +@@ -33,6 +33,7 @@ import random + import sys + import unittest + from mock import Mock ++from time import sleep + + from paramiko import ( + AuthHandler, +@@ -1196,6 +1197,146 @@ class AlgorithmDisablingTests(unittest.TestCase): + assert "diffie-hellman-group14-sha256" not in kexen + assert "zlib" not in compressions + ++_disable_sha2 = dict( ++ disabled_algorithms=dict(keys=["rsa-sha2-256", "rsa-sha2-512"]) ++) ++_disable_sha1 = dict(disabled_algorithms=dict(keys=["ssh-rsa"])) ++_disable_sha2_pubkey = dict( ++ disabled_algorithms=dict(pubkeys=["rsa-sha2-256", "rsa-sha2-512"]) ++) ++_disable_sha1_pubkey = dict(disabled_algorithms=dict(pubkeys=["ssh-rsa"])) ++ ++ ++unicodey = "\u2022" ++ ++ ++class TestServer(ServerInterface): ++ paranoid_did_password = False ++ paranoid_did_public_key = False ++ # TODO: make this ed25519 or something else modern? (_is_ this used??) ++ paranoid_key = DSSKey.from_private_key_file(_support("test_dss.key")) ++ ++ def __init__(self, allowed_keys=None): ++ self.allowed_keys = allowed_keys if allowed_keys is not None else [] ++ ++ def check_channel_request(self, kind, chanid): ++ if kind == "bogus": ++ return OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED ++ return OPEN_SUCCEEDED ++ ++ def check_channel_exec_request(self, channel, command): ++ if command != b"yes": ++ return False ++ return True ++ ++ def check_channel_shell_request(self, channel): ++ return True ++ ++ def check_global_request(self, kind, msg): ++ self._global_request = kind ++ # NOTE: for w/e reason, older impl of this returned False always, even ++ # tho that's only supposed to occur if the request cannot be served. ++ # For now, leaving that the default unless test supplies specific ++ # 'acceptable' request kind ++ return kind == "acceptable" ++ ++ def check_channel_x11_request( ++ self, ++ channel, ++ single_connection, ++ auth_protocol, ++ auth_cookie, ++ screen_number, ++ ): ++ self._x11_single_connection = single_connection ++ self._x11_auth_protocol = auth_protocol ++ self._x11_auth_cookie = auth_cookie ++ self._x11_screen_number = screen_number ++ return True ++ ++ def check_port_forward_request(self, addr, port): ++ self._listen = socket.socket() ++ self._listen.bind(("127.0.0.1", 0)) ++ self._listen.listen(1) ++ return self._listen.getsockname()[1] ++ ++ def cancel_port_forward_request(self, addr, port): ++ self._listen.close() ++ self._listen = None ++ ++ def check_channel_direct_tcpip_request(self, chanid, origin, destination): ++ self._tcpip_dest = destination ++ return OPEN_SUCCEEDED ++ ++ def get_allowed_auths(self, username): ++ if username == "slowdive": ++ return "publickey,password" ++ if username == "paranoid": ++ if ( ++ not self.paranoid_did_password ++ and not self.paranoid_did_public_key ++ ): ++ return "publickey,password" ++ elif self.paranoid_did_password: ++ return "publickey" ++ else: ++ return "password" ++ if username == "commie": ++ return "keyboard-interactive" ++ if username == "utf8": ++ return "password" ++ if username == "non-utf8": ++ return "password" ++ return "publickey" ++ ++ def check_auth_password(self, username, password): ++ if (username == "slowdive") and (password == "pygmalion"): ++ return AUTH_SUCCESSFUL ++ if (username == "paranoid") and (password == "paranoid"): ++ # 2-part auth (even openssh doesn't support this) ++ self.paranoid_did_password = True ++ if self.paranoid_did_public_key: ++ return AUTH_SUCCESSFUL ++ return AUTH_PARTIALLY_SUCCESSFUL ++ if (username == "utf8") and (password == unicodey): ++ return AUTH_SUCCESSFUL ++ if (username == "non-utf8") and (password == "\xff"): ++ return AUTH_SUCCESSFUL ++ if username == "bad-server": ++ raise Exception("Ack!") ++ if username == "unresponsive-server": ++ time.sleep(5) ++ return AUTH_SUCCESSFUL ++ return AUTH_FAILED ++ ++ def check_auth_publickey(self, username, key): ++ if (username == "paranoid") and (key == self.paranoid_key): ++ # 2-part auth ++ self.paranoid_did_public_key = True ++ if self.paranoid_did_password: ++ return AUTH_SUCCESSFUL ++ return AUTH_PARTIALLY_SUCCESSFUL ++ # TODO: make sure all tests incidentally using this to pass, _without ++ # sending a username oops_, get updated somehow - probably via server() ++ # default always injecting a username ++ elif key in self.allowed_keys: ++ return AUTH_SUCCESSFUL ++ return AUTH_FAILED ++ ++ def check_auth_interactive(self, username, submethods): ++ if username == "commie": ++ self.username = username ++ return InteractiveQuery( ++ "password", "Please enter a password.", ("Password", False) ++ ) ++ return AUTH_FAILED ++ ++ def check_auth_interactive_response(self, responses): ++ if self.username == "commie": ++ if (len(responses) == 1) and (responses[0] == "cat"): ++ return AUTH_SUCCESSFUL ++ return AUTH_FAILED ++ + + @contextmanager + def server( +@@ -1206,13 +1347,20 @@ def server( + connect=None, + pubkeys=None, + catch_error=False, ++ transport_factory=None, ++ server_transport_factory=None, ++ defer=False, ++ skip_verify=False, + ): + """ + SSH server contextmanager for testing. + ++ Yields a tuple of ``(tc, ts)`` (client- and server-side `Transport` ++ objects), or ``(tc, ts, err)`` when ``catch_error==True``. ++ + :param hostkey: + Host key to use for the server; if None, loads +- ``test_rsa.key``. ++ ``rsa.key``. + :param init: + Default `Transport` constructor kwargs to use for both sides. + :param server_init: +@@ -1226,6 +1374,17 @@ def server( + :param catch_error: + Whether to capture connection errors & yield from contextmanager. + Necessary for connection_time exception testing. ++ :param transport_factory: ++ Like the same-named param in SSHClient: which Transport class to use. ++ :param server_transport_factory: ++ Like ``transport_factory``, but only impacts the server transport. ++ :param bool defer: ++ Whether to defer authentication during connecting. ++ ++ This is really just shorthand for ``connect={}`` which would do roughly ++ the same thing. Also: this implies skip_verify=True automatically! ++ :param bool skip_verify: ++ Whether NOT to do the default "make sure auth passed" check. + """ + if init is None: + init = {} +@@ -1234,18 +1393,27 @@ def server( + if client_init is None: + client_init = {} + if connect is None: +- connect = dict(username="slowdive", password="pygmalion") ++ # No auth at all please ++ if defer: ++ connect = dict() ++ # Default username based auth ++ else: ++ connect = dict(username="slowdive", password="pygmalion") + socks = LoopSocket() + sockc = LoopSocket() + sockc.link(socks) +- tc = Transport(sockc, **dict(init, **client_init)) +- ts = Transport(socks, **dict(init, **server_init)) ++ if transport_factory is None: ++ transport_factory = Transport ++ if server_transport_factory is None: ++ server_transport_factory = transport_factory ++ tc = transport_factory(sockc, **dict(init, **client_init)) ++ ts = server_transport_factory(socks, **dict(init, **server_init)) + + if hostkey is None: + hostkey = RSAKey.from_private_key_file(_support("test_rsa.key")) + ts.add_server_key(hostkey) + event = threading.Event() +- server = NullServer(allowed_keys=pubkeys) ++ server = TestServer(allowed_keys=pubkeys) + assert not event.is_set() + assert not ts.is_active() + assert tc.get_username() is None +@@ -1273,22 +1441,15 @@ def server( + + yield (tc, ts, err) if catch_error else (tc, ts) + ++ if not (catch_error or skip_verify or defer): ++ assert ts.is_authenticated() ++ assert tc.is_authenticated() ++ + tc.close() + ts.close() + socks.close() + sockc.close() + +- +-_disable_sha2 = dict( +- disabled_algorithms=dict(keys=["rsa-sha2-256", "rsa-sha2-512"]) +-) +-_disable_sha1 = dict(disabled_algorithms=dict(keys=["ssh-rsa"])) +-_disable_sha2_pubkey = dict( +- disabled_algorithms=dict(pubkeys=["rsa-sha2-256", "rsa-sha2-512"]) +-) +-_disable_sha1_pubkey = dict(disabled_algorithms=dict(pubkeys=["ssh-rsa"])) +- +- + class TestSHA2SignatureKeyExchange(unittest.TestCase): + # NOTE: these all rely on the default server() hostkey being RSA + # NOTE: these rely on both sides being properly implemented re: agreed-upon +@@ -1352,7 +1513,10 @@ class TestSHA2SignatureKeyExchange(unittest.TestCase): + # the entire preferred-hostkeys structure when given an explicit key as + # a client.) + hostkey = RSAKey.from_private_key_file(_support("test_rsa.key")) +- with server(hostkey=hostkey, connect=dict(hostkey=hostkey)) as (tc, _): ++ connect = dict( ++ hostkey=hostkey, username="slowdive", password="pygmalion" ++ ) ++ with server(hostkey=hostkey, connect=connect) as (tc, _): + assert tc.host_key_type == "rsa-sha2-512" + + +-- +2.33.0 diff --git a/python-paramiko.spec b/python-paramiko.spec index e43543473212f69e313c419cc3f3b8bddfe53fc1..e1cccd5fa3333baa011f23e0f89ed0ec011ad318 100644 --- a/python-paramiko.spec +++ b/python-paramiko.spec @@ -1,6 +1,6 @@ Name: python-paramiko Version: 2.11.0 -Release: 1 +Release: 2 Summary: Python SSH module License: LGPLv2+ URL: https://github.com/paramiko/paramiko @@ -11,6 +11,13 @@ Source0: https://github.com/paramiko/paramiko/archive/%{version}/paramiko- Patch6000: backport-Skip-tests-requiring-invoke.patch Patch6001: 0003-remove-pytest-relaxed-dep.patch Patch6002: backport-fix-error-in-sftp-testcase.patch +Patch6003: backport-0001-CVE-2023-48795.patch +Patch6004: backport-0002-CVE-2023-48795.patch +Patch6005: backport-0003-CVE-2023-48795.patch +Patch6006: backport-0004-CVE-2023-48795.patch +Patch6007: backport-0005-CVE-2023-48795.patch +Patch6008: backport-0006-CVE-2023-48795.patch +Patch6009: backport-0007-CVE-2023-48795.patch BuildArch: noarch @@ -69,6 +76,9 @@ PYTHONPATH=%{buildroot}%{python3_sitelib} pytest-%{python3_version} %doc html/ demos/ NEWS README.rst %changelog +* Thu Jan 11 2024 zhangpan - 2.11.0-2 +- fix CVE-2023-48795 + * Tue Oct 17 2023 zhuyuncheng - 2.11.0-1 - upgrade to 2.11.0-1