Version in base suite: 27.3.4.1+dfsg-1+deb13u1 Base version: erlang_27.3.4.1+dfsg-1+deb13u1 Target version: erlang_27.3.4.1+dfsg-1+deb13u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/e/erlang/erlang_27.3.4.1+dfsg-1+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/e/erlang/erlang_27.3.4.1+dfsg-1+deb13u2.dsc changelog | 31 ++ patches/CVE-2026-21620.patch | 578 ++++++++++++++++++++++++++++++++++++++++ patches/CVE-2026-23941.patch | 159 +++++++++++ patches/CVE-2026-23942.patch | 196 +++++++++++++ patches/CVE-2026-23943.patch | 619 +++++++++++++++++++++++++++++++++++++++++++ patches/series | 4 6 files changed, 1587 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpn2g5hpbg/erlang_27.3.4.1+dfsg-1+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpn2g5hpbg/erlang_27.3.4.1+dfsg-1+deb13u2.dsc: no acceptable signature found diff -Nru erlang-27.3.4.1+dfsg/debian/changelog erlang-27.3.4.1+dfsg/debian/changelog --- erlang-27.3.4.1+dfsg/debian/changelog 2025-07-08 07:27:28.000000000 +0000 +++ erlang-27.3.4.1+dfsg/debian/changelog 2026-04-04 13:45:31.000000000 +0000 @@ -1,5 +1,36 @@ +erlang (1:27.3.4.1+dfsg-1+deb13u2) trixie; urgency=medium + + [ Lucas Kanashiro ] + * Fix CVE-2026-21620. + Relative Path Traversal, Improper Isolation or Compartmentalization + vulnerability in Erlang OTP (tftp_file modules). Closes: #1128651 + * Fix CVE-2026-23941. + Inconsistent Interpretation of HTTP Requests ('HTTP Request Smuggling') + vulnerability in Erlang OTP (inets httpd module) allows HTTP Request + Smuggling. + - d/p/CVE-2026-23941.patch + * Fix CVE-2026-23942. + Improper Limitation of a Pathname to a Restricted Directory ('Path + Traversal') vulnerability in Erlang OTP (ssh_sftpd module) allows Path + Traversal. + - d/p/CVE-2026-23942.patch + * Fix CVE-2026-23943. + Improper Handling of Highly Compressed Data (Compression Bomb) + vulnerability in Erlang OTP ssh (ssh_transport modules) allows Denial of + Service via Resource Depletion. + - d/p/CVE-2026-23943.patch + Closes: #1130912 + + -- Sergei Golovan Sat, 04 Apr 2026 16:45:31 +0300 + erlang (1:27.3.4.1+dfsg-1+deb13u1) trixie; urgency=medium + [ Jochen Sprickerhof ] + * Add salsa-ci + * Add gbp.conf. + Needed to reproduce the orig.tar with empty directories. + + [ Sergei Golovan ] * Fix CVE-2025-48038: allocation of resources without limits or throttling vulnerability in the ssh_sftp module allows excessive allocation, resource leak exposure (closes: #1115093). diff -Nru erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-21620.patch erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-21620.patch --- erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-21620.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-21620.patch 2026-04-04 13:45:31.000000000 +0000 @@ -0,0 +1,578 @@ +From: Erlang/OTP +Date: Thu, 19 Feb 2026 16:58:37 +0100 +Subject: Merge branch 'raimo/tftp/path-traversal-27/OTP-19981' into maint-27 + +* raimo/tftp/path-traversal-27/OTP-19981: + Fix typos + Fix old timing sensitive test case + Document security considerations + Fix old timing sensitive test case + Test option root_dir + Rewrite old style catch + Validate initial options + +Origin: upstream, https://github.com/erlang/otp/commit/3970738f687325138eb75f798054fa8960ac354e +Bug-Debian: https://bugs.debian.org/1128651 + +More info about this CVE: https://github.com/erlang/otp/security/advisories/GHSA-hmrc-prh3-rpvp +--- + debian/patches/CVE-2026-21620.patch | 572 +++++++++++++++++++++++++++++++++ + debian/patches/series | 1 + + lib/tftp/doc/guides/getting_started.md | 5 +- + lib/tftp/doc/guides/introduction.md | 15 + + lib/tftp/src/tftp.erl | 50 ++- + lib/tftp/src/tftp_file.erl | 121 +++---- + lib/tftp/test/tftp_SUITE.erl | 119 +++++-- + lib/tftp/test/tftp_test_lib.hrl | 5 +- + 8 files changed, 807 insertions(+), 81 deletions(-) + create mode 100644 debian/patches/CVE-2026-21620.patch + +diff --git a/lib/tftp/doc/guides/getting_started.md b/lib/tftp/doc/guides/getting_started.md +index e9112a8..fbdd203 100644 +--- a/lib/tftp/doc/guides/getting_started.md ++++ b/lib/tftp/doc/guides/getting_started.md +@@ -30,13 +30,14 @@ a sample file using the TFTP client. + _Step 1._ Create a sample file to be used for the transfer: + + ```text +- $ echo "Erlang/OTP 21" > file.txt ++ $ echo "Erlang/OTP 21" > /tmp/file.txt + ``` + + _Step 2._ Start the TFTP server: + + ```erlang +- 1> {ok, Pid} = tftp:start([{port, 19999}]). ++ 1> Callback = {callback,{"",tftp_file,[{root_dir,"/tmp"}]}}. ++ 2> {ok, Pid} = tftp:start([{port, 19999}, Callback]). + {ok,<0.65.0>} + ``` + +diff --git a/lib/tftp/doc/guides/introduction.md b/lib/tftp/doc/guides/introduction.md +index 55d35bd..d86f676 100644 +--- a/lib/tftp/doc/guides/introduction.md ++++ b/lib/tftp/doc/guides/introduction.md +@@ -42,3 +42,18 @@ file system. TFTP is often installed with controls such that only + files that have public read access are available via TFTP and writing + files via TFTP is disallowed." + ++This essentially means that any machine on the network ++that can reach the TFTP server is able to read and write, ++without authentication, any file on the machine that runs ++the TFTP server, that the user (or group) that runs the TFTP server ++(in this case the Erlang VM) is allowed to read or write. ++The machine configuration has to be prepared for that. ++ ++> #### Warning {: .warning } ++> ++> The default behavior mentioned above is in general very risky, ++> and as a remedy, this TFTP application's default callback ++> `tftp_file` implements an initial state option ++> `{root_dir,Dir}` that restricts the callback's file accesses ++> to `Dir` and subdirectories. It is recommended ++> to use that option when starting start this TFTP server. +diff --git a/lib/tftp/src/tftp.erl b/lib/tftp/src/tftp.erl +index fdbe527..bd8c017 100644 +--- a/lib/tftp/src/tftp.erl ++++ b/lib/tftp/src/tftp.erl +@@ -41,10 +41,11 @@ Interface module for the `tftp` application. + ## Overwiew + + This is a complete implementation of the following IETF standards: +- RFC 1350, The TFTP Protocol (revision 2). +- RFC 2347, TFTP Option Extension. +- RFC 2348, TFTP Blocksize Option. +- RFC 2349, TFTP Timeout Interval and Transfer Size Options. ++ ++* [RFC 1350][], The TFTP Protocol (revision 2). ++* [RFC 2347][], TFTP Option Extension. ++* [RFC 2348][], TFTP Blocksize Option. ++* [RFC 2349][], TFTP Timeout Interval and Transfer Size Options. + + The only feature that not is implemented in this release is + the "netascii" transfer mode. +@@ -60,6 +61,11 @@ with a TFTP daemon and performs the actual transfer of the file. + Most of the options are common for both the client and the server + side, but some of them differs a little. + ++[RFC 1350]: https://datatracker.ietf.org/doc/html/rfc1350 ++[RFC 2347]: https://datatracker.ietf.org/doc/html/rfc2347 ++[RFC 2348]: https://datatracker.ietf.org/doc/html/rfc2348 ++[RFC 2349]: https://datatracker.ietf.org/doc/html/rfc2349 ++ + ## Callbacks + + A `tftp` callback module is to be implemented as a `tftp` behavior and export +@@ -197,7 +203,7 @@ All options most of them common to the client and server. + Controls which features to reject. This is mostly useful for the server as it + can restrict the use of certain TFTP options or read/write access. + +-- **`{callback, {RegExp ::string(), Module::module(), State :: term()}}`** ++- **`{callback, {RegExp ::string(), Module::module(), InitialState :: term()}}`** + + Registration of a callback module. When a file is to be transferred, its local + filename is matched to the regular expressions of the registered callbacks. +@@ -207,6 +213,24 @@ All options most of them common to the client and server. + The callback module must implement the `tftp` behavior, see + [callbacks](`m:tftp#callbacks`). + ++ At the end of the list of callbacks there are always the default callbacks ++ `tftp_file` and `tftp_binary` with the `RegExp = ""` and `InitialState = []`. ++ ++ The `InitialState` should be an option list, and the empty list ++ should be accepted by any callback module. The `tftp_file` ++ callback module accepts an `InitialState = [{root_dir, Dir}]` ++ that restrict local file operations to files in `Dir` and subdirectories. ++ All file names received in protocol requests, relative or absolute, ++ are regarded as relative to this directory. ++ ++ > #### Warning {: .warning } ++ > ++ > The default callback module configuration allows access to any file ++ > on any local filesystem that is readable or writable by the user ++ > running the Erlang VM. This can be a security vulnerability. ++ > It is therefore recommended to explicitly configure the `tftp_file` ++ > callback module to use the `root_dir` option. ++ + - **`{logger, module()}`** + + Callback module for customized logging of errors, warnings, and info messages. +@@ -390,6 +414,22 @@ Starts a daemon process listening for UDP packets on a port. + + When it receives a request for read or write, it spawns a temporary + server process handling the actual transfer of the (virtual) file. ++ ++The request filename is matched against the regexps of the registered ++callback modules, and the first match selects the callback ++to handle the request. ++ ++If there are no registered callback modules, `tftp_file` is used, ++with the initial state `[]`. ++ ++> #### Warning {: .warning } ++> ++> The default callback module configuration allows access to any file ++> on any local filesystem that is readable or writable by the user ++> running the Erlang VM. This can be a security vulnerability. ++> See the [`{callback,_}` option](`t:connection_option/0`) ++> at the start of this module reference for a remedy. ++ + """. + + -spec start(Options) -> {ok, Pid} | {error, Reason} when +diff --git a/lib/tftp/src/tftp_file.erl b/lib/tftp/src/tftp_file.erl +index 27d2b9c..87f0c76 100644 +--- a/lib/tftp/src/tftp_file.erl ++++ b/lib/tftp/src/tftp_file.erl +@@ -1,7 +1,7 @@ + %% + %% %CopyrightBegin% + %% +-%% Copyright Ericsson AB 2005-2024. All Rights Reserved. ++%% Copyright Ericsson AB 2005-2026. All Rights Reserved. + %% + %% Licensed under the Apache License, Version 2.0 (the "License"); + %% you may not use this file except in compliance with the License. +@@ -44,10 +44,6 @@ + + -include_lib("kernel/include/file.hrl"). + +--record(initial, +- {filename, +- is_native_ascii}). +- + -record(state, + {access, + filename, +@@ -96,8 +92,8 @@ + + prepare(_Peer, Access, Filename, Mode, SuggestedOptions, Initial) when is_list(Initial) -> + %% Client side +- case catch handle_options(Access, Filename, Mode, SuggestedOptions, Initial) of +- {ok, Filename2, IsNativeAscii, IsNetworkAscii, AcceptedOptions} -> ++ try handle_options(Access, Filename, Mode, SuggestedOptions, Initial) of ++ {Filename2, IsNativeAscii, IsNetworkAscii, AcceptedOptions} -> + State = #state{access = Access, + filename = Filename2, + is_native_ascii = IsNativeAscii, +@@ -106,9 +102,9 @@ prepare(_Peer, Access, Filename, Mode, SuggestedOptions, Initial) when is_list(I + blksize = lookup_blksize(AcceptedOptions), + count = 0, + buffer = []}, +- {ok, AcceptedOptions, State}; +- {error, {Code, Text}} -> +- {error, {Code, Text}} ++ {ok, AcceptedOptions, State} ++ catch throw : Error -> ++ {error, Error} + end. + + %% --------------------------------------------------------- +@@ -154,12 +150,12 @@ open(Peer, Access, Filename, Mode, SuggestedOptions, Initial) when is_list(Initi + end; + open(_Peer, Access, Filename, Mode, NegotiatedOptions, State) when is_record(State, state) -> + %% Both sides +- case catch handle_options(Access, Filename, Mode, NegotiatedOptions, State) of +- {ok, _Filename2, _IsNativeAscii, _IsNetworkAscii, Options} +- when Options =:= NegotiatedOptions -> +- do_open(State); +- {error, {Code, Text}} -> +- {error, {Code, Text}} ++ try handle_options(Access, Filename, Mode, NegotiatedOptions, State) of ++ {_Filename2, _IsNativeAscii, _IsNetworkAscii, Options} ++ when Options =:= NegotiatedOptions -> ++ do_open(State) ++ catch throw : Error -> ++ {error, Error} + end; + open(Peer, Access, Filename, Mode, NegotiatedOptions, State) -> + %% Handle upgrade from old releases. Please, remove this clause in next release. +@@ -295,45 +291,62 @@ abort(_Code, _Text, #state{fd = Fd, access = Access} = State) -> + %%------------------------------------------------------------------- + + handle_options(Access, Filename, Mode, Options, Initial) -> +- I = #initial{filename = Filename, is_native_ascii = is_native_ascii()}, +- {Filename2, IsNativeAscii} = handle_initial(Initial, I), +- IsNetworkAscii = handle_mode(Mode, IsNativeAscii), ++ {Filename2, IsNativeAscii} = handle_initial(Initial, Filename), ++ IsNetworkAscii = ++ case Mode of ++ "netascii" when IsNativeAscii =:= true -> ++ true; ++ "octet" -> ++ false; ++ _ -> ++ throw({badop, "Illegal mode " ++ Mode}) ++ end, + Options2 = do_handle_options(Access, Filename2, Options), +- {ok, Filename2, IsNativeAscii, IsNetworkAscii, Options2}. +- +-handle_mode(Mode, IsNativeAscii) -> +- case Mode of +- "netascii" when IsNativeAscii =:= true -> true; +- "octet" -> false; +- _ -> throw({error, {badop, "Illegal mode " ++ Mode}}) ++ {Filename2, IsNativeAscii, IsNetworkAscii, Options2}. ++ ++handle_initial( ++ #state{filename = Filename, is_native_ascii = IsNativeAscii}, _FName) -> ++ {Filename, IsNativeAscii}; ++handle_initial(Initial, Filename) when is_list(Initial) -> ++ Opts = get_initial_opts(Initial, #{}), ++ {case Opts of ++ #{ root_dir := RootDir } -> ++ safe_filename(Filename, RootDir); ++ #{} -> ++ Filename ++ end, ++ maps:get(is_native_ascii, Opts, is_native_ascii())}. ++ ++get_initial_opts([], Opts) -> Opts; ++get_initial_opts([Opt | Initial], Opts) -> ++ case Opt of ++ {root_dir, RootDir} -> ++ is_map_key(root_dir, Opts) andalso ++ throw({badop, "Internal error. root_dir already set"}), ++ get_initial_opts(Initial, Opts#{ root_dir => RootDir }); ++ {native_ascii, Bool} when is_boolean(Bool) -> ++ get_initial_opts(Initial, Opts#{ is_native_ascii => Bool }) + end. + +-handle_initial([{root_dir, Dir} | Initial], I) -> +- case catch filename_join(Dir, I#initial.filename) of +- {'EXIT', _} -> +- throw({error, {badop, "Internal error. root_dir is not a string"}}); +- Filename2 -> +- handle_initial(Initial, I#initial{filename = Filename2}) +- end; +-handle_initial([{native_ascii, Bool} | Initial], I) -> +- case Bool of +- true -> handle_initial(Initial, I#initial{is_native_ascii = true}); +- false -> handle_initial(Initial, I#initial{is_native_ascii = false}) +- end; +-handle_initial([], I) when is_record(I, initial) -> +- {I#initial.filename, I#initial.is_native_ascii}; +-handle_initial(State, _) when is_record(State, state) -> +- {State#state.filename, State#state.is_native_ascii}. +- +-filename_join(Dir, Filename) -> +- case filename:pathtype(Filename) of +- absolute -> +- [_ | RelFilename] = filename:split(Filename), +- filename:join([Dir, RelFilename]); +- _ -> +- filename:join([Dir, Filename]) ++safe_filename(Filename, RootDir) -> ++ absolute =:= filename:pathtype(RootDir) orelse ++ throw({badop, "Internal error. root_dir is not absolute"}), ++ filelib:is_dir(RootDir) orelse ++ throw({badop, "Internal error. root_dir not a directory"}), ++ RelFilename = ++ case filename:pathtype(Filename) of ++ absolute -> ++ filename:join(tl(filename:split(Filename))); ++ _ -> Filename ++ end, ++ case filelib:safe_relative_path(RelFilename, RootDir) of ++ unsafe -> ++ throw({badop, "Internal error. Filename out of bounds"}); ++ SafeFilename -> ++ filename:join(RootDir, SafeFilename) + end. + ++ + do_handle_options(Access, Filename, [{Key, Val} | T]) -> + case Key of + "tsize" -> +@@ -361,15 +374,15 @@ do_handle_options(_Access, _Filename, []) -> + + + handle_integer(Access, Filename, Key, Val, Options, Min, Max) -> +- case catch list_to_integer(Val) of +- {'EXIT', _} -> +- do_handle_options(Access, Filename, Options); ++ try list_to_integer(Val) of + Int when Int >= Min, Int =< Max -> + [{Key, Val} | do_handle_options(Access, Filename, Options)]; + Int when Int >= Min, Max =:= infinity -> + [{Key, Val} | do_handle_options(Access, Filename, Options)]; + _Int -> +- throw({error, {badopt, "Illegal " ++ Key ++ " value " ++ Val}}) ++ throw({badopt, "Illegal " ++ Key ++ " value " ++ Val}) ++ catch error : _ -> ++ do_handle_options(Access, Filename, Options) + end. + + lookup_blksize(Options) -> +diff --git a/lib/tftp/test/tftp_SUITE.erl b/lib/tftp/test/tftp_SUITE.erl +index 7289242..655874c 100644 +--- a/lib/tftp/test/tftp_SUITE.erl ++++ b/lib/tftp/test/tftp_SUITE.erl +@@ -20,7 +20,7 @@ + + -module(tftp_SUITE). + +--compile(export_all). ++-compile([export_all, nowarn_export_all]). + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% Includes and defines +@@ -29,18 +29,13 @@ + -include_lib("common_test/include/ct.hrl"). + -include("tftp_test_lib.hrl"). + +--define(START_DAEMON(Port, Options), ++-define(START_DAEMON(Options), + begin +- {ok, Pid} = ?VERIFY({ok, _Pid}, tftp:start([{port, Port} | Options])), +- if +- Port == 0 -> +- {ok, ActualOptions} = ?IGNORE(tftp:info(Pid)), +- {value, {port, ActualPort}} = +- lists:keysearch(port, 1, ActualOptions), +- {ActualPort, Pid}; +- true -> +- {Port, Pid} +- end ++ {ok, Pid} = ?VERIFY({ok, _Pid}, tftp:start([{port, 0} | Options])), ++ {ok, ActualOptions} = ?IGNORE(tftp:info(Pid)), ++ {value, {port, ActualPort}} = ++ lists:keysearch(port, 1, ActualOptions), ++ {ActualPort, Pid} + end). + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +@@ -78,6 +73,7 @@ suite() -> [{ct_hooks,[ts_install_cth]}]. + all() -> + [ + simple, ++ root_dir, + extra, + reuse_connection, + resend_client, +@@ -126,7 +122,7 @@ simple(suite) -> + simple(Config) when is_list(Config) -> + ?VERIFY(ok, application:start(tftp)), + +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, brief}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, brief}])), + + %% Read fail + RemoteFilename = "tftp_temporary_remote_test_file.txt", +@@ -152,6 +148,73 @@ simple(Config) when is_list(Config) -> + ?VERIFY(ok, application:stop(tftp)), + ok. + ++%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% ++%% root_dir ++%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% ++ ++root_dir(doc) -> ++ ["Start the daemon and check the root_dir option."]; ++root_dir(suite) -> ++ []; ++root_dir(Config) when is_list(Config) -> ++ ?VERIFY(ok, application:start(tftp)), ++ PrivDir = get_conf(priv_dir, Config), ++ Root = hd(filename:split(PrivDir)), ++ Up = "..", ++ Remote = "remote.txt", ++ Local = "tftp_temporary_local_test_file.txt", ++ SideDir = fn_jn(PrivDir,tftp_side), ++ RootDir = fn_jn(PrivDir,tftp_root), ++ ?IGNORE(file:del_dir_r(RootDir)), ++ ?IGNORE(file:del_dir_r(SideDir)), ++ ok = filelib:ensure_path(fn_jn(RootDir,sub)), ++ ok = filelib:ensure_path(SideDir), ++ Blob = binary:copy(<<$1>>, 2000), ++ Size = byte_size(Blob), ++ ok = file:write_file(fn_jn(SideDir,Remote), Blob), ++ {Port, DaemonPid} = ++ ?IGNORE(?START_DAEMON([{debug, brief}, ++ {callback, ++ {"", tftp_file, [{root_dir, RootDir}]}}])), ++ try ++ %% Outside root_dir ++ ?VERIFY({error, {client_open, badop, _}}, ++ tftp:read_file( ++ fn_jn([Up,tftp_side,Remote]), binary, [{port, Port}])), ++ ?VERIFY({error, {client_open, badop, _}}, ++ tftp:write_file( ++ fn_jn([Up,tftp_side,Remote]), Blob, [{port, Port}])), ++ %% Nonexistent ++ ?VERIFY({error, {client_open, enoent, _}}, ++ tftp:read_file( ++ fn_jn(sub,Remote), binary, [{port, Port}])), ++ ?VERIFY({error, {client_open, enoent, _}}, ++ tftp:write_file( ++ fn_jn(nonexistent,Remote), Blob, [{port, Port}])), ++ %% Write and read ++ ?VERIFY({ok, Size}, ++ tftp:write_file( ++ fn_jn(sub,Remote), Blob, [{port, Port}])), ++ ?VERIFY({ok, Blob}, ++ tftp:read_file( ++ fn_jn([Root,sub,Remote]), binary, [{port, Port}])), ++ ?VERIFY({ok, Size}, ++ tftp:read_file( ++ fn_jn(sub,Remote), Local, [{port, Port}])), ++ ?VERIFY({ok, Blob}, file:read_file(Local)), ++ ?VERIFY(ok, file:delete(Local)), ++ ?VERIFY(ok, application:stop(tftp)) ++ after ++ %% Cleanup ++ unlink(DaemonPid), ++ exit(DaemonPid, kill), ++ ?IGNORE(file:del_dir_r(SideDir)), ++ ?IGNORE(file:del_dir_r(RootDir)), ++ ?IGNORE(application:stop(tftp)) ++ end, ++ ok. ++ ++ + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% Extra + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +@@ -164,7 +227,7 @@ extra(Config) when is_list(Config) -> + ?VERIFY({'EXIT', {badarg,{fake_key, fake_flag}}}, + tftp:start([{port, 0}, {fake_key, fake_flag}])), + +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, brief}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, brief}])), + + RemoteFilename = "tftp_extra_temporary_remote_test_file.txt", + LocalFilename = "tftp_extra_temporary_local_test_file.txt", +@@ -298,7 +361,7 @@ resend_client(suite) -> + []; + resend_client(Config) when is_list(Config) -> + Host = {127, 0, 0, 1}, +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, all}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, all}])), + + ?VERIFY(ok, resend_read_client(Host, Port, 10)), + ?VERIFY(ok, resend_read_client(Host, Port, 512)), +@@ -418,6 +481,9 @@ resend_read_client(Host, Port, BlkSize) -> + Ack5Bin = <<0, 4, 0, 5>>, + ?VERIFY(ok, gen_udp:send(Socket, Host, NewPort, Ack5Bin)), + ++ %% Recv ACK #6 ++ ?VERIFY({udp, Socket, Host, NewPort, <<0,3,0,6>>}, recv(Timeout)), ++ + %% Close socket + ?VERIFY(ok, gen_udp:close(Socket)), + +@@ -693,11 +759,16 @@ resend_read_server(Host, BlkSize) -> + Data6Bin = list_to_binary([0, 3, 0, 6 | Block6]), + ?VERIFY(ok, gen_udp:send(ServerSocket, Host, ClientPort, Data6Bin)), + ++ %% Recv ACK #6 ++ Ack6Bin = <<0, 4, 0, 6>>, ++ ?VERIFY({udp, ServerSocket, Host, ClientPort, Ack6Bin}, recv(Timeout)), ++ + %% Close daemon and server sockets + ?VERIFY(ok, gen_udp:close(ServerSocket)), + ?VERIFY(ok, gen_udp:close(DaemonSocket)), + +- ?VERIFY({ClientPid, {tftp_client_reply, {ok, Blob}}}, recv(Timeout)), ++ ?VERIFY({ClientPid, {tftp_client_reply, {ok, Blob}}}, ++ recv(2 * (Timeout + timer:seconds(1)))), + + ?VERIFY(timeout, recv(Timeout)), + ok. +@@ -859,7 +930,7 @@ reuse_connection(suite) -> + []; + reuse_connection(Config) when is_list(Config) -> + Host = {127, 0, 0, 1}, +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, all}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, all}])), + + RemoteFilename = "reuse_connection.tmp", + BlkSize = 512, +@@ -933,7 +1004,7 @@ large_file(suite) -> + large_file(Config) when is_list(Config) -> + ?VERIFY(ok, application:start(tftp)), + +- {Port, DaemonPid} = ?IGNORE(?START_DAEMON(0, [{debug, brief}])), ++ {Port, DaemonPid} = ?IGNORE(?START_DAEMON([{debug, brief}])), + + %% Read fail + RemoteFilename = "tftp_temporary_large_file_remote_test_file.txt", +@@ -968,3 +1039,15 @@ recv(Timeout) -> + after Timeout -> + timeout + end. ++ ++get_conf(Key, Config) -> ++ Default = make_ref(), ++ case proplists:get_value(Key, Config, Default) of ++ Default -> ++ erlang:error({no_key, Key}); ++ Value -> ++ Value ++ end. ++ ++fn_jn(A, B) -> filename:join(A, B). ++fn_jn(P) -> filename:join(P). +diff --git a/lib/tftp/test/tftp_test_lib.hrl b/lib/tftp/test/tftp_test_lib.hrl +index eb8ed77..743b9a5 100644 +--- a/lib/tftp/test/tftp_test_lib.hrl ++++ b/lib/tftp/test/tftp_test_lib.hrl +@@ -1,7 +1,7 @@ + %% + %% %CopyrightBegin% + %% +-%% Copyright Ericsson AB 2007-2018. All Rights Reserved. ++%% Copyright Ericsson AB 2007-2026. All Rights Reserved. + %% + %% Licensed under the Apache License, Version 2.0 (the "License"); + %% you may not use this file except in compliance with the License. +@@ -24,7 +24,8 @@ + tftp_test_lib:log(Format, Args, ?MODULE, ?LINE)). + + -define(ERROR(Reason), +- tftp_test_lib:error(Reason, ?MODULE, ?LINE)). ++ erlang:error({?MODULE,?LINE,?FUNCTION_NAME,(Reason)})). ++ %% tftp_test_lib:error(Reason, ?MODULE, ?LINE)). + + -define(VERIFY(Expected, Expr), + fun() -> diff -Nru erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23941.patch erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23941.patch --- erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23941.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23941.patch 2026-04-04 13:45:31.000000000 +0000 @@ -0,0 +1,159 @@ +From: Erlang/OTP +Date: Thu, 12 Mar 2026 16:58:29 +0100 +Subject: Merge branch 'whaileee/inets/httpd/http-request-smuggling/OTP-20007' + into maint-27 + +* whaileee/inets/httpd/http-request-smuggling/OTP-20007: + Prevent httpd from parsing HTTP requests when multiple Content-Length headers are present + +Origin: upstream, https://github.com/erlang/otp/commit/a761d391d8d08316cbd7d4a86733ba932b73c45b +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912 +--- + lib/inets/src/http_server/httpd_request.erl | 53 ++++++++++++++-------- + .../src/http_server/httpd_request_handler.erl | 10 ++-- + lib/inets/test/httpd_SUITE.erl | 24 +++++++++- + 3 files changed, 63 insertions(+), 24 deletions(-) + +diff --git a/lib/inets/src/http_server/httpd_request.erl b/lib/inets/src/http_server/httpd_request.erl +index f5f582d..6229928 100644 +--- a/lib/inets/src/http_server/httpd_request.erl ++++ b/lib/inets/src/http_server/httpd_request.erl +@@ -211,7 +211,7 @@ parse_headers(<>, Header, Headers, _, _, + Headers), + {ok, list_to_tuple(lists:reverse([Body, {http_request:headers(FinalHeaders, #http_request_h{}), FinalHeaders} | Result]))}; + NewHeader -> +- case check_header(NewHeader, Options) of ++ case check_header(NewHeader, Headers, Options) of + ok -> + FinalHeaders = lists:filtermap(fun(H) -> + httpd_custom:customize_headers(Customize, request_header, H) +@@ -261,7 +261,7 @@ parse_headers(<>, Header, Headers, Current, Max, + parse_headers(Rest, [Octet], Headers, + Current, Max, Options, Result); + NewHeader -> +- case check_header(NewHeader, Options) of ++ case check_header(NewHeader, Headers, Options) of + ok -> + parse_headers(Rest, [Octet], [NewHeader | Headers], + Current, Max, Options, Result); +@@ -430,23 +430,36 @@ get_persistens(HTTPVersion,ParsedHeader,ConfigDB)-> + default_version()-> + "HTTP/1.1". + +-check_header({"content-length", Value}, Maxsizes) -> +- Max = proplists:get_value(max_content_length, Maxsizes), +- MaxLen = length(integer_to_list(Max)), +- case length(Value) =< MaxLen of +- true -> +- try +- list_to_integer(Value) +- of +- I when I>= 0 -> +- ok; +- _ -> +- {error, {size_error, Max, 411, "negative content-length"}} +- catch _:_ -> +- {error, {size_error, Max, 411, "content-length not an integer"}} +- end; +- false -> +- {error, {size_error, Max, 413, "content-length unreasonably long"}} ++check_header({"content-length", Value}, Headers, MaxSizes) -> ++ case check_parsed_content_length_values(Value, Headers) of ++ true -> ++ check_content_length_value(Value, MaxSizes); ++ false -> ++ {error, {bad_request, 400, "Multiple Content-Length headers with different values"}} + end; +-check_header(_, _) -> ++ ++check_header(_, _, _) -> + ok. ++ ++check_parsed_content_length_values(CurrentValue, Headers) -> ++ ContentLengths = [V || {"content-length", _} = V <- Headers], ++ length([V || {"content-length", Value} = V <- ContentLengths, Value =:= CurrentValue]) =:= length(ContentLengths). ++ ++check_content_length_value(Value, MaxSizes) -> ++ Max = proplists:get_value(max_content_length, MaxSizes), ++ MaxLen = length(integer_to_list(Max)), ++ case length(Value) =< MaxLen of ++ true -> ++ try ++ list_to_integer(Value) ++ of ++ I when I>= 0 -> ++ ok; ++ _ -> ++ {error, {size_error, Max, 411, "negative content-length"}} ++ catch _:_ -> ++ {error, {size_error, Max, 411, "content-length not an integer"}} ++ end; ++ false -> ++ {error, {size_error, Max, 413, "content-length unreasonably long"}} ++ end. +diff --git a/lib/inets/src/http_server/httpd_request_handler.erl b/lib/inets/src/http_server/httpd_request_handler.erl +index 048e6c1..aeb4e2d 100644 +--- a/lib/inets/src/http_server/httpd_request_handler.erl ++++ b/lib/inets/src/http_server/httpd_request_handler.erl +@@ -260,12 +260,16 @@ handle_info({Proto, Socket, Data}, + httpd_response:send_status(NewModData, ErrCode, ErrStr, {max_size, MaxSize}), + {stop, normal, State#state{response_sent = true, + mod = NewModData}}; +- +- {error, {version_error, ErrCode, ErrStr}, Version} -> ++ {error, {version_error, ErrCode, ErrStr}, Version} -> + NewModData = ModData#mod{http_version = Version}, + httpd_response:send_status(NewModData, ErrCode, ErrStr), + {stop, normal, State#state{response_sent = true, +- mod = NewModData}}; ++ mod = NewModData}}; ++ {error, {bad_request, ErrCode, ErrStr}, Version} -> ++ NewModData = ModData#mod{http_version = Version}, ++ httpd_response:send_status(NewModData, ErrCode, ErrStr), ++ {stop, normal, State#state{response_sent = true, ++ mod = NewModData}}; + + {http_chunk = Module, Function, Args} when ChunkState =/= undefined -> + NewState = handle_chunk(Module, Function, Args, State), +diff --git a/lib/inets/test/httpd_SUITE.erl b/lib/inets/test/httpd_SUITE.erl +index d91b8e1..0ee9fcc 100644 +--- a/lib/inets/test/httpd_SUITE.erl ++++ b/lib/inets/test/httpd_SUITE.erl +@@ -126,7 +126,7 @@ groups() -> + disturbing_1_0, + reload_config_file + ]}, +- {post, [], [chunked_post, chunked_chunked_encoded_post, post_204]}, ++ {post, [], [chunked_post, chunked_chunked_encoded_post, post_204, multiple_content_length_header]}, + {basic_auth, [], [basic_auth_1_1, basic_auth_1_0, verify_href_1_1]}, + {auth_api, [], [auth_api_1_1, auth_api_1_0]}, + {auth_api_dets, [], [auth_api_1_1, auth_api_1_0]}, +@@ -2027,6 +2027,28 @@ tls_alert(Config) when is_list(Config) -> + Port = proplists:get_value(port, Config), + {error, {tls_alert, _}} = ssl:connect("localhost", Port, [{verify, verify_peer} | SSLOpts]). + ++%%------------------------------------------------------------------------- ++multiple_content_length_header() -> ++ [{doc, "Test Content-Length header"}]. ++ ++multiple_content_length_header(Config) when is_list(Config) -> ++ ok = http_status("POST / ", ++ {"Content-Length:0" ++ "\r\n", ++ ""}, ++ [{http_version, "HTTP/1.1"} |Config], ++ [{statuscode, 501}]), ++ ok = http_status("POST / ", ++ {"Content-Length:0" ++ "\r\n" ++ ++ "Content-Length:0" ++ "\r\n", ++ ""}, ++ [{http_version, "HTTP/1.1"} |Config], ++ [{statuscode, 501}]), ++ ok = http_status("POST / ", ++ {"Content-Length:1" ++ "\r\n" ++ ++ "Content-Length:0" ++ "\r\n", ++ "Z"}, ++ [{http_version, "HTTP/1.1"} |Config], ++ [{statuscode, 400}]). + %%-------------------------------------------------------------------- + %% Internal functions ----------------------------------- + %%-------------------------------------------------------------------- diff -Nru erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23942.patch erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23942.patch --- erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23942.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23942.patch 2026-04-04 13:45:31.000000000 +0000 @@ -0,0 +1,196 @@ +From: Erlang/OTP +Date: Thu, 12 Mar 2026 16:58:27 +0100 +Subject: Merge branch 'kuba/maint-27/ssh/sftp_path/OTP-20009' into maint-27 + +* kuba/maint-27/ssh/sftp_path/OTP-20009: + ssh: Fix path traversal vulnerability in ssh_sftpd root directory validation + +Origin: upstream, https://github.com/erlang/otp/commit/9e0ac85d3485e7898e0da88a14be0ee2310a3b28 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912 +--- + lib/ssh/doc/guides/hardening.md | 25 +++++++++++++++++++++++ + lib/ssh/src/ssh_sftpd.erl | 25 ++++++++++++++++++----- + lib/ssh/test/ssh_sftpd_SUITE.erl | 44 ++++++++++++++++++++++++++++------------ + 3 files changed, 76 insertions(+), 18 deletions(-) + +--- a/lib/ssh/doc/guides/hardening.md ++++ b/lib/ssh/doc/guides/hardening.md +@@ -241,3 +241,28 @@ + The negotiation (session setup time) time can be limited with the _parameter_ + `NegotiationTimeout` in a call establishing an ssh session, for example + `ssh:connect/3`. ++ ++## SFTP Security ++ ++### Root Directory Isolation ++ ++The [`root`](`m:ssh_sftpd`) option restricts SFTP users to a ++specific directory tree, preventing access to files outside that directory. ++ ++**Example:** ++ ++```erlang ++ssh:daemon(Port, [ ++ {subsystems, [ssh_sftpd:subsystem_spec([{root, "/home/sftpuser"}])]}, ++ ... ++]). ++``` ++ ++**Important:** The `root` option is configured per daemon, not per user. All ++users connecting to the same daemon share the same root directory. For per-user ++isolation, consider running separate daemon instances on different ports or ++using OS-level mechanisms (PAM chroot, containers, file permissions). ++ ++**Defense-in-depth:** For high-security deployments, combine the `root` option ++with OS-level isolation mechanisms such as chroot jails, containers, or ++mandatory access control (SELinux, AppArmor). +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -100,10 +100,15 @@ + data provided by the SFTP client. (Note: limitations might be also + enforced by underlying operating system) + +-- **`root`** - Sets the SFTP root directory. Then the user cannot see any files +- above this root. If, for example, the root directory is set to `/tmp`, then +- the user sees this directory as `/`. If the user then writes `cd /etc`, the +- user moves to `/tmp/etc`. ++- **`root`** - Sets the SFTP root directory. The user cannot access files ++ outside this directory tree. If, for example, the root directory is set to ++ `/tmp`, then the user sees this directory as `/`. If the user then writes ++ `cd /etc`, the user moves to `/tmp/etc`. ++ ++ Note: This provides application-level isolation. For additional security, ++ consider using OS-level chroot or similar mechanisms. See the ++ [SFTP Security](hardening.md#sftp-security) section in the Hardening guide ++ for deployment recommendations. + + - **`sftpd_vsn`** - Sets the SFTP version to use. Defaults to 5. Version 6 is + under development and limited. +@@ -922,7 +927,17 @@ + end. + + is_within_root(Root, File) -> +- lists:prefix(Root, File). ++ RootParts = filename:split(Root), ++ FileParts = filename:split(File), ++ is_prefix_components(RootParts, FileParts). ++ ++%% Verify if request file path is within configured root directory ++is_prefix_components([], _) -> ++ true; ++is_prefix_components([H|T1], [H|T2]) -> ++ is_prefix_components(T1, T2); ++is_prefix_components(_, _) -> ++ false. + + %% Remove leading slash (/), if any, in order to make the filename + %% relative (to the root) +--- a/lib/ssh/test/ssh_sftpd_SUITE.erl ++++ b/lib/ssh/test/ssh_sftpd_SUITE.erl +@@ -33,8 +33,7 @@ + end_per_testcase/2 + ]). + +--export([ +- access_outside_root/1, ++-export([access_outside_root/1, + links/1, + mk_rm_dir/1, + open_close_dir/1, +@@ -160,7 +159,7 @@ + RootDir = filename:join(BaseDir, a), + CWD = filename:join(RootDir, b), + %% Make the directory chain: +- ok = filelib:ensure_dir(filename:join(CWD, tmp)), ++ ok = filelib:ensure_path(CWD), + SubSystems = [ssh_sftpd:subsystem_spec([{root, RootDir}, + {cwd, CWD}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]); +@@ -221,7 +220,12 @@ + [{sftp, {Cm, Channel}}, {sftpd, Sftpd }| Config]. + + end_per_testcase(_TestCase, Config) -> +- catch ssh:stop_daemon(proplists:get_value(sftpd, Config)), ++ try ++ ssh:stop_daemon(proplists:get_value(sftpd, Config)) ++ catch ++ Class:Error:_Stack -> ++ ?CT_LOG("Class = ~p Error = ~p", [Class, Error]) ++ end, + {Cm, Channel} = proplists:get_value(sftp, Config), + ssh_connection:close(Cm, Channel), + ssh:close(Cm), +@@ -688,33 +692,47 @@ + access_outside_root(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + BaseDir = filename:join(PrivDir, access_outside_root), +- %% A file outside the tree below RootDir which is BaseDir/a +- %% Make the file BaseDir/bad : + BadFilePath = filename:join([BaseDir, bad]), + ok = file:write_file(BadFilePath, <<>>), ++ FileInSiblingDir = filename:join([BaseDir, a2, "secret.txt"]), ++ ok = filelib:ensure_dir(FileInSiblingDir), ++ ok = file:write_file(FileInSiblingDir, <<"secret">>), ++ TestFolderStructure = ~""" ++ PrivDir ++ |-- access_outside_root (BaseDir) ++ | |-- a (RootDir folder) ++ | | +-- b (CWD folder) ++ | |-- a2 (sibling folder with name prefix equal to RootDir) ++ | | +-- secret.txt ++ | +-- bad.txt ++ """, ++ ?CT_LOG("TestFolderStructure = ~n~s", [TestFolderStructure]), + {Cm, Channel} = proplists:get_value(sftp, Config), +- %% Try to access a file parallel to the RootDir: +- try_access("/../bad", Cm, Channel, 0), ++ %% Try to access a file parallel to the RootDir using parent traversal: ++ try_access("/../bad.txt", Cm, Channel, 0), + %% Try to access the same file via the CWD which is /b relative to the RootDir: +- try_access("../../bad", Cm, Channel, 1). +- ++ try_access("../../bad.txt", Cm, Channel, 1), ++ %% Try to access sibling folder name prefixed with root dir ++ try_access("/../a2/secret.txt", Cm, Channel, 2), ++ try_access("../../a2/secret.txt", Cm, Channel, 3). + + try_access(Path, Cm, Channel, ReqId) -> + Return = + open_file(Path, Cm, Channel, ReqId, + ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, + ?SSH_FXF_OPEN_EXISTING), +- ct:log("Try open ~p -> ~p",[Path,Return]), ++ ?CT_LOG("Try open ~p -> ~w",[Path,Return]), + case Return of + {ok, <>, _} -> ++ ?CT_LOG("Got the unexpected ?SSH_FXP_HANDLE",[]), + ct:fail("Could open a file outside the root tree!"); + {ok, <>, <<>>} -> + case Code of + ?SSH_FX_FILE_IS_A_DIRECTORY -> +- ct:log("Got the expected SSH_FX_FILE_IS_A_DIRECTORY status",[]), ++ ?CT_LOG("Got the expected SSH_FX_FILE_IS_A_DIRECTORY status",[]), + ok; + ?SSH_FX_FAILURE -> +- ct:log("Got the expected SSH_FX_FAILURE status",[]), ++ ?CT_LOG("Got the expected SSH_FX_FAILURE status",[]), + ok; + _ -> + case Rest of +--- a/lib/ssh/test/ssh_test_lib.hrl ++++ b/lib/ssh/test/ssh_test_lib.hrl +@@ -67,3 +67,14 @@ + ct:log("~p:~p Show file~n~s =~n~s~n", + [?MODULE,?LINE,File__, Contents__]) + end)(File)). ++ ++-define(SSH_TEST_LIB_FORMAT, "(~s ~p:~p in ~p) "). ++-define(SSH_TEST_LIB_ARGS, ++ [erlang:pid_to_list(self()), ?MODULE, ?LINE, ?FUNCTION_NAME]). ++-define(CT_LOG(F), ++ (ct:log(?SSH_TEST_LIB_FORMAT ++ F, ?SSH_TEST_LIB_ARGS, [esc_chars]))). ++-define(CT_LOG(F, Args), ++ (ct:log( ++ ?SSH_TEST_LIB_FORMAT ++ F, ++ ?SSH_TEST_LIB_ARGS ++ Args, ++ [esc_chars]))). diff -Nru erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23943.patch erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23943.patch --- erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23943.patch 1970-01-01 00:00:00.000000000 +0000 +++ erlang-27.3.4.1+dfsg/debian/patches/CVE-2026-23943.patch 2026-04-04 13:45:31.000000000 +0000 @@ -0,0 +1,619 @@ +From: Erlang/OTP +Date: Thu, 12 Mar 2026 16:58:28 +0100 +Subject: Merge branch + 'michal/maint-27/ssh/fix-unbounded-zlib-inflate/OTP-20011' into maint-27 + +* michal/maint-27/ssh/fix-unbounded-zlib-inflate/OTP-20011: + Add test for post-authentication compression + Add information about compression-based attacks to hardening guide + Adjust documentation to mention that zlib is disabled by default + Add tests that verify we disconnect on too large decompressed data + Always run compression test + Disable zlib by default and limit size of decompressed data + +Origin: upstream, https://github.com/erlang/otp/commit/93073c3bd338c60cd2bae715ce6a1d4ffc1a8fd3 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1130912 +--- + lib/ssh/doc/guides/configurations.md | 8 +- + lib/ssh/doc/guides/configure_algos.md | 24 +++--- + lib/ssh/doc/guides/hardening.md | 20 +++++ + lib/ssh/doc/ssh_app.md | 9 +- + lib/ssh/src/ssh_connection_handler.erl | 7 ++ + lib/ssh/src/ssh_transport.erl | 64 +++++++++++++-- + lib/ssh/test/ssh_basic_SUITE.erl | 66 ++++++++------- + lib/ssh/test/ssh_protocol_SUITE.erl | 146 ++++++++++++++++++++++++++++++++- + lib/ssh/test/ssh_trpt_test_lib.erl | 11 ++- + 9 files changed, 299 insertions(+), 56 deletions(-) + +--- a/lib/ssh/doc/guides/configurations.md ++++ b/lib/ssh/doc/guides/configurations.md +@@ -185,8 +185,8 @@ + 'hmac-sha1']}, + {server2client,['hmac-sha2-256','hmac-sha2-512', + 'hmac-sha1']}]}, +- {compression,[{client2server,[none,'zlib@openssh.com',zlib]}, +- {server2client,[none,'zlib@openssh.com',zlib]}]}] ++ {compression,[{client2server,[none,'zlib@openssh.com']}, ++ {server2client,[none,'zlib@openssh.com']}]}] + ``` + + Note that the algorithms in the file `ex2.config` is not yet applied. They will +@@ -202,8 +202,8 @@ + {server2client,['aes192-ctr']}]}, + {mac,[{client2server,['hmac-sha1']}, + {server2client,['hmac-sha1']}]}, +- {compression,[{client2server,[none,'zlib@openssh.com',zlib]}, +- {server2client,[none,'zlib@openssh.com',zlib]}]}] ++ {compression,[{client2server,[none,'zlib@openssh.com']}, ++ {server2client,[none,'zlib@openssh.com']}]}] + 4> + ``` + +--- a/lib/ssh/doc/guides/configure_algos.md ++++ b/lib/ssh/doc/guides/configure_algos.md +@@ -78,7 +78,9 @@ + This list is also divided into two for the both directions + + - **`compression`** - If and how to compress the message. Examples are `none`, +- that is, no compression and `zlib`. ++ that is, no compression, ++ `zlib` for pre-authentication compression (disabled by default), ++ and `'zlib@openssh.com'` for post-authentication compression. + + This list is also divided into two for the both directions + +@@ -120,8 +122,8 @@ + 'hmac-sha1']}, + {server2client,['hmac-sha2-256','hmac-sha2-512', + 'hmac-sha1']}]}, +- {compression,[{client2server,[none,'zlib@openssh.com',zlib]}, +- {server2client,[none,'zlib@openssh.com',zlib]}]}] ++ {compression,[{client2server,[none,'zlib@openssh.com']}, ++ {server2client,[none,'zlib@openssh.com']}]}] + ``` + + {: #example_default_algorithms } +@@ -174,8 +176,8 @@ + 'hmac-sha1']}, + {server2client,['hmac-sha2-256','hmac-sha2-512', + 'hmac-sha1']}]}, +- {compression,[{client2server,[none,'zlib@openssh.com',zlib]}, +- {server2client,[none,'zlib@openssh.com',zlib]}]}] ++ {compression,[{client2server,[none,'zlib@openssh.com']}, ++ {server2client,[none,'zlib@openssh.com']}]}] + ``` + + Note that the unmentioned lists (`public_key`, `cipher`, `mac` and +@@ -209,8 +211,8 @@ + 'hmac-sha1']}, + {server2client,['hmac-sha2-256','hmac-sha2-512', + 'hmac-sha1']}]}, +- {compression,[{client2server,[none,'zlib@openssh.com',zlib]}, +- {server2client,[none,'zlib@openssh.com',zlib]}]}] ++ {compression,[{client2server,[none,'zlib@openssh.com']}, ++ {server2client,[none,'zlib@openssh.com']}]}] + ``` + + Note that both lists in `cipher` has been changed to the provided value +@@ -246,8 +248,8 @@ + 'hmac-sha1']}, + {server2client,['hmac-sha2-256','hmac-sha2-512', + 'hmac-sha1']}]}, +- {compression,[{client2server,[none,'zlib@openssh.com',zlib]}, +- {server2client,[none,'zlib@openssh.com',zlib]}]}] ++ {compression,[{client2server,[none,'zlib@openssh.com']}, ++ {server2client,[none,'zlib@openssh.com']}]}] + ``` + + ### Example 4 +@@ -341,8 +343,8 @@ + 'hmac-sha1']}, + {server2client,['hmac-sha2-256','hmac-sha2-512', + 'hmac-sha1']}]}, +- {compression,[{client2server,[none,'zlib@openssh.com',zlib]}, +- {server2client,[none,'zlib@openssh.com',zlib]}]}] ++ {compression,[{client2server,[none,'zlib@openssh.com']}, ++ {server2client,[none,'zlib@openssh.com']}]}] + ``` + + And the result shows that the Diffie-Hellman Group1 is added at the head of the +--- a/lib/ssh/doc/guides/hardening.md ++++ b/lib/ssh/doc/guides/hardening.md +@@ -93,6 +93,26 @@ + + ![SSH server timeouts](assets/ssh_timeouts.jpg "SSH server timeouts") + ++### Resilience to compression-based attacks ++ ++SSH supports compression of the data stream. ++ ++Reasonable finite [max_sessions](`m:ssh#hardening_daemon_options-max_sessions`) ++option is highly recommended if compression is used to prevent excessive resource ++usage by the compression library. ++See [Counters and parallelism](#counters-and-parallelism). ++ ++The `'zlib@openssh.com'` algorithm is recommended because it only activates ++after successful authentication. ++ ++The `'zlib'` algorithm is not recommended because it activates before ++authentication completes, allowing unauthenticated clients to expose potential ++vulnerabilities in compression libraries, and increases attack surface of ++compression-based side-channel and traffic-analysis attacks. ++ ++In both algorithms decompression is protected by a size limit that prevents ++excessive memory consumption. ++ + ## Verifying the remote daemon (server) in an SSH client + + Every SSH server presents a public key - the _host key_ \- to the client while +--- a/lib/ssh/doc/ssh_app.md ++++ b/lib/ssh/doc/ssh_app.md +@@ -231,7 +231,14 @@ + **Compression algorithms** + - none + - zlib@openssh.com +- - zlib ++ ++The following compression algorithm is disabled by default: ++ ++- (zlib) ++ ++It can be enabled with the ++[preferred_algorithms](`t:ssh:preferred_algorithms_common_option/0`) or ++[modify_algorithms](`t:ssh:modify_algorithms_common_option/0`) options. + + ## Unicode support + +--- a/lib/ssh/src/ssh_connection_handler.erl ++++ b/lib/ssh/src/ssh_connection_handler.erl +@@ -1228,6 +1228,13 @@ + io_lib:format("Bad packet: Size (~p bytes) exceeds max size", + [PacketLen]), + StateName, D0), ++ {stop, Shutdown, D}; ++ ++ {error, exceeds_max_decompressed_size} -> ++ {Shutdown, D} = ++ ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, ++ "Bad packet: Size after decompression exceeds max size", ++ StateName, D0), + {stop, Shutdown, D} + catch + Class:Reason0:Stacktrace -> +--- a/lib/ssh/src/ssh_transport.erl ++++ b/lib/ssh/src/ssh_transport.erl +@@ -193,6 +193,9 @@ + 'ssh-dss' + ]); + ++default_algorithms1(compression) -> ++ supported_algorithms(compression, same(['zlib'])); ++ + default_algorithms1(Alg) -> + supported_algorithms(Alg, []). + +@@ -1449,8 +1452,12 @@ + case unpack(pkt_type(CryptoAlg), mac_type(MacAlg), + DecryptedPfx, EncryptedBuffer, AEAD, TotalNeeded, Ssh0) of + {ok, Payload, NextPacketBytes, Ssh1} -> +- {Ssh, DecompressedPayload} = decompress(Ssh1, Payload), +- {packet_decrypted, DecompressedPayload, NextPacketBytes, Ssh}; ++ case decompress(Ssh1, Payload) of ++ {ok, Ssh, DecompressedPayload} -> ++ {packet_decrypted, DecompressedPayload, NextPacketBytes, Ssh}; ++ Other -> ++ Other ++ end; + Other -> + Other + end. +@@ -1966,15 +1973,56 @@ + {ok, Ssh#ssh{decompress = none, decompress_ctx = undefined}}. + + decompress(#ssh{decompress = none} = Ssh, Data) -> +- {Ssh, Data}; ++ {ok, Ssh, Data}; + decompress(#ssh{decompress = zlib, decompress_ctx = Context} = Ssh, Data) -> +- Decompressed = zlib:inflate(Context, Data), +- {Ssh, list_to_binary(Decompressed)}; ++ case safe_zlib_inflate(Context, Data) of ++ {ok, Decompressed} -> ++ {ok, Ssh, Decompressed}; ++ Other -> ++ Other ++ end; + decompress(#ssh{decompress = 'zlib@openssh.com', authenticated = false} = Ssh, Data) -> +- {Ssh, Data}; ++ {ok, Ssh, Data}; + decompress(#ssh{decompress = 'zlib@openssh.com', decompress_ctx = Context, authenticated = true} = Ssh, Data) -> +- Decompressed = zlib:inflate(Context, Data), +- {Ssh, list_to_binary(Decompressed)}. ++ case safe_zlib_inflate(Context, Data) of ++ {ok, Decompressed} -> ++ {ok, Ssh, Decompressed}; ++ Other -> ++ Other ++ end. ++ ++%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% ++%% Safe decompression loop ++%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% ++ ++safe_zlib_inflate(Context, Data) -> ++ safe_zlib_inflate_loop(Context, {0, []}, zlib:safeInflate(Context, Data)). ++ ++safe_zlib_inflate_loop(Context, {AccLen0, AccData}, {Status, Chunk}) ++ when Status == continue; Status == finished -> ++ ChunkLen = iolist_size(Chunk), ++ AccLen = AccLen0 + ChunkLen, ++ %% RFC 4253 section 6 ++ %% Align with packets that don't use compression, we can process payloads with length ++ %% that required minimum padding. ++ %% From ?SSH_MAX_PACKET_SIZE subtract: ++ %% 1 byte for length of padding_length field ++ %% 4 bytes for minimum allowed length of padding ++ %% We don't subtract: ++ %% 4 bytes for packet_length field - not included in packet_length ++ %% x bytes for mac (size depends on type of used mac) - not included in packet_length ++ case AccLen > (?SSH_MAX_PACKET_SIZE - 5) of ++ true -> ++ {error, exceeds_max_decompressed_size}; ++ false when Status == continue -> ++ Next = zlib:safeInflate(Context, []), ++ safe_zlib_inflate_loop(Context, {AccLen, [Chunk | AccData]}, Next); ++ false when Status == finished -> ++ Reversed = lists:reverse([Chunk | AccData]), ++ {ok, iolist_to_binary(Reversed)} ++ end; ++safe_zlib_inflate_loop(_Context, {_AccLen, _AccData}, {need_dictionary, Adler, _Chunk}) -> ++ erlang:error({need_dictionary, Adler}). + + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %% +--- a/lib/ssh/test/ssh_basic_SUITE.erl ++++ b/lib/ssh/test/ssh_basic_SUITE.erl +@@ -56,6 +56,7 @@ + double_close/1, + exec/1, + exec_compressed/1, ++ exec_compressed_post_auth_compression/1, + exec_with_io_in/1, + exec_with_io_out/1, + host_equal/2, +@@ -153,7 +154,7 @@ + ]}, + + {p_basic, [?PARALLEL], [send, peername_sockname, +- exec, exec_compressed, ++ exec, exec_compressed, exec_compressed_post_auth_compression, + exec_with_io_out, exec_with_io_in, + cli, cli_exit_normal, cli_exit_status, + idle_time_client, idle_time_server, +@@ -401,37 +402,42 @@ + %%-------------------------------------------------------------------- + %%% Test that compression option works + exec_compressed(Config) when is_list(Config) -> +- case ssh_test_lib:ssh_supports(zlib, compression) of +- false -> +- {skip, "zlib compression is not supported"}; ++ exec_compressed_helper(Config, 'zlib'). + +- true -> +- process_flag(trap_exit, true), +- SystemDir = filename:join(proplists:get_value(priv_dir, Config), system), +- UserDir = proplists:get_value(priv_dir, Config), +- +- {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SystemDir},{user_dir, UserDir}, +- {preferred_algorithms,[{compression, [zlib]}]}, +- {failfun, fun ssh_test_lib:failfun/2}]), +- +- ConnectionRef = +- ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, +- {user_dir, UserDir}, +- {user_interaction, false}]), +- {ok, ChannelId} = ssh_connection:session_channel(ConnectionRef, infinity), +- success = ssh_connection:exec(ConnectionRef, ChannelId, +- "1+1.", infinity), +- Data = {ssh_cm, ConnectionRef, {data, ChannelId, 0, <<"2">>}}, +- case ssh_test_lib:receive_exec_result(Data) of +- expected -> +- ok; +- Other -> +- ct:fail(Other) +- end, +- ssh_test_lib:receive_exec_end(ConnectionRef, ChannelId), +- ssh:close(ConnectionRef), +- ssh:stop_daemon(Pid) +- end. ++%%-------------------------------------------------------------------- ++%%% Test that post authentication compression option works ++exec_compressed_post_auth_compression(Config) when is_list(Config) -> ++ exec_compressed_helper(Config, 'zlib@openssh.com'). ++ ++%%-------------------------------------------------------------------- ++%%% Exec compressed helper ++exec_compressed_helper(Config, CompressAlgorithm) -> ++ process_flag(trap_exit, true), ++ SystemDir = filename:join(proplists:get_value(priv_dir, Config), system), ++ UserDir = proplists:get_value(priv_dir, Config), ++ ++ {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SystemDir},{user_dir, UserDir}, ++ {preferred_algorithms,[{compression, [CompressAlgorithm]}]}, ++ {failfun, fun ssh_test_lib:failfun/2}]), ++ ++ ConnectionRef = ++ ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, ++ {user_dir, UserDir}, ++ {user_interaction, false}, ++ {preferred_algorithms,[{compression, [CompressAlgorithm]}]}]), ++ {ok, ChannelId} = ssh_connection:session_channel(ConnectionRef, infinity), ++ success = ssh_connection:exec(ConnectionRef, ChannelId, ++ "1+1.", infinity), ++ Data = {ssh_cm, ConnectionRef, {data, ChannelId, 0, <<"2">>}}, ++ case ssh_test_lib:receive_exec_result(Data) of ++ expected -> ++ ok; ++ Other -> ++ ct:fail(Other) ++ end, ++ ssh_test_lib:receive_exec_end(ConnectionRef, ChannelId), ++ ssh:close(ConnectionRef), ++ ssh:stop_daemon(Pid). + + %%-------------------------------------------------------------------- + %%% Idle timeout test +--- a/lib/ssh/test/ssh_protocol_SUITE.erl ++++ b/lib/ssh/test/ssh_protocol_SUITE.erl +@@ -51,6 +51,10 @@ + client_handles_keyboard_interactive_0_pwds/1, + client_handles_banner_keyboard_interactive/1, + client_info_line/1, ++ decompression_bomb_client/1, ++ decompression_bomb_client_after_auth/1, ++ decompression_bomb_server/1, ++ decompression_bomb_server_after_auth/1, + do_gex_client_init/3, + do_gex_client_init_old/3, + empty_service_name/1, +@@ -138,7 +142,11 @@ + lib_no_match + ]}, + {packet_size_error, [], [packet_length_too_large, +- packet_length_too_short]}, ++ packet_length_too_short, ++ decompression_bomb_client, ++ decompression_bomb_client_after_auth, ++ decompression_bomb_server, ++ decompression_bomb_server_after_auth]}, + {field_size_error, [], [service_name_length_too_large, + service_name_length_too_short]}, + {kex, [], [custom_kexinit, +@@ -231,6 +239,8 @@ + [{preferred_algorithms,[{cipher,?DEFAULT_CIPHERS} + ]} + | Opts]); ++init_per_testcase(decompression_bomb_client, Config) -> ++ start_std_daemon(Config, [{preferred_algorithms, [{compression, ['zlib']}]}]); + init_per_testcase(_TestCase, Config) -> + check_std_daemon_works(Config, ?LINE). + +@@ -246,6 +256,8 @@ + TC == gex_client_old_request_exact ; + TC == gex_client_old_request_noexact -> + stop_std_daemon(Config); ++end_per_testcase(decompression_bomb_client, Config) -> ++ stop_std_daemon(Config); + end_per_testcase(_TestCase, Config) -> + check_std_daemon_works(Config, ?LINE). + +@@ -683,6 +695,138 @@ + ], InitialState). + + %%%-------------------------------------------------------------------- ++decompression_bomb_client(Config) -> ++ {ok, InitialState} = connect_and_kex(Config, ssh_trpt_test_lib:exec([]), ++ [{kex, [?DEFAULT_KEX]}, ++ {cipher, ?DEFAULT_CIPHERS}, ++ {compression, ['zlib']}], dh), ++ %% ?SSH_MAX_PACKET_SIZE - 9 is enough to trigger disconnect because Payload of ssh packet becomes: ++ %% 1 byte message identifier ++ %% 4 bytes length of data field ++ %% ?SSH_MAX_PACKET_SIZE - 9 bytes of data ++ %% This is longer than max decompressed Payload length which is ?SSH_MAX_PACKET_SIZE - 5 ++ %% See more in ssh_transport:safe_zlib_inflate_loop ++ Data = binary:copy(<<0>>, ?SSH_MAX_PACKET_SIZE - 9), ++ {ok, _} = ++ ssh_trpt_test_lib:exec([ ++ {send, #ssh_msg_ignore{data = Data}}, ++ {match, disconnect(), receive_msg} ++ ], InitialState). ++ ++%%%-------------------------------------------------------------------- ++decompression_bomb_client_after_auth(Config) -> ++ {ok, InitialState} = connect_and_kex(Config, ssh_trpt_test_lib:exec([]), ++ [{kex, [?DEFAULT_KEX]}, ++ {cipher, ?DEFAULT_CIPHERS}, ++ {compression, ['zlib@openssh.com']}], dh), ++ {User, Pwd} = server_user_password(Config), ++ {ok, AfterAuthState} = ++ ssh_trpt_test_lib:exec( ++ [{send, #ssh_msg_service_request{name = "ssh-userauth"}}, ++ {match, #ssh_msg_service_accept{name = "ssh-userauth"}, receive_msg}, ++ {send, #ssh_msg_userauth_request{user = User, ++ service = "ssh-connection", ++ method = "password", ++ data = <> ++ }}, ++ {match, #ssh_msg_userauth_success{_='_'}, receive_msg} ++ ], InitialState), ++ %% See explanation in decompression_bomb_client ++ Data = binary:copy(<<0>>, ?SSH_MAX_PACKET_SIZE - 9), ++ {ok, _} = ++ ssh_trpt_test_lib:exec([ ++ {send, #ssh_msg_ignore{data = Data}}, ++ {match, disconnect(), receive_msg} ++ ], AfterAuthState). ++ ++%%%-------------------------------------------------------------------- ++decompression_bomb_server(Config) -> ++ {ok, InitialState} = ssh_trpt_test_lib:exec(listen), ++ HostPort = ssh_trpt_test_lib:server_host_port(InitialState), ++ %% See explanation in decompression_bomb_client ++ Data = binary:copy(<<0>>, ?SSH_MAX_PACKET_SIZE - 9), ++ ServerPid = ++ spawn_link( ++ fun() -> ++ {ok, _} = ++ ssh_trpt_test_lib:exec( ++ [{set_options, [print_ops, print_messages]}, ++ {accept, [{system_dir, system_dir(Config)}, ++ {user_dir, user_dir(Config)}, ++ {preferred_algorithms,[{kex, [?DEFAULT_KEX]}, ++ {cipher, ?DEFAULT_CIPHERS}, ++ {compression, ['zlib']}]}]}, ++ receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {match, #ssh_msg_kexdh_init{_='_'}, receive_msg}, ++ {send, ssh_msg_kexdh_reply}, ++ {send, #ssh_msg_newkeys{}}, ++ {match, #ssh_msg_newkeys{_='_'}, receive_msg}, ++ {send, #ssh_msg_ignore{data = Data}}, ++ {match, disconnect(), receive_msg} ++ ], InitialState) ++ end), ++ Ref = monitor(process, ServerPid), ++ {error, "Protocol error"} = ++ std_connect(HostPort, Config, ++ [{silently_accept_hosts, true}, ++ {user_dir, user_dir(Config)}, ++ {user_interaction, false}, ++ {preferred_algorithms, [{compression,['zlib']}]}]), ++ receive ++ {'DOWN', Ref, process, ServerPid, normal} -> ok ++ end. ++ ++%%%-------------------------------------------------------------------- ++decompression_bomb_server_after_auth(Config) -> ++ {ok, InitialState} = ssh_trpt_test_lib:exec(listen), ++ HostPort = ssh_trpt_test_lib:server_host_port(InitialState), ++ %% See explanation in decompression_bomb_client ++ Data = binary:copy(<<0>>, ?SSH_MAX_PACKET_SIZE - 9), ++ ServerPid = ++ spawn_link( ++ fun() -> ++ {ok ,_} = ++ ssh_trpt_test_lib:exec( ++ [{set_options, [print_ops, print_messages]}, ++ {accept, [{system_dir, system_dir(Config)}, ++ {user_dir, user_dir(Config)}, ++ {preferred_algorithms,[{kex, [?DEFAULT_KEX]}, ++ {cipher, ?DEFAULT_CIPHERS}, ++ {compression, ['zlib@openssh.com']}]}]}, ++ receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {match, #ssh_msg_kexdh_init{_='_'}, receive_msg}, ++ {send, ssh_msg_kexdh_reply}, ++ {send, #ssh_msg_newkeys{}}, ++ {match, #ssh_msg_newkeys{_='_'}, receive_msg}, ++ {match, #ssh_msg_service_request{name="ssh-userauth"}, receive_msg}, ++ {send, #ssh_msg_service_accept{name="ssh-userauth"}}, ++ {match, #ssh_msg_userauth_request{service="ssh-connection", ++ method="none", ++ _='_'}, receive_msg}, ++ {send, #ssh_msg_userauth_success{}}, ++ {send, #ssh_msg_ignore{data = Data}}, ++ {match, disconnect(), receive_msg} ++ ], InitialState) ++ end), ++ Ref = monitor(process, ServerPid), ++ {ok, _} = ++ std_connect(HostPort, Config, ++ [{silently_accept_hosts, true}, ++ {user_dir, user_dir(Config)}, ++ {user_interaction, false}, ++ {preferred_algorithms, [{compression, ['zlib@openssh.com']}]}]), ++ receive ++ {'DOWN', Ref, process, ServerPid, normal} -> ok ++ end. ++ ++%%%-------------------------------------------------------------------- + service_name_length_too_large(Config) -> bad_service_name_length(Config, +4). + + service_name_length_too_short(Config) -> bad_service_name_length(Config, -4). +@@ -1528,12 +1672,14 @@ + connect_and_kex(Config, ssh_trpt_test_lib:exec([]) ). + + connect_and_kex(Config, InitialState) -> ++ ClientAlgs = [{kex,[?DEFAULT_KEX]}, {cipher,?DEFAULT_CIPHERS}], ++ connect_and_kex(Config, InitialState, ClientAlgs, dh). ++ ++connect_and_kex(Config, InitialState, ClientAlgs, Variant) -> + ssh_trpt_test_lib:exec( + [{connect, + server_host(Config),server_port(Config), +- [{preferred_algorithms,[{kex,[?DEFAULT_KEX]}, +- {cipher,?DEFAULT_CIPHERS} +- ]}, ++ [{preferred_algorithms,ClientAlgs}, + {silently_accept_hosts, true}, + {recv_ext_info, false}, + {user_dir, user_dir(Config)}, +@@ -1543,14 +1689,20 @@ + receive_hello, + {send, hello}, + {send, ssh_msg_kexinit}, +- {match, #ssh_msg_kexinit{_='_'}, receive_msg}, +- {send, ssh_msg_kexdh_init}, +- {match,# ssh_msg_kexdh_reply{_='_'}, receive_msg}, +- {send, #ssh_msg_newkeys{}}, +- {match, #ssh_msg_newkeys{_='_'}, receive_msg} +- ], ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}] ++ ++ get_kex_variant_ops(Variant) ++ ++ [{send, #ssh_msg_newkeys{}}, ++ {match, #ssh_msg_newkeys{_='_'}, receive_msg} ++ ], + InitialState). + ++get_kex_variant_ops(dh) -> ++ [{send, ssh_msg_kexdh_init}, ++ {match, #ssh_msg_kexdh_reply{_='_'}, receive_msg}]; ++get_kex_variant_ops(ecdh) -> ++ [{send, ssh_msg_kex_ecdh_init}, ++ {match, #ssh_msg_kex_ecdh_reply{_='_'}, receive_msg}]. ++ + channel_close_timeout(Config) -> + {User,_Pwd} = server_user_password(Config), + %% Create a listening socket as server socket: +--- a/lib/ssh/test/ssh_trpt_test_lib.erl ++++ b/lib/ssh/test/ssh_trpt_test_lib.erl +@@ -446,7 +446,13 @@ + fun(X) when X==true;X==detail -> {"Send~n~s~n",[format_msg(Msg)]} end), + {ok, Packet, C} = ssh_transport:new_keys_message(S#s.ssh), + send_bytes(Packet, S#s{ssh = C}); +- ++ ++send(S0, #ssh_msg_userauth_success{} = Msg) -> ++ S = opt(print_messages, S0, ++ fun(X) when X==true;X==detail -> {"Send~n~s~n",[format_msg(Msg)]} end), ++ {Packet, C} = ssh_transport:ssh_packet(Msg, S#s.ssh), ++ send_bytes(Packet, S#s{ssh = C#ssh{authenticated = true}, return_value = Msg}); ++ + send(S0, Msg) when is_tuple(Msg) -> + S = opt(print_messages, S0, + fun(X) when X==true;X==detail -> {"Send~n~s~n",[format_msg(Msg)]} end), +@@ -512,6 +518,9 @@ + #ssh_msg_newkeys{} -> + {ok, C} = ssh_transport:handle_new_keys(PeerMsg, S#s.ssh), + S#s{ssh=C}; ++ #ssh_msg_userauth_success{} -> % Always the client ++ C = S#s.ssh, ++ S#s{ssh = C#ssh{authenticated = true}}; + _ -> + S + end diff -Nru erlang-27.3.4.1+dfsg/debian/patches/series erlang-27.3.4.1+dfsg/debian/patches/series --- erlang-27.3.4.1+dfsg/debian/patches/series 2025-07-08 07:27:28.000000000 +0000 +++ erlang-27.3.4.1+dfsg/debian/patches/series 2026-04-04 13:45:31.000000000 +0000 @@ -9,3 +9,7 @@ CVE-2025-48039.patch CVE-2025-48040.patch CVE-2025-48041.patch +CVE-2026-21620.patch +CVE-2026-23941.patch +CVE-2026-23942.patch +CVE-2026-23943.patch