From jspricke at debian.org Tue Feb 10 10:36:15 2026 From: jspricke at debian.org (Jochen Sprickerhof) Date: Tue, 10 Feb 2026 11:36:15 +0100 Subject: [Pkg-erlang-devel] Bug#1127606: trixie-pu: package erlang/1:27.3.4.1+dfsg-1 Message-ID: <177071977539.25395.5153033524660630239.reportbug@fenchel> Package: release.debian.org Severity: normal Tags: trixie X-Debbugs-Cc: erlang at packages.debian.org, Sergei Golovan , Bastien Roucaries Control: affects -1 + src:erlang User: release.debian.org at packages.debian.org Usertags: pu [ Reason ] There have been several CVEs published for the erlang programming language that have been flagged as no DSA or unimportant affecting the http and ssh server implementations. [ Impact ] Untrusted user data processing and denial of service attacks. [ Tests ] Manually tested. [ Risks ] Low risk, given that those implementations are niche and the patches mostly add safe guards. [ Checklist ] [X] *all* changes are documented in the d/changelog [X] I reviewed all changes and I approve them [X] attach debdiff against the package in (old)stable [X] the issue is verified as fixed in unstable [ Other info ] I reached out to Sergei before sending this PU but got no answer. Bastien merged those patches in Salsa so I assume it is fine to send this now. @Sergei please reply if you disagree. -------------- next part -------------- diff --git a/debian/changelog b/debian/changelog index 58b941444e..551ee27b24 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,22 @@ +erlang (1:27.3.4.1+dfsg-1+deb14u1) trixie-proposed-updates; urgency=medium + + * 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). + * Fix CVE-2025-48039: allocation of resources without limits or throttling + vulnerability in the ssh_sftp module allows excessive allocation, + resource leak exposure (closes: #1115092). + * Fix CVE-2025-48040: uncontrolled resource consumption vulnerability in + the ssh_sftp module allows excessive allocation, flooding (closes: 1115091). + * Fix CVE-2025-48041: allocation of resources without limits or throttling + vulnerability in the ssh_sftp module allows excessive allocation, + flooding (closes: #1115090). + * Fix CVE-2016-1000107: inets does not protect applications from the presence + of untrusted client data in the HTTP_PROXY environment variable + (closes: #1115086). + + -- Sergei Golovan Tue, 08 Jul 2025 10:27:28 +0300 + erlang (1:27.3.4.1+dfsg-1) unstable; urgency=medium * New upstream bugfix release. diff --git a/debian/gbp.conf b/debian/gbp.conf new file mode 100644 index 0000000000..cec628c744 --- /dev/null +++ b/debian/gbp.conf @@ -0,0 +1,2 @@ +[DEFAULT] +pristine-tar = True diff --git a/debian/patches/CVE-2016-1000107.patch b/debian/patches/CVE-2016-1000107.patch new file mode 100644 index 0000000000..c598f7d60e --- /dev/null +++ b/debian/patches/CVE-2016-1000107.patch @@ -0,0 +1,217 @@ +From: Upstream (Marcel Lanz and Konrad Pietrzak ) +Subject: A mix of patches to fix CVE-2016-1000107 and + to test for it. +Date: Thu, 18 Sep 2025 11:12:13 +0300 +Bug-Debian: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1115086 +Bug: https://github.com/erlang/otp/issues/3392 + +--- a/lib/inets/examples/server_root/cgi-bin/printenv.bat ++++ b/lib/inets/examples/server_root/cgi-bin/printenv.bat +@@ -1,8 +1,33 @@ ++:: ++:: %CopyrightBegin% ++:: ++:: SPDX-License-Identifier: Apache-2.0 ++:: ++:: Copyright Ericsson AB 1997-2025. 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. ++:: You may obtain a copy of the License at ++:: ++:: http://www.apache.org/licenses/LICENSE-2.0 ++:: ++:: Unless required by applicable law or agreed to in writing, software ++:: distributed under the License is distributed on an "AS IS" BASIS, ++:: WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++:: See the License for the specific language governing permissions and ++:: limitations under the License. ++:: ++:: %CopyrightEnd% ++:: ++:: ++ ++ + @echo off + echo tomrad > c:\cygwin\tmp\hej + echo Content-type: text/html + echo. + echo ^ ^ ^OS Environment^ ^ ^^ ++set http_proxy=%HTTP_PROXY% + set + echo ^^^ + +--- a/lib/inets/examples/server_root/cgi-bin/printenv.sh ++++ b/lib/inets/examples/server_root/cgi-bin/printenv.sh +@@ -1,6 +1,31 @@ + #!/bin/sh ++# ++# %CopyrightBegin% ++# ++# SPDX-License-Identifier: Apache-2.0 ++# ++# Copyright Ericsson AB 1997-2025. 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. ++# You may obtain a copy of the License at ++# ++# http://www.apache.org/licenses/LICENSE-2.0 ++# ++# Unless required by applicable law or agreed to in writing, software ++# distributed under the License is distributed on an "AS IS" BASIS, ++# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. ++# See the License for the specific language governing permissions and ++# limitations under the License. ++# ++# %CopyrightEnd% ++# ++# ++ ++ + echo "Content-type: text/html" + echo "" + echo " OS Environment
"
++export http_proxy=$HTTP_PROXY
+ env
+-echo "
" +\ No newline at end of file ++echo "" +--- a/lib/inets/src/http_server/httpd_script_env.erl ++++ b/lib/inets/src/http_server/httpd_script_env.erl +@@ -42,6 +42,8 @@ + %% + %% Description: Creates a list of cgi/esi environment variables and + %% there values. ++%% ++%% Note: "PROXY" header/variable is skipped because of CVE-2016-1000107 + %%-------------------------------------------------------------------------- + create_env(ScriptType, ModData, ScriptElements) -> + create_basic_elements(ScriptType, ModData) +@@ -149,6 +151,8 @@ + create_http_header_elements(ScriptType, [{Name, Value} | Headers], Acc, OtherAcc) + when is_list(Value) -> + try http_env_element(ScriptType, Name, Value) of ++ skipped -> ++ create_http_header_elements(ScriptType, Headers, Acc, OtherAcc); + Element -> + create_http_header_elements(ScriptType, Headers, [Element | Acc], + OtherAcc) +@@ -158,9 +162,16 @@ + [{Name, Value} | OtherAcc]) + end. + +-http_env_element(cgi, VarName0, Value) -> +- VarName = re:replace(VarName0,"-","_", [{return,list}, global]), +- {"HTTP_"++ http_util:to_upper(VarName), Value}; ++http_env_element(cgi, VarName0, Value) -> ++ case http_util:to_upper(VarName0) of ++ "PROXY" -> ++ %% CVE-2016-1000107 ? https://github.com/erlang/otp/issues/3392 ++ skipped; ++ VarName1 -> ++ VarNameUpper = re:replace(VarName1, "-", "_", [{return, list}, global]), ++ {"HTTP_" ++ VarNameUpper, Value} ++ end; ++ + http_env_element(esi, VarName0, Value) -> + list_to_existing_atom(VarName0), + VarName = re:replace(VarName0,"-","_", [{return,list}, global]), +--- a/lib/inets/test/httpd_SUITE.erl ++++ b/lib/inets/test/httpd_SUITE.erl +@@ -42,7 +42,8 @@ + %% Seconds before successful auths timeout. + -define(AUTH_TIMEOUT,5). + -define(URL_START, "http://"). +- ++-define(URL_START_HTTPS, "https://"). ++-define(SSL_NO_VERIFY, {ssl, [{verify, verify_none}]}). + %%-------------------------------------------------------------------- + %% Common Test interface functions ----------------------------------- + %%-------------------------------------------------------------------- +@@ -133,13 +134,14 @@ + {security, [], [security_1_1, security_1_0]}, + {logging, [], [disk_log_internal, disk_log_exists, + disk_log_bad_size, disk_log_bad_file]}, +- {http_1_1, [], [esi_propagate, esi_atom_leak, {group, http_1_1_parallel}] ++ load()}, ++ {http_1_1, [], [esi_propagate, esi_atom_leak, {group, http_1_1_parallel}, ++ cgi_bin_env] ++ load()}, + {http_1_1_parallel, [parallel], + [host, chunked, expect, cgi, cgi_chunked_encoding_test, + trace, range, if_modified_since, mod_esi_chunk_timeout, + esi_put, esi_patch, esi_post, esi_headers] + ++ http_head() ++ http_get()}, +- {http_1_0, [], [{group, http_1_0_parallel} | load()]}, ++ {http_1_0, [], [cgi_bin_env, {group, http_1_0_parallel} | load()]}, + {http_1_0_parallel, [parallel], [host, cgi, trace] ++ http_head() ++ http_get()}, + {http_rel_path_script_alias, [], [cgi]}, + {esi, [], [erl_script_timeout_default, +@@ -1291,6 +1293,51 @@ + [Test301(T) || T <- TestURIs301], + ok. + ++cgi_bin_env() -> ++[{doc, "Test whether HTTP_PROXY header is not applied to an environment ++that runs the cgi script"}]. ++cgi_bin_env(Config) -> ++ Proto = case proplists:get_value(type, Config, undefined) =:= ssl of ++ true -> https; ++ _ -> http ++ end, ++ Cgi = case os:type() of ++ {win32, _} -> ++ "printenv.bat"; ++ _ -> ++ "printenv.sh" ++ end, ++ HttpOpts = case Proto of ++ https -> [?SSL_NO_VERIFY]; ++ _ -> [] ++ end, ++ RandomString = base64:encode(crypto:strong_rand_bytes(9)), ++ Endpoint = "/cgi-bin/" ++ Cgi, ++ Env = os:env(), ++ %% Grab the value of HTTP_PROXY from the environment before the request ++ HttpProxyEnv = proplists:get_value("HTTP_PROXY", Env, undefined), ++ Url = url(Proto, Endpoint, Config), ++ {ok, {_Status, _Headers, Body}} = httpc:request(get, {Url, [{"PROXY", RandomString}, ++ {"proxy", RandomString}]}, ++ HttpOpts, []), ++ %% The script prints the system's environment to the body so we need to ++ %% grab the value of interest ++ HttpEnv = re:split(Body, "\n"), ++ BinSize = size(<<"HTTP_PROXY">>) * 8, ++ %% Filter keys of interest, while converting to proplist ++ EnvProp = [{binary_to_list(Key), binary_to_list(Val)} || ++ <>], ++ %% EnvProp should only have HTTP_PROXY or be an empty list ++ RespHttpProxyEnv = proplists:get_value("HTTP_PROXY", EnvProp, undefined), ++ case HttpProxyEnv of ++ undefined -> ++ %% HTTP_PROXY was not set before the request ++ ?assertEqual([], EnvProp); ++ _ -> ++ %% HTTP_PROXY was set, so ensure it's the same in the body ++ ?assertEqual(HttpProxyEnv, RespHttpProxyEnv) ++ end. + %%------------------------------------------------------------------------- + actions() -> + [{doc, "Test mod_actions"}]. +@@ -1983,10 +2030,15 @@ + %%-------------------------------------------------------------------- + %% Internal functions ----------------------------------- + %%-------------------------------------------------------------------- ++url(https, End, Config) -> ++ ?URL_START_HTTPS ++ url(End, Config); + url(http, End, Config) -> ++ ?URL_START ++ url(End, Config). ++ ++url(End, Config) -> + Port = proplists:get_value(port, Config), + {ok,Host} = inet:gethostname(), +- ?URL_START ++ Host ++ ":" ++ integer_to_list(Port) ++ End. ++ Host ++ ":" ++ integer_to_list(Port) ++ End. + + http_get_url(Port0, HeaderDelay, ChunkDelay, BadChunkDelay) -> + {ok, Host} = inet:gethostname(), diff --git a/debian/patches/CVE-2025-48038.patch b/debian/patches/CVE-2025-48038.patch new file mode 100644 index 0000000000..3d28cbba13 --- /dev/null +++ b/debian/patches/CVE-2025-48038.patch @@ -0,0 +1,26 @@ +From: Upstream (Jakub Witczak ) +Date: Wed, 27 Aug 2025 17:49:08 +0200 +Subject: [PATCH] ssh: verify file handle size limit for client data + - reject handles exceeding 256 bytes (as specified for SFTP) + - fixes CVE-2025-48038 + +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -259,6 +259,17 @@ + handle_data(Type, ChannelId, Data, State#state{pending = <<>>}) + end. + ++%% From draft-ietf-secsh-filexfer-02 "The file handle strings MUST NOT be longer than 256 bytes." ++handle_op(Request, ReqId, <>, State = #state{xf = XF}) ++ when (Request == ?SSH_FXP_CLOSE orelse ++ Request == ?SSH_FXP_FSETSTAT orelse ++ Request == ?SSH_FXP_FSTAT orelse ++ Request == ?SSH_FXP_READ orelse ++ Request == ?SSH_FXP_READDIR orelse ++ Request == ?SSH_FXP_WRITE), ++ HLen > 256 -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_INVALID_HANDLE, "Invalid handle"), ++ State; + handle_op(?SSH_FXP_INIT, Version, B, State) when is_binary(B) -> + XF = State#state.xf, + Vsn = lists:min([XF#ssh_xfer.vsn, Version]), diff --git a/debian/patches/CVE-2025-48039.patch b/debian/patches/CVE-2025-48039.patch new file mode 100644 index 0000000000..11072574d7 --- /dev/null +++ b/debian/patches/CVE-2025-48039.patch @@ -0,0 +1,220 @@ +From: Upstream (Jakub Witczak ) +Date: Fri, 11 Jul 2025 13:59:41 +0200 +Subject: [PATCH] ssh: ssh_sftpd verify path size for client data + - reject max_path exceeding the 4096 limit or according to other option value + - fix CVE-2025-48039 + +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -57,6 +57,7 @@ Specifies a channel process to handle an SFTP subsystem. + file_handler, % atom() - callback module + file_state, % state for the file callback module + max_files, % integer >= 0 max no files sent during READDIR ++ max_path, % integer > 0 - max length of path + options, % from the subsystem declaration + handles % list of open handles + %% handle is either {, directory, {Path, unread|eof}} or +@@ -86,6 +87,11 @@ Options: + limit. If supplied, the number of filenames returned to the SFTP client per + `READDIR` request is limited to at most the given value. + ++- **`max_path`** - The default value is `4096`. Positive integer value ++ represents the maximum path length which cannot be exceeded in ++ 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 +@@ -98,6 +104,7 @@ Options: + Options :: [ {cwd, string()} | + {file_handler, CbMod | {CbMod, FileState}} | + {max_files, integer()} | ++ {max_path, integer()} | + {root, string()} | + {sftpd_vsn, integer()} + ], +@@ -149,8 +156,12 @@ init(Options) -> + {Root0, State0} + end, + MaxLength = proplists:get_value(max_files, Options, 0), ++ MaxPath = proplists:get_value(max_path, Options, 4096), + Vsn = proplists:get_value(sftpd_vsn, Options, 5), +- {ok, State#state{cwd = CWD, root = Root, max_files = MaxLength, ++ {ok, State#state{cwd = CWD, ++ root = Root, ++ max_files = MaxLength, ++ max_path = MaxPath, + options = Options, + handles = [], pending = <<>>, + xf = #ssh_xfer{vsn = Vsn, ext = []}}}. +@@ -270,6 +281,30 @@ handle_op(Request, ReqId, <>, State = #state{xf = XF}) + HLen > 256 -> + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_INVALID_HANDLE, "Invalid handle"), + State; ++handle_op(Request, ReqId, <>, ++ State = #state{max_path = MaxPath, xf = XF}) ++ when (Request == ?SSH_FXP_LSTAT orelse ++ Request == ?SSH_FXP_MKDIR orelse ++ Request == ?SSH_FXP_OPEN orelse ++ Request == ?SSH_FXP_OPENDIR orelse ++ Request == ?SSH_FXP_READLINK orelse ++ Request == ?SSH_FXP_REALPATH orelse ++ Request == ?SSH_FXP_REMOVE orelse ++ Request == ?SSH_FXP_RMDIR orelse ++ Request == ?SSH_FXP_SETSTAT orelse ++ Request == ?SSH_FXP_STAT), ++ PLen > MaxPath -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NO_SUCH_PATH, ++ "No such path"), ++ State; ++handle_op(Request, ReqId, <>, ++ State = #state{max_path = MaxPath, xf = XF}) ++ when (Request == ?SSH_FXP_RENAME orelse ++ Request == ?SSH_FXP_SYMLINK), ++ (PLen > MaxPath orelse PLen2 > MaxPath) -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NO_SUCH_PATH, ++ "No such path"), ++ State; + handle_op(?SSH_FXP_INIT, Version, B, State) when is_binary(B) -> + XF = State#state.xf, + Vsn = lists:min([XF#ssh_xfer.vsn, Version]), +--- a/lib/ssh/test/ssh_sftpd_SUITE.erl ++++ b/lib/ssh/test/ssh_sftpd_SUITE.erl +@@ -43,6 +43,7 @@ + open_file_dir_v6/1, + read_dir/1, + read_file/1, ++ max_path/1, + real_path/1, + relative_path/1, + relpath/1, +@@ -71,9 +72,8 @@ + -define(SSH_TIMEOUT, 5000). + -define(REG_ATTERS, <<0,0,0,0,1>>). + -define(UNIX_EPOCH, 62167219200). +- +--define(is_set(F, Bits), +- ((F) band (Bits)) == (F)). ++-define(MAX_PATH, 200). ++-define(is_set(F, Bits), ((F) band (Bits)) == (F)). + + %%-------------------------------------------------------------------- + %% Common Test interface functions ----------------------------------- +@@ -86,6 +86,7 @@ all() -> + [open_close_file, + open_close_dir, + read_file, ++ max_path, + read_dir, + write_file, + rename_file, +@@ -180,7 +181,8 @@ init_per_testcase(TestCase, Config) -> + {sftpd_vsn, 6}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]); + _ -> +- SubSystems = [ssh_sftpd:subsystem_spec([])], ++ SubSystems = [ssh_sftpd:subsystem_spec( ++ [{max_path, ?MAX_PATH}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]) + end, + +@@ -333,6 +335,23 @@ read_file(Config) when is_list(Config) -> + + {ok, Data} = file:read_file(FileName). + ++%%-------------------------------------------------------------------- ++max_path(Config) when is_list(Config) -> ++ PrivDir = proplists:get_value(priv_dir, Config), ++ FileName = filename:join(PrivDir, "test.txt"), ++ {Cm, Channel} = proplists:get_value(sftp, Config), ++ %% verify max_path limit ++ LongFileName = ++ filename:join(PrivDir, ++ "t" ++ lists:flatten(lists:duplicate(?MAX_PATH, "e")) ++ "st.txt"), ++ {ok, _} = file:copy(FileName, LongFileName), ++ ReqId1 = req_id(), ++ {ok, <>, _} = ++ open_file(LongFileName, Cm, Channel, ReqId1, ++ ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING). ++ + %%-------------------------------------------------------------------- + read_dir(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), +@@ -388,35 +407,33 @@ rename_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), + NewFileName = filename:join(PrivDir, "test1.txt"), +- ReqId = 0, ++ LongFileName = ++ filename:join(PrivDir, ++ "t" ++ lists:flatten(lists:duplicate(?MAX_PATH, "e")) ++ "st.txt"), + {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, ReqId, 6, 0), +- +- NewReqId = ReqId + 1, +- +- {ok, <>, _} = +- rename(NewFileName, FileName, Cm, Channel, NewReqId, 6, +- ?SSH_FXP_RENAME_OVERWRITE), +- +- NewReqId1 = NewReqId + 1, +- file:copy(FileName, NewFileName), +- +- %% No overwrite +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, NewReqId1, 6, +- ?SSH_FXP_RENAME_NATIVE), +- +- NewReqId2 = NewReqId1 + 1, +- +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, NewReqId2, 6, +- ?SSH_FXP_RENAME_ATOMIC). ++ Version = 6, ++ [begin ++ case Action of ++ {Code, AFile, BFile, Flags} -> ++ ReqId = req_id(), ++ ct:log("ReqId = ~p,~nCode = ~p,~nAFile = ~p,~nBFile = ~p,~nFlags = ~p", ++ [ReqId, Code, AFile, BFile, Flags]), ++ {ok, <>, _} = ++ rename(AFile, BFile, Cm, Channel, ReqId, Version, Flags); ++ {file_copy, AFile, BFile} -> ++ {ok, _} = file:copy(AFile, BFile) ++ end ++ end || ++ Action <- ++ [{?SSH_FX_OK, FileName, NewFileName, 0}, ++ {?SSH_FX_OK, NewFileName, FileName, ?SSH_FXP_RENAME_OVERWRITE}, ++ {file_copy, FileName, NewFileName}, ++ %% no overwrite ++ {?SSH_FX_FILE_ALREADY_EXISTS, FileName, NewFileName, ?SSH_FXP_RENAME_NATIVE}, ++ {?SSH_FX_OP_UNSUPPORTED, FileName, NewFileName, ?SSH_FXP_RENAME_ATOMIC}, ++ %% max_path ++ {?SSH_FX_NO_SUCH_PATH, FileName, LongFileName, 0}]], ++ ok. + + %%-------------------------------------------------------------------- + mk_rm_dir(Config) when is_list(Config) -> +@@ -1078,3 +1095,12 @@ encode_file_type(Type) -> + + not_default_permissions() -> + 8#600. %% User read-write-only ++ ++req_id() -> ++ ReqId = ++ case get(req_id) of ++ undefined -> 0; ++ I -> I ++ end, ++ put(req_id, ReqId + 1), ++ ReqId. diff --git a/debian/patches/CVE-2025-48040.patch b/debian/patches/CVE-2025-48040.patch new file mode 100644 index 0000000000..042c73fe7b --- /dev/null +++ b/debian/patches/CVE-2025-48040.patch @@ -0,0 +1,488 @@ +From: Jakub Witczak +Date: Wed, 20 Aug 2025 10:30:55 +0200 +Subject: ssh: key exchange robustness improvements + +- reduce untrusted data processing for non-debug logs +- trim badmatch exceptions to avoid processing potentially malicious data +- terminate with kexinit_error when too many algorithms are received in KEX init message + +origin: backport, https://github.com/erlang/otp/commit/7cd7abb7e19e16b027eaee6a54e1f6fbbe21181a +--- + lib/ssh/src/ssh_connection.erl | 3 +- + lib/ssh/src/ssh_connection_handler.erl | 35 +++++++--- + lib/ssh/src/ssh_lib.erl | 18 ++++- + lib/ssh/src/ssh_message.erl | 42 +++++++----- + lib/ssh/src/ssh_transport.erl | 120 +++++++++++++++++++-------------- + lib/ssh/test/ssh_connection_SUITE.erl | 14 ++-- + 6 files changed, 151 insertions(+), 81 deletions(-) + +diff --git a/lib/ssh/src/ssh_connection.erl b/lib/ssh/src/ssh_connection.erl +index bb644b6..d6be161 100644 +--- a/lib/ssh/src/ssh_connection.erl ++++ b/lib/ssh/src/ssh_connection.erl +@@ -769,10 +769,9 @@ handle_msg(Msg, Connection, server, Ssh = #ssh{authenticated = false}) -> + %% respond by disconnecting, preferably with a proper disconnect message + %% sent to ease troubleshooting. + MsgFun = fun(M) -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len, Ssh#ssh.opts), + io_lib:format("Connection terminated. Unexpected message for unauthenticated user." + " Message: ~w", [M], +- [{chars_limit, MaxLogItemLen}]) ++ [{chars_limit, ssh_lib:max_log_len(Ssh)}]) + end, + ?LOG_DEBUG(MsgFun, [Msg]), + {disconnect, {?SSH_DISCONNECT_PROTOCOL_ERROR, "Connection refused"}, handle_stop(Connection)}; +diff --git a/lib/ssh/src/ssh_connection_handler.erl b/lib/ssh/src/ssh_connection_handler.erl +index d852e46..e32f42c 100644 +--- a/lib/ssh/src/ssh_connection_handler.erl ++++ b/lib/ssh/src/ssh_connection_handler.erl +@@ -1186,12 +1186,21 @@ handle_event(info, {Proto, Sock, NewData}, StateName, + {next_event, internal, Msg} + ]} + catch +- C:E:ST -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len,SshParams#ssh.opts), ++ Class:Reason0:Stacktrace -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Bad packet: Decrypted, but can't decode~n~p:~p~n~p", ++ [Class,Reason,Stacktrace], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]); ++ (_) -> ++ io_lib:format("Bad packet: Decrypted, but can't decode ~p:~p", ++ [Class, Reason], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]) ++ end, + {Shutdown, D} = + ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, +- io_lib:format("Bad packet: Decrypted, but can't decode~n~p:~p~n~p", +- [C,E,ST], [{chars_limit, MaxLogItemLen}]), ++ ?SELECT_MSG(MsgFun), + StateName, D1), + {stop, Shutdown, D} + end; +@@ -1221,12 +1230,20 @@ handle_event(info, {Proto, Sock, NewData}, StateName, + StateName, D0), + {stop, Shutdown, D} + catch +- C:E:ST -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len,SshParams#ssh.opts), ++ Class:Reason0:Stacktrace -> ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Bad packet: Couldn't decrypt~n~p:~p~n~p", ++ [Class,Reason0,Stacktrace], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]); ++ (_) -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ io_lib:format("Bad packet: Couldn't decrypt~n~p:~p", ++ [Class,Reason], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]) ++ end, + {Shutdown, D} = +- ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, +- io_lib:format("Bad packet: Couldn't decrypt~n~p:~p~n~p", +- [C,E,ST], [{chars_limit, MaxLogItemLen}]), ++ ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, ?SELECT_MSG(MsgFun), + StateName, D0), + {stop, Shutdown, D} + end; +diff --git a/lib/ssh/src/ssh_lib.erl b/lib/ssh/src/ssh_lib.erl +index 762bc3e..2c63a06 100644 +--- a/lib/ssh/src/ssh_lib.erl ++++ b/lib/ssh/src/ssh_lib.erl +@@ -31,7 +31,9 @@ + format_time_ms/1, + comp/2, + set_label/1, +- set_label/2 ++ set_label/2, ++ trim_reason/1, ++ max_log_len/1 + ]). + + -include("ssh.hrl"). +@@ -97,3 +99,17 @@ set_label(client, Details) -> + proc_lib:set_label({sshc, Details}); + set_label(server, Details) -> + proc_lib:set_label({sshd, Details}). ++ ++%% We don't want to process badmatch details, potentially containing ++%% malicious data of unknown size ++trim_reason({badmatch, V}) when is_binary(V) -> ++ badmatch; ++trim_reason(E) -> ++ E. ++ ++max_log_len(#ssh{opts = Opts}) -> ++ ?GET_OPT(max_log_item_len, Opts); ++max_log_len(Opts) when is_map(Opts) -> ++ ?GET_OPT(max_log_item_len, Opts). ++ ++ +diff --git a/lib/ssh/src/ssh_message.erl b/lib/ssh/src/ssh_message.erl +index 1ab4100..de5eb8b 100644 +--- a/lib/ssh/src/ssh_message.erl ++++ b/lib/ssh/src/ssh_message.erl +@@ -44,7 +44,7 @@ + + -behaviour(ssh_dbg). + -export([ssh_dbg_trace_points/0, ssh_dbg_flags/1, ssh_dbg_on/1, ssh_dbg_off/1, ssh_dbg_format/2]). +--define(ALG_NAME_LIMIT, 64). ++-define(ALG_NAME_LIMIT, 64). % RFC4251 sec6 + + ucl(B) -> + try unicode:characters_to_list(B) of +@@ -822,23 +822,33 @@ decode_kex_init(<>, Acc, 0) -> + %% See rfc 4253 7.1 + X = 0, + list_to_tuple(lists:reverse([X, erl_boolean(Bool) | Acc])); +-decode_kex_init(<>, Acc, N) -> ++decode_kex_init(<>, Acc, N) when ++ byte_size(Data) < ?MAX_NUM_ALGORITHMS * ?ALG_NAME_LIMIT -> + BinParts = binary:split(Data, <<$,>>, [global]), +- Process = +- fun(<<>>, PAcc) -> +- PAcc; +- (Part, PAcc) -> +- case byte_size(Part) > ?ALG_NAME_LIMIT of +- true -> +- ?LOG_DEBUG("Ignoring too long name", []), ++ AlgCount = length(BinParts), ++ case AlgCount =< ?MAX_NUM_ALGORITHMS of ++ true -> ++ Process = ++ fun(<<>>, PAcc) -> + PAcc; +- false -> +- Name = binary:bin_to_list(Part), +- [Name | PAcc] +- end +- end, +- Names = lists:foldr(Process, [], BinParts), +- decode_kex_init(Rest, [Names | Acc], N - 1). ++ (Part, PAcc) -> ++ case byte_size(Part) =< ?ALG_NAME_LIMIT of ++ true -> ++ Name = binary:bin_to_list(Part), ++ [Name | PAcc]; ++ false -> ++ ?LOG_DEBUG("Ignoring too long name", []), ++ PAcc ++ end ++ end, ++ Names = lists:foldr(Process, [], BinParts), ++ decode_kex_init(Rest, [Names | Acc], N - 1); ++ false -> ++ throw({error, {kexinit_error, N, {alg_count, AlgCount}}}) ++ end; ++decode_kex_init(<>, _Acc, N) -> ++ throw({error, {kexinit, N, {string_size, byte_size(Data)}}}). ++ + + + %%%================================================================ +diff --git a/lib/ssh/src/ssh_transport.erl b/lib/ssh/src/ssh_transport.erl +index ef42ef7..4debef1 100644 +--- a/lib/ssh/src/ssh_transport.erl ++++ b/lib/ssh/src/ssh_transport.erl +@@ -404,8 +404,9 @@ handle_kexinit_msg(#ssh_msg_kexinit{} = CounterPart, #ssh_msg_kexinit{} = Own, + key_exchange_first_msg(Algos#alg.kex, + Ssh#ssh{algorithms = Algos}) + catch +- Class:Error -> +- Msg = kexinit_error(Class, Error, client, Own, CounterPart), ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ Msg = kexinit_error(Class, Reason, client, Own, CounterPart, Ssh), + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, Msg) + end; + +@@ -421,31 +422,38 @@ handle_kexinit_msg(#ssh_msg_kexinit{} = CounterPart, #ssh_msg_kexinit{} = Own, + Algos -> + {ok, Ssh#ssh{algorithms = Algos}} + catch +- Class:Error -> +- Msg = kexinit_error(Class, Error, server, Own, CounterPart), ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ Msg = kexinit_error(Class, Reason, server, Own, CounterPart, Ssh), + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, Msg) + end. + +-kexinit_error(Class, Error, Role, Own, CounterPart) -> ++kexinit_error(Class, Error, Role, Own, CounterPart, Ssh) -> + {Fmt,Args} = + case {Class,Error} of + {error, {badmatch,{false,Alg}}} -> + {Txt,W,C} = alg_info(Role, Alg), +- {"No common ~s algorithm,~n" +- " we have:~n ~s~n" +- " peer have:~n ~s~n", +- [Txt, +- lists:join(", ", element(W,Own)), +- lists:join(", ", element(C,CounterPart)) +- ]}; ++ MsgFun = ++ fun(debug) -> ++ {"No common ~s algorithm,~n" ++ " we have:~n ~s~n" ++ " peer have:~n ~s~n", ++ [Txt, ++ lists:join(", ", element(W,Own)), ++ lists:join(", ", element(C,CounterPart))]}; ++ (_) -> ++ {"No common ~s algorithm", [Txt]} ++ end, ++ ?SELECT_MSG(MsgFun); + _ -> + {"Kexinit failed in ~p: ~p:~p", [Role,Class,Error]} + end, +- try io_lib:format(Fmt, Args) of ++ try io_lib:format(Fmt, Args, [{chars_limit, ssh_lib:max_log_len(Ssh)}]) of + R -> R + catch + _:_ -> +- io_lib:format("Kexinit failed in ~p: ~p:~p", [Role, Class, Error]) ++ io_lib:format("Kexinit failed in ~p: ~p:~p", [Role, Class, Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh)}]) + end. + + alg_info(client, Alg) -> +@@ -597,14 +605,19 @@ handle_kexdh_init(#ssh_msg_kexdh_init{e = E}, + session_id = sid(Ssh1, H)}}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + true -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ MsgFun = ++ fun(debug) -> + io_lib:format("Kexdh init failed, received 'e' out of bounds~n E=~p~n P=~p", +- [E,P]) +- ) ++ [E,P], [{chars_limit, ssh_lib:max_log_len(Opts)}]); ++ (_) -> ++ io_lib:format("Kexdh init failed, received 'e' out of bounds", [], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}] ) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kexdh_reply(#ssh_msg_kexdh_reply{public_host_key = PeerPubHostKey, +@@ -625,14 +638,15 @@ handle_kexdh_reply(#ssh_msg_kexdh_reply{public_host_key = PeerPubHostKey, + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh init failed. Verify host key: ~p",[Error]) ++ io_lib:format("Kexdh init failed. Verify host key: ~p",[Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) + ) + end; + + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Kexdh init failed, received 'f' out of bounds~n F=~p~n P=~p", +- [F,P]) ++ [F,P], [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) + ) + end. + +@@ -658,7 +672,8 @@ handle_kex_dh_gex_request(#ssh_msg_kex_dh_gex_request{min = Min0, + }}; + {error,_} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("No possible diffie-hellman-group-exchange group found",[]) ++ io_lib:format("No possible diffie-hellman-group-exchange group found",[], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}]) + ) + end; + +@@ -690,8 +705,8 @@ handle_kex_dh_gex_request(#ssh_msg_kex_dh_gex_request_old{n = NBits}, + }}; + {error,_} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("No possible diffie-hellman-group-exchange group found",[]) +- ) ++ io_lib:format("No possible diffie-hellman-group-exchange group found",[], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + + handle_kex_dh_gex_request(_, _) -> +@@ -717,7 +732,6 @@ handle_kex_dh_gex_group(#ssh_msg_kex_dh_gex_group{p = P, g = G}, Ssh0) -> + {Public, Private} = generate_key(dh, [P,G,2*Sz]), + {SshPacket, Ssh1} = + ssh_packet(#ssh_msg_kex_dh_gex_init{e = Public}, Ssh0), % Pub = G^Priv mod P (def) +- + {ok, SshPacket, + Ssh1#ssh{keyex_key = {{Private, Public}, {G, P}}}}. + +@@ -748,19 +762,22 @@ handle_kex_dh_gex_init(#ssh_msg_kex_dh_gex_init{e = E}, + }}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- "Kexdh init failed, received 'k' out of bounds" +- ) ++ "Kexdh init failed, received 'k' out of bounds") + end; + true -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh gex init failed, received 'e' out of bounds~n E=~p~n P=~p", +- [E,P]) +- ) ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Kexdh gex init failed, received 'e' out of bounds~n" ++ " E=~p~n P=~p", [E,P]); ++ (_) -> ++ io_lib:format("Kexdh gex init failed, received 'e' out of bounds", []) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kex_dh_gex_reply(#ssh_msg_kex_dh_gex_reply{public_host_key = PeerPubHostKey, +@@ -785,20 +802,18 @@ handle_kex_dh_gex_reply(#ssh_msg_kex_dh_gex_reply{public_host_key = PeerPubHostK + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh gex reply failed. Verify host key: ~p",[Error]) +- ) ++ io_lib:format("Kexdh gex reply failed. Verify host key: ~p", ++ [Error], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end; + + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- "Kexdh gex init failed, 'K' out of bounds" +- ) ++ "Kexdh gex init failed, 'K' out of bounds") + end; + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Kexdh gex init failed, received 'f' out of bounds~n F=~p~n P=~p", +- [F,P]) +- ) ++ [F,P], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end. + + %%%---------------------------------------------------------------- +@@ -832,17 +847,25 @@ handle_kex_ecdh_init(#ssh_msg_kex_ecdh_init{q_c = PeerPublic}, + session_id = sid(Ssh1, H)}}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end + catch +- Class:Error -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ MsgFun = ++ fun(debug) -> + io_lib:format("ECDH compute key failed in server: ~p:~p~n" + "Kex: ~p, Curve: ~p~n" + "PeerPublic: ~p", +- [Class,Error,Kex,Curve,PeerPublic]) +- ) ++ [Class,Reason,Kex,Curve,PeerPublic], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]); ++ (_) -> ++ io_lib:format("ECDH compute key failed in server: ~p:~p", ++ [Class,Reason], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kex_ecdh_reply(#ssh_msg_kex_ecdh_reply{public_host_key = PeerPubHostKey, +@@ -865,15 +888,14 @@ handle_kex_ecdh_reply(#ssh_msg_kex_ecdh_reply{public_host_key = PeerPubHostKey, + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("ECDH reply failed. Verify host key: ~p",[Error]) +- ) ++ io_lib:format("ECDH reply failed. Verify host key: ~p",[Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end + catch + Class:Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Peer ECDH public key seem invalid: ~p:~p", +- [Class,Error]) +- ) ++ [Class,Error], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end. + + +diff --git a/lib/ssh/test/ssh_connection_SUITE.erl b/lib/ssh/test/ssh_connection_SUITE.erl +index ea5beeb..1fe966c 100644 +--- a/lib/ssh/test/ssh_connection_SUITE.erl ++++ b/lib/ssh/test/ssh_connection_SUITE.erl +@@ -350,7 +350,7 @@ get_value(Key, List) -> + print_interesting_events([], Cnt) -> + {ok, Cnt}; + print_interesting_events([#{level := Level} = Event | Tail], Cnt) +- when Level /= info, Level /= notice -> ++ when Level /= info, Level /= notice, Level /= debug -> + ct:log("------------~nInteresting event found:~n~p~n==========~n", [Event]), + print_interesting_events(Tail, Cnt + 1); + print_interesting_events([_|Tail], Cnt) -> +@@ -1633,6 +1633,8 @@ gracefull_invalid_long_start_no_nl(Config) when is_list(Config) -> + end. + + kex_error(Config) -> ++ #{level := Level} = logger:get_primary_config(), ++ ok = logger:set_primary_config(level, debug), + PrivDir = proplists:get_value(priv_dir, Config), + UserDir = filename:join(PrivDir, nopubkey), % to make sure we don't use public-key-auth + file:make_dir(UserDir), +@@ -1653,6 +1655,10 @@ kex_error(Config) -> + ok % Other msg + end, + self()), ++ Cleanup = fun() -> ++ ok = logger:remove_handler(kex_error), ++ ok = logger:set_primary_config(level, Level) ++ end, + try + ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, + {user, "foo"}, +@@ -1670,7 +1676,7 @@ kex_error(Config) -> + %% ok + receive + {Ref, ErrMsgTxt} -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:log("ErrMsgTxt = ~n~s", [ErrMsgTxt]), + Lines = lists:map(fun string:trim/1, string:tokens(ErrMsgTxt, "\n")), + OK = (lists:all(fun(S) -> lists:member(S,Lines) end, +@@ -1688,12 +1694,12 @@ kex_error(Config) -> + ct:fail("unexpected error text msg", []) + end + after 20000 -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:fail("timeout", []) + end; + + error:{badmatch,{error,_}} -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:fail("unexpected error msg", []) + end. + diff --git a/debian/patches/CVE-2025-48041.patch b/debian/patches/CVE-2025-48041.patch new file mode 100644 index 0000000000..23ad66f04f --- /dev/null +++ b/debian/patches/CVE-2025-48041.patch @@ -0,0 +1,265 @@ +From: Jakub Witczak +Date: Wed, 20 Aug 2025 10:31:50 +0200 +Subject: ssh: max_handles option added to ssh_sftpd + +- add max_handles option and update tests (1000 by default) +- remove sshd_read_file redundant testcase + +origin: backport, https://github.com/erlang/otp/commit/5f9af63eec4657a37663828d206517828cb9f288 +--- + lib/ssh/src/ssh_sftpd.erl | 37 ++++++++++++++---- + lib/ssh/test/ssh_sftpd_SUITE.erl | 83 ++++++++++++++++++---------------------- + 2 files changed, 67 insertions(+), 53 deletions(-) + +diff --git a/lib/ssh/src/ssh_sftpd.erl b/lib/ssh/src/ssh_sftpd.erl +index 60be295..385df56 100644 +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -57,6 +57,7 @@ Specifies a channel process to handle an SFTP subsystem. + file_handler, % atom() - callback module + file_state, % state for the file callback module + max_files, % integer >= 0 max no files sent during READDIR ++ max_handles, % integer > 0 - max number of file handles + max_path, % integer > 0 - max length of path + options, % from the subsystem declaration + handles % list of open handles +@@ -87,6 +88,13 @@ Options: + limit. If supplied, the number of filenames returned to the SFTP client per + `READDIR` request is limited to at most the given value. + ++- **`max_handles`** - The default value is `1000`. Positive integer ++ value represents the maximum number of file handles allowed for a ++ connection. ++ ++ (Note: separate limitation might be also enforced by underlying ++ operating system) ++ + - **`max_path`** - The default value is `4096`. Positive integer value + represents the maximum path length which cannot be exceeded in + data provided by the SFTP client. (Note: limitations might be also +@@ -104,6 +112,7 @@ Options: + Options :: [ {cwd, string()} | + {file_handler, CbMod | {CbMod, FileState}} | + {max_files, integer()} | ++ {max_handles, integer()} | + {max_path, integer()} | + {root, string()} | + {sftpd_vsn, integer()} +@@ -156,11 +165,13 @@ init(Options) -> + {Root0, State0} + end, + MaxLength = proplists:get_value(max_files, Options, 0), ++ MaxHandles = proplists:get_value(max_handles, Options, 1000), + MaxPath = proplists:get_value(max_path, Options, 4096), + Vsn = proplists:get_value(sftpd_vsn, Options, 5), + {ok, State#state{cwd = CWD, + root = Root, + max_files = MaxLength, ++ max_handles = MaxHandles, + max_path = MaxPath, + options = Options, + handles = [], pending = <<>>, +@@ -328,14 +339,16 @@ handle_op(?SSH_FXP_REALPATH, ReqId, + end; + handle_op(?SSH_FXP_OPENDIR, ReqId, + <>, +- State0 = #state{xf = #ssh_xfer{vsn = Vsn}, +- file_handler = FileMod, file_state = FS0}) -> ++ State0 = #state{xf = #ssh_xfer{vsn = Vsn}, ++ file_handler = FileMod, file_state = FS0, ++ max_handles = MaxHandles}) -> + RelPath = unicode:characters_to_list(RPath), + AbsPath = relate_file_name(RelPath, State0), + + XF = State0#state.xf, + {IsDir, FS1} = FileMod:is_dir(AbsPath, FS0), + State1 = State0#state{file_state = FS1}, ++ HandlesCnt = length(State0#state.handles), + case IsDir of + false when Vsn > 5 -> + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NOT_A_DIRECTORY, +@@ -345,8 +358,12 @@ handle_op(?SSH_FXP_OPENDIR, ReqId, + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_FAILURE, + "Not a directory"), + State1; +- true -> +- add_handle(State1, XF, ReqId, directory, {RelPath,unread}) ++ true when HandlesCnt < MaxHandles -> ++ add_handle(State1, XF, ReqId, directory, {RelPath,unread}); ++ true -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_FAILURE, ++ "max_handles limit reached"), ++ State1 + end; + handle_op(?SSH_FXP_READDIR, ReqId, + <>, +@@ -797,7 +814,9 @@ open(Vsn, ReqId, Data, State) when Vsn >= 4 -> + do_open(ReqId, State, Path, Flags). + + do_open(ReqId, State0, Path, Flags) -> +- #state{file_handler = FileMod, file_state = FS0, xf = #ssh_xfer{vsn = Vsn}} = State0, ++ #state{file_handler = FileMod, file_state = FS0, xf = #ssh_xfer{vsn = Vsn}, ++ max_handles = MaxHandles} = State0, ++ HandlesCnt = length(State0#state.handles), + AbsPath = relate_file_name(Path, State0), + {IsDir, _FS1} = FileMod:is_dir(AbsPath, FS0), + case IsDir of +@@ -809,7 +828,7 @@ do_open(ReqId, State0, Path, Flags) -> + ssh_xfer:xf_send_status(State0#state.xf, ReqId, + ?SSH_FX_FAILURE, "File is a directory"), + State0; +- false -> ++ false when HandlesCnt < MaxHandles -> + OpenFlags = [binary | Flags], + {Res, FS1} = FileMod:open(AbsPath, OpenFlags, FS0), + State1 = State0#state{file_state = FS1}, +@@ -820,7 +839,11 @@ do_open(ReqId, State0, Path, Flags) -> + ssh_xfer:xf_send_status(State1#state.xf, ReqId, + ssh_xfer:encode_erlang_status(Error)), + State1 +- end ++ end; ++ false -> ++ ssh_xfer:xf_send_status(State0#state.xf, ReqId, ++ ?SSH_FX_FAILURE, "max_handles limit reached"), ++ State0 + end. + + %% resolve all symlinks in a path +diff --git a/lib/ssh/test/ssh_sftpd_SUITE.erl b/lib/ssh/test/ssh_sftpd_SUITE.erl +index 850073f..a2ca29c 100644 +--- a/lib/ssh/test/ssh_sftpd_SUITE.erl ++++ b/lib/ssh/test/ssh_sftpd_SUITE.erl +@@ -52,7 +52,6 @@ + retrieve_attributes/1, + root_with_cwd/1, + set_attributes/1, +- sshd_read_file/1, + ver3_open_flags/1, + ver3_rename/1, + ver6_basic/1, +@@ -72,6 +71,7 @@ + -define(SSH_TIMEOUT, 5000). + -define(REG_ATTERS, <<0,0,0,0,1>>). + -define(UNIX_EPOCH, 62167219200). ++-define(MAX_HANDLES, 10). + -define(MAX_PATH, 200). + -define(is_set(F, Bits), ((F) band (Bits)) == (F)). + +@@ -98,8 +98,7 @@ all() -> + links, + ver3_rename, + ver3_open_flags, +- relpath, +- sshd_read_file, ++ relpath, + ver6_basic, + access_outside_root, + root_with_cwd, +@@ -182,7 +181,8 @@ init_per_testcase(TestCase, Config) -> + ssh:daemon(0, [{subsystems, SubSystems}|Options]); + _ -> + SubSystems = [ssh_sftpd:subsystem_spec( +- [{max_path, ?MAX_PATH}])], ++ [{max_handles, ?MAX_HANDLES}, ++ {max_path, ?MAX_PATH}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]) + end, + +@@ -318,22 +318,25 @@ open_close_dir(Config) when is_list(Config) -> + read_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), +- +- ReqId = 0, +- {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- open_file(FileName, Cm, Channel, ReqId, +- ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, +- ?SSH_FXF_OPEN_EXISTING), +- +- NewReqId = 1, +- +- {ok, <>, _} = +- read_file(Handle, 100, 0, Cm, Channel, NewReqId), +- +- {ok, Data} = file:read_file(FileName). ++ {Cm, Channel} = proplists:get_value(sftp, Config), ++ [begin ++ R1 = req_id(), ++ {ok, <>, _} = ++ open_file(FileName, Cm, Channel, R1, ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING), ++ R2 = req_id(), ++ {ok, <>, _} = ++ read_file(Handle, 100, 0, Cm, Channel, R2), ++ {ok, Data} = file:read_file(FileName) ++ end || _I <- lists:seq(0, ?MAX_HANDLES-1)], ++ ReqId = req_id(), ++ {ok, <>, _} = ++ open_file(FileName, Cm, Channel, ReqId, ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING), ++ ct:log("Message: ~s", [Msg]), ++ ok. + + %%-------------------------------------------------------------------- + max_path(Config) when is_list(Config) -> +@@ -356,12 +359,21 @@ max_path(Config) when is_list(Config) -> + read_dir(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + {Cm, Channel} = proplists:get_value(sftp, Config), +- ReqId = 0, +- {ok, <>, _} = +- open_dir(PrivDir, Cm, Channel, ReqId), +- ok = read_dir(Handle, Cm, Channel, ReqId). ++ [begin ++ R1 = req_id(), ++ {ok, <>, _} = ++ open_dir(PrivDir, Cm, Channel, R1), ++ R2 = req_id(), ++ ok = read_dir(Handle, Cm, Channel, R2) ++ end || _I <- lists:seq(0, ?MAX_HANDLES-1)], ++ ReqId = req_id(), ++ {ok, <>, _} = ++ open_dir(PrivDir, Cm, Channel, ReqId), ++ ct:log("Message: ~s", [Msg]), ++ ok. + +-%%-------------------------------------------------------------------- + write_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), +@@ -661,27 +673,6 @@ relpath(Config) when is_list(Config) -> + Root = Path + end. + +-%%-------------------------------------------------------------------- +-sshd_read_file(Config) when is_list(Config) -> +- PrivDir = proplists:get_value(priv_dir, Config), +- FileName = filename:join(PrivDir, "test.txt"), +- +- ReqId = 0, +- {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- open_file(FileName, Cm, Channel, ReqId, +- ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, +- ?SSH_FXF_OPEN_EXISTING), +- +- NewReqId = 1, +- +- {ok, <>, _} = +- read_file(Handle, 100, 0, Cm, Channel, NewReqId), +- +- {ok, Data} = file:read_file(FileName). +-%%-------------------------------------------------------------------- + ver6_basic(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + %FileName = filename:join(PrivDir, "test.txt"), diff --git a/debian/patches/series b/debian/patches/series index 8b5554fbcb..70cd2fcc6c 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -4,3 +4,8 @@ javascript.patch x32.patch doc.patch exdoc.patch +CVE-2016-1000107.patch +CVE-2025-48038.patch +CVE-2025-48039.patch +CVE-2025-48040.patch +CVE-2025-48041.patch diff --git a/debian/salsa-ci.yml b/debian/salsa-ci.yml new file mode 100644 index 0000000000..695edf0eb8 --- /dev/null +++ b/debian/salsa-ci.yml @@ -0,0 +1,6 @@ +--- +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml + +variables: + RELEASE: 'trixie' From jspricke at debian.org Tue Feb 10 10:45:23 2026 From: jspricke at debian.org (Jochen Sprickerhof) Date: Tue, 10 Feb 2026 11:45:23 +0100 Subject: [Pkg-erlang-devel] Bug#1127607: bookworm-pu: package erlang/1:25.2.3+dfsg-1+deb12u3 Message-ID: <177072032364.27529.9755640301766536998.reportbug@fenchel> Package: release.debian.org Severity: normal Tags: bookworm X-Debbugs-Cc: erlang at packages.debian.org, Sergei Golovan , Bastien Roucaries Control: affects -1 + src:erlang User: release.debian.org at packages.debian.org Usertags: pu [ Reason ] There have been several CVEs published for the erlang programming language that have been flagged as no DSA affecting the ssh server implementation. [ Impact ] Mostly denial of service attacks. [ Tests ] Manually tested. [ Risks ] Low risk, given that those implementations are niche and the patches mostly add safe guards. [ Checklist ] [X] *all* changes are documented in the d/changelog [X] I reviewed all changes and I approve them [X] attach debdiff against the package in (old)stable [X] the issue is verified as fixed in unstable [ Other info ] @Sergei as with #1127606 (trixie) please write if you disagree. -------------- next part -------------- diff --git a/debian/changelog b/debian/changelog index 6738cb3c3a..b6b3272c74 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,20 @@ +erlang (1:25.2.3+dfsg-1+deb12u4) bookworm; urgency=medium + + * Non-maintainer upload. + * 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). + * Fix CVE-2025-48039: allocation of resources without limits or throttling + vulnerability in the ssh_sftp module allows excessive allocation, + resource leak exposure (closes: #1115092). + * Fix CVE-2025-48040: uncontrolled resource consumption vulnerability in + the ssh_sftp module allows excessive allocation, flooding (closes: 1115091). + * Fix CVE-2025-48041: allocation of resources without limits or throttling + vulnerability in the ssh_sftp module allows excessive allocation, + flooding (closes: #1115090). + + -- Jochen Sprickerhof Thu, 15 Jan 2026 10:56:30 +0100 + erlang (1:25.2.3+dfsg-1+deb12u3) bookworm-proposed-updates; urgency=medium * Fix FTBFS with newer xsltproc. diff --git a/debian/gbp.conf b/debian/gbp.conf new file mode 100644 index 0000000000..cec628c744 --- /dev/null +++ b/debian/gbp.conf @@ -0,0 +1,2 @@ +[DEFAULT] +pristine-tar = True diff --git a/debian/patches/CVE-2025-48038.patch b/debian/patches/CVE-2025-48038.patch new file mode 100644 index 0000000000..800160e857 --- /dev/null +++ b/debian/patches/CVE-2025-48038.patch @@ -0,0 +1,34 @@ +From: Jakub Witczak +Date: Wed, 27 Aug 2025 17:49:08 +0200 +Subject: ssh: verify file handle size limit for client data + +- reject handles exceeding 256 bytes (as specified for SFTP) + +Origin: https://github.com/erlang/otp/commit/f09e0201ff701993dc24a08f15e524daf72db42f +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-48038 +--- + lib/ssh/src/ssh_sftpd.erl | 11 +++++++++++ + 1 file changed, 11 insertions(+) + +diff --git a/lib/ssh/src/ssh_sftpd.erl b/lib/ssh/src/ssh_sftpd.erl +index 6bcad0d..cd24c3e 100644 +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -222,6 +222,17 @@ handle_data(Type, ChannelId, Data0, State = #state{pending = Pending}) -> + handle_data(Type, ChannelId, Data, State#state{pending = <<>>}) + end. + ++%% From draft-ietf-secsh-filexfer-02 "The file handle strings MUST NOT be longer than 256 bytes." ++handle_op(Request, ReqId, <>, State = #state{xf = XF}) ++ when (Request == ?SSH_FXP_CLOSE orelse ++ Request == ?SSH_FXP_FSETSTAT orelse ++ Request == ?SSH_FXP_FSTAT orelse ++ Request == ?SSH_FXP_READ orelse ++ Request == ?SSH_FXP_READDIR orelse ++ Request == ?SSH_FXP_WRITE), ++ HLen > 256 -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_INVALID_HANDLE, "Invalid handle"), ++ State; + handle_op(?SSH_FXP_INIT, Version, B, State) when is_binary(B) -> + XF = State#state.xf, + Vsn = lists:min([XF#ssh_xfer.vsn, Version]), diff --git a/debian/patches/CVE-2025-48039.patch b/debian/patches/CVE-2025-48039.patch new file mode 100644 index 0000000000..a683c023c0 --- /dev/null +++ b/debian/patches/CVE-2025-48039.patch @@ -0,0 +1,239 @@ +From: Jakub Witczak +Date: Fri, 11 Jul 2025 13:59:41 +0200 +Subject: ssh: ssh_sftpd verify path size for client data + +- reject max_path exceeding the 4096 limit or according to other option value + +Origin: https://github.com/erlang/otp/commit/043ee3c943e2977c1acdd740ad13992fd60b6bf0 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-48039 +--- + lib/ssh/doc/src/ssh_sftpd.xml | 8 ++++ + lib/ssh/src/ssh_sftpd.erl | 32 +++++++++++++- + lib/ssh/test/ssh_sftpd_SUITE.erl | 90 ++++++++++++++++++++++++++-------------- + 3 files changed, 97 insertions(+), 33 deletions(-) + +diff --git a/lib/ssh/doc/src/ssh_sftpd.xml b/lib/ssh/doc/src/ssh_sftpd.xml +index 49a23f4..efabf3f 100644 +--- a/lib/ssh/doc/src/ssh_sftpd.xml ++++ b/lib/ssh/doc/src/ssh_sftpd.xml +@@ -65,6 +65,14 @@ + If supplied, the number of filenames returned to the SFTP client per READDIR + request is limited to at most the given value.

+ ++ max_path ++ ++

The default value is 4096. Positive integer ++ value represents the maximum path length which cannot be ++ exceeded in 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 +diff --git a/lib/ssh/src/ssh_sftpd.erl b/lib/ssh/src/ssh_sftpd.erl +index cd24c3e..5632848 100644 +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -52,6 +52,7 @@ + file_handler, % atom() - callback module + file_state, % state for the file callback module + max_files, % integer >= 0 max no files sent during READDIR ++ max_path, % integer > 0 - max length of path + options, % from the subsystem declaration + handles % list of open handles + %% handle is either {, directory, {Path, unread|eof}} or +@@ -65,6 +66,7 @@ + Options :: [ {cwd, string()} | + {file_handler, CbMod | {CbMod, FileState}} | + {max_files, integer()} | ++ {max_path, integer()} | + {root, string()} | + {sftpd_vsn, integer()} + ], +@@ -115,8 +117,12 @@ init(Options) -> + {Root0, State0} + end, + MaxLength = proplists:get_value(max_files, Options, 0), ++ MaxPath = proplists:get_value(max_path, Options, 4096), + Vsn = proplists:get_value(sftpd_vsn, Options, 5), +- {ok, State#state{cwd = CWD, root = Root, max_files = MaxLength, ++ {ok, State#state{cwd = CWD, ++ root = Root, ++ max_files = MaxLength, ++ max_path = MaxPath, + options = Options, + handles = [], pending = <<>>, + xf = #ssh_xfer{vsn = Vsn, ext = []}}}. +@@ -233,6 +239,30 @@ handle_op(Request, ReqId, <>, State = #state{xf = XF}) + HLen > 256 -> + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_INVALID_HANDLE, "Invalid handle"), + State; ++handle_op(Request, ReqId, <>, ++ State = #state{max_path = MaxPath, xf = XF}) ++ when (Request == ?SSH_FXP_LSTAT orelse ++ Request == ?SSH_FXP_MKDIR orelse ++ Request == ?SSH_FXP_OPEN orelse ++ Request == ?SSH_FXP_OPENDIR orelse ++ Request == ?SSH_FXP_READLINK orelse ++ Request == ?SSH_FXP_REALPATH orelse ++ Request == ?SSH_FXP_REMOVE orelse ++ Request == ?SSH_FXP_RMDIR orelse ++ Request == ?SSH_FXP_SETSTAT orelse ++ Request == ?SSH_FXP_STAT), ++ PLen > MaxPath -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NO_SUCH_PATH, ++ "No such path"), ++ State; ++handle_op(Request, ReqId, <>, ++ State = #state{max_path = MaxPath, xf = XF}) ++ when (Request == ?SSH_FXP_RENAME orelse ++ Request == ?SSH_FXP_SYMLINK), ++ (PLen > MaxPath orelse PLen2 > MaxPath) -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NO_SUCH_PATH, ++ "No such path"), ++ State; + handle_op(?SSH_FXP_INIT, Version, B, State) when is_binary(B) -> + XF = State#state.xf, + Vsn = lists:min([XF#ssh_xfer.vsn, Version]), +diff --git a/lib/ssh/test/ssh_sftpd_SUITE.erl b/lib/ssh/test/ssh_sftpd_SUITE.erl +index 42677b7..f04cde3 100644 +--- a/lib/ssh/test/ssh_sftpd_SUITE.erl ++++ b/lib/ssh/test/ssh_sftpd_SUITE.erl +@@ -43,6 +43,7 @@ + open_file_dir_v6/1, + read_dir/1, + read_file/1, ++ max_path/1, + real_path/1, + relative_path/1, + relpath/1, +@@ -71,9 +72,8 @@ + -define(SSH_TIMEOUT, 10000). + -define(REG_ATTERS, <<0,0,0,0,1>>). + -define(UNIX_EPOCH, 62167219200). +- +--define(is_set(F, Bits), +- ((F) band (Bits)) == (F)). ++-define(MAX_PATH, 200). ++-define(is_set(F, Bits), ((F) band (Bits)) == (F)). + + %%-------------------------------------------------------------------- + %% Common Test interface functions ----------------------------------- +@@ -86,6 +86,7 @@ all() -> + [open_close_file, + open_close_dir, + read_file, ++ max_path, + read_dir, + write_file, + rename_file, +@@ -180,7 +181,8 @@ init_per_testcase(TestCase, Config) -> + {sftpd_vsn, 6}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]); + _ -> +- SubSystems = [ssh_sftpd:subsystem_spec([])], ++ SubSystems = [ssh_sftpd:subsystem_spec( ++ [{max_path, ?MAX_PATH}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]) + end, + +@@ -333,6 +335,23 @@ read_file(Config) when is_list(Config) -> + + {ok, Data} = file:read_file(FileName). + ++%%-------------------------------------------------------------------- ++max_path(Config) when is_list(Config) -> ++ PrivDir = proplists:get_value(priv_dir, Config), ++ FileName = filename:join(PrivDir, "test.txt"), ++ {Cm, Channel} = proplists:get_value(sftp, Config), ++ %% verify max_path limit ++ LongFileName = ++ filename:join(PrivDir, ++ "t" ++ lists:flatten(lists:duplicate(?MAX_PATH, "e")) ++ "st.txt"), ++ {ok, _} = file:copy(FileName, LongFileName), ++ ReqId1 = req_id(), ++ {ok, <>, _} = ++ open_file(LongFileName, Cm, Channel, ReqId1, ++ ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING). ++ + %%-------------------------------------------------------------------- + read_dir(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), +@@ -388,35 +407,33 @@ rename_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), + NewFileName = filename:join(PrivDir, "test1.txt"), +- ReqId = 0, ++ LongFileName = ++ filename:join(PrivDir, ++ "t" ++ lists:flatten(lists:duplicate(?MAX_PATH, "e")) ++ "st.txt"), + {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, ReqId, 6, 0), +- +- NewReqId = ReqId + 1, +- +- {ok, <>, _} = +- rename(NewFileName, FileName, Cm, Channel, NewReqId, 6, +- ?SSH_FXP_RENAME_OVERWRITE), +- +- NewReqId1 = NewReqId + 1, +- file:copy(FileName, NewFileName), +- +- %% No overwrite +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, NewReqId1, 6, +- ?SSH_FXP_RENAME_NATIVE), +- +- NewReqId2 = NewReqId1 + 1, +- +- {ok, <>, _} = +- rename(FileName, NewFileName, Cm, Channel, NewReqId2, 6, +- ?SSH_FXP_RENAME_ATOMIC). ++ Version = 6, ++ [begin ++ case Action of ++ {Code, AFile, BFile, Flags} -> ++ ReqId = req_id(), ++ ct:log("ReqId = ~p,~nCode = ~p,~nAFile = ~p,~nBFile = ~p,~nFlags = ~p", ++ [ReqId, Code, AFile, BFile, Flags]), ++ {ok, <>, _} = ++ rename(AFile, BFile, Cm, Channel, ReqId, Version, Flags); ++ {file_copy, AFile, BFile} -> ++ {ok, _} = file:copy(AFile, BFile) ++ end ++ end || ++ Action <- ++ [{?SSH_FX_OK, FileName, NewFileName, 0}, ++ {?SSH_FX_OK, NewFileName, FileName, ?SSH_FXP_RENAME_OVERWRITE}, ++ {file_copy, FileName, NewFileName}, ++ %% no overwrite ++ {?SSH_FX_FILE_ALREADY_EXISTS, FileName, NewFileName, ?SSH_FXP_RENAME_NATIVE}, ++ {?SSH_FX_OP_UNSUPPORTED, FileName, NewFileName, ?SSH_FXP_RENAME_ATOMIC}, ++ %% max_path ++ {?SSH_FX_NO_SUCH_PATH, FileName, LongFileName, 0}]], ++ ok. + + %%-------------------------------------------------------------------- + mk_rm_dir(Config) when is_list(Config) -> +@@ -1078,3 +1095,12 @@ encode_file_type(Type) -> + + not_default_permissions() -> + 8#600. %% User read-write-only ++ ++req_id() -> ++ ReqId = ++ case get(req_id) of ++ undefined -> 0; ++ I -> I ++ end, ++ put(req_id, ReqId + 1), ++ ReqId. diff --git a/debian/patches/CVE-2025-48040.patch b/debian/patches/CVE-2025-48040.patch new file mode 100644 index 0000000000..d27a182c56 --- /dev/null +++ b/debian/patches/CVE-2025-48040.patch @@ -0,0 +1,477 @@ +From: Jakub Witczak +Date: Wed, 20 Aug 2025 10:30:55 +0200 +Subject: ssh: key exchange robustness improvements + +- reduce untrusted data processing for non-debug logs +- trim badmatch exceptions to avoid processing potentially malicious data +- terminate with kexinit_error when too many algorithms are received in KEX init message + +Origin: https://github.com/erlang/otp/commit/548f1295d86d0803da884db8685cc16d461d0d5a +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-48040 +--- + lib/ssh/src/ssh_connection.erl | 3 +- + lib/ssh/src/ssh_connection_handler.erl | 35 +++++++--- + lib/ssh/src/ssh_lib.erl | 15 ++++- + lib/ssh/src/ssh_message.erl | 42 +++++++----- + lib/ssh/src/ssh_transport.erl | 120 +++++++++++++++++++-------------- + lib/ssh/test/ssh_connection_SUITE.erl | 12 +++- + 6 files changed, 147 insertions(+), 80 deletions(-) + +diff --git a/lib/ssh/src/ssh_connection.erl b/lib/ssh/src/ssh_connection.erl +index badf3a2..b21d249 100644 +--- a/lib/ssh/src/ssh_connection.erl ++++ b/lib/ssh/src/ssh_connection.erl +@@ -481,10 +481,9 @@ handle_msg(Msg, Connection, server, Ssh = #ssh{authenticated = false}) -> + %% respond by disconnecting, preferably with a proper disconnect message + %% sent to ease troubleshooting. + MsgFun = fun(M) -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len, Ssh#ssh.opts), + io_lib:format("Connection terminated. Unexpected message for unauthenticated user." + " Message: ~w", [M], +- [{chars_limit, MaxLogItemLen}]) ++ [{chars_limit, ssh_lib:max_log_len(Ssh)}]) + end, + ?LOG_DEBUG(MsgFun, [Msg]), + {disconnect, {?SSH_DISCONNECT_PROTOCOL_ERROR, "Connection refused"}, handle_stop(Connection)}; +diff --git a/lib/ssh/src/ssh_connection_handler.erl b/lib/ssh/src/ssh_connection_handler.erl +index ba46468..fa3b374 100644 +--- a/lib/ssh/src/ssh_connection_handler.erl ++++ b/lib/ssh/src/ssh_connection_handler.erl +@@ -1146,12 +1146,21 @@ handle_event(info, {Proto, Sock, NewData}, StateName, + {next_event, internal, Msg} + ]} + catch +- C:E:ST -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len,SshParams#ssh.opts), ++ Class:Reason0:Stacktrace -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Bad packet: Decrypted, but can't decode~n~p:~p~n~p", ++ [Class,Reason,Stacktrace], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]); ++ (_) -> ++ io_lib:format("Bad packet: Decrypted, but can't decode ~p:~p", ++ [Class, Reason], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]) ++ end, + {Shutdown, D} = + ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, +- io_lib:format("Bad packet: Decrypted, but can't decode~n~p:~p~n~p", +- [C,E,ST], [{chars_limit, MaxLogItemLen}]), ++ ?SELECT_MSG(MsgFun), + StateName, D1), + {stop, Shutdown, D} + end; +@@ -1181,12 +1190,20 @@ handle_event(info, {Proto, Sock, NewData}, StateName, + StateName, D0), + {stop, Shutdown, D} + catch +- C:E:ST -> +- MaxLogItemLen = ?GET_OPT(max_log_item_len,SshParams#ssh.opts), ++ Class:Reason0:Stacktrace -> ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Bad packet: Couldn't decrypt~n~p:~p~n~p", ++ [Class,Reason0,Stacktrace], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]); ++ (_) -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ io_lib:format("Bad packet: Couldn't decrypt~n~p:~p", ++ [Class,Reason], ++ [{chars_limit, ssh_lib:max_log_len(SshParams)}]) ++ end, + {Shutdown, D} = +- ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, +- io_lib:format("Bad packet: Couldn't decrypt~n~p:~p~n~p", +- [C,E,ST], [{chars_limit, MaxLogItemLen}]), ++ ?send_disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR, ?SELECT_MSG(MsgFun), + StateName, D0), + {stop, Shutdown, D} + end; +diff --git a/lib/ssh/src/ssh_lib.erl b/lib/ssh/src/ssh_lib.erl +index 3d29b5e..c6791f1 100644 +--- a/lib/ssh/src/ssh_lib.erl ++++ b/lib/ssh/src/ssh_lib.erl +@@ -28,7 +28,9 @@ + format_address_port/2, format_address_port/1, + format_address/1, + format_time_ms/1, +- comp/2 ++ comp/2, ++ trim_reason/1, ++ max_log_len/1 + ]). + + -include("ssh.hrl"). +@@ -86,3 +88,14 @@ comp([], [], Truth) -> + + comp(_, _, _) -> + false. ++%% We don't want to process badmatch details, potentially containing ++%% malicious data of unknown size ++trim_reason({badmatch, V}) when is_binary(V) -> ++ badmatch; ++trim_reason(E) -> ++ E. ++ ++max_log_len(#ssh{opts = Opts}) -> ++ ?GET_OPT(max_log_item_len, Opts); ++max_log_len(Opts) when is_map(Opts) -> ++ ?GET_OPT(max_log_item_len, Opts). +diff --git a/lib/ssh/src/ssh_message.erl b/lib/ssh/src/ssh_message.erl +index e22a4e2..4d5ac74 100644 +--- a/lib/ssh/src/ssh_message.erl ++++ b/lib/ssh/src/ssh_message.erl +@@ -43,7 +43,7 @@ + + -behaviour(ssh_dbg). + -export([ssh_dbg_trace_points/0, ssh_dbg_flags/1, ssh_dbg_on/1, ssh_dbg_off/1, ssh_dbg_format/2]). +--define(ALG_NAME_LIMIT, 64). ++-define(ALG_NAME_LIMIT, 64). % RFC4251 sec6 + + ucl(B) -> + try unicode:characters_to_list(B) of +@@ -821,23 +821,33 @@ decode_kex_init(<>, Acc, 0) -> + %% See rfc 4253 7.1 + X = 0, + list_to_tuple(lists:reverse([X, erl_boolean(Bool) | Acc])); +-decode_kex_init(<>, Acc, N) -> ++decode_kex_init(<>, Acc, N) when ++ byte_size(Data) < ?MAX_NUM_ALGORITHMS * ?ALG_NAME_LIMIT -> + BinParts = binary:split(Data, <<$,>>, [global]), +- Process = +- fun(<<>>, PAcc) -> +- PAcc; +- (Part, PAcc) -> +- case byte_size(Part) > ?ALG_NAME_LIMIT of +- true -> +- ?LOG_DEBUG("Ignoring too long name", []), ++ AlgCount = length(BinParts), ++ case AlgCount =< ?MAX_NUM_ALGORITHMS of ++ true -> ++ Process = ++ fun(<<>>, PAcc) -> + PAcc; +- false -> +- Name = binary:bin_to_list(Part), +- [Name | PAcc] +- end +- end, +- Names = lists:foldr(Process, [], BinParts), +- decode_kex_init(Rest, [Names | Acc], N - 1). ++ (Part, PAcc) -> ++ case byte_size(Part) =< ?ALG_NAME_LIMIT of ++ true -> ++ Name = binary:bin_to_list(Part), ++ [Name | PAcc]; ++ false -> ++ ?LOG_DEBUG("Ignoring too long name", []), ++ PAcc ++ end ++ end, ++ Names = lists:foldr(Process, [], BinParts), ++ decode_kex_init(Rest, [Names | Acc], N - 1); ++ false -> ++ throw({error, {kexinit_error, N, {alg_count, AlgCount}}}) ++ end; ++decode_kex_init(<>, _Acc, N) -> ++ throw({error, {kexinit, N, {string_size, byte_size(Data)}}}). ++ + + + %%%================================================================ +diff --git a/lib/ssh/src/ssh_transport.erl b/lib/ssh/src/ssh_transport.erl +index 6081a02..5abc11b 100644 +--- a/lib/ssh/src/ssh_transport.erl ++++ b/lib/ssh/src/ssh_transport.erl +@@ -405,8 +405,9 @@ handle_kexinit_msg(#ssh_msg_kexinit{} = CounterPart, #ssh_msg_kexinit{} = Own, + key_exchange_first_msg(Algos#alg.kex, + Ssh#ssh{algorithms = Algos}) + catch +- Class:Error -> +- Msg = kexinit_error(Class, Error, client, Own, CounterPart), ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ Msg = kexinit_error(Class, Reason, client, Own, CounterPart, Ssh), + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, Msg) + end; + +@@ -422,31 +423,38 @@ handle_kexinit_msg(#ssh_msg_kexinit{} = CounterPart, #ssh_msg_kexinit{} = Own, + Algos -> + {ok, Ssh#ssh{algorithms = Algos}} + catch +- Class:Error -> +- Msg = kexinit_error(Class, Error, server, Own, CounterPart), ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ Msg = kexinit_error(Class, Reason, server, Own, CounterPart, Ssh), + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, Msg) + end. + +-kexinit_error(Class, Error, Role, Own, CounterPart) -> ++kexinit_error(Class, Error, Role, Own, CounterPart, Ssh) -> + {Fmt,Args} = + case {Class,Error} of + {error, {badmatch,{false,Alg}}} -> + {Txt,W,C} = alg_info(Role, Alg), +- {"No common ~s algorithm,~n" +- " we have:~n ~s~n" +- " peer have:~n ~s~n", +- [Txt, +- lists:join(", ", element(W,Own)), +- lists:join(", ", element(C,CounterPart)) +- ]}; ++ MsgFun = ++ fun(debug) -> ++ {"No common ~s algorithm,~n" ++ " we have:~n ~s~n" ++ " peer have:~n ~s~n", ++ [Txt, ++ lists:join(", ", element(W,Own)), ++ lists:join(", ", element(C,CounterPart))]}; ++ (_) -> ++ {"No common ~s algorithm", [Txt]} ++ end, ++ ?SELECT_MSG(MsgFun); + _ -> + {"Kexinit failed in ~p: ~p:~p", [Role,Class,Error]} + end, +- try io_lib:format(Fmt, Args) of ++ try io_lib:format(Fmt, Args, [{chars_limit, ssh_lib:max_log_len(Ssh)}]) of + R -> R + catch + _:_ -> +- io_lib:format("Kexinit failed in ~p: ~p:~p", [Role, Class, Error]) ++ io_lib:format("Kexinit failed in ~p: ~p:~p", [Role, Class, Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh)}]) + end. + + alg_info(client, Alg) -> +@@ -598,14 +606,19 @@ handle_kexdh_init(#ssh_msg_kexdh_init{e = E}, + session_id = sid(Ssh1, H)}}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + true -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ MsgFun = ++ fun(debug) -> + io_lib:format("Kexdh init failed, received 'e' out of bounds~n E=~p~n P=~p", +- [E,P]) +- ) ++ [E,P], [{chars_limit, ssh_lib:max_log_len(Opts)}]); ++ (_) -> ++ io_lib:format("Kexdh init failed, received 'e' out of bounds", [], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}] ) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kexdh_reply(#ssh_msg_kexdh_reply{public_host_key = PeerPubHostKey, +@@ -626,14 +639,15 @@ handle_kexdh_reply(#ssh_msg_kexdh_reply{public_host_key = PeerPubHostKey, + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh init failed. Verify host key: ~p",[Error]) ++ io_lib:format("Kexdh init failed. Verify host key: ~p",[Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) + ) + end; + + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Kexdh init failed, received 'f' out of bounds~n F=~p~n P=~p", +- [F,P]) ++ [F,P], [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) + ) + end. + +@@ -659,7 +673,8 @@ handle_kex_dh_gex_request(#ssh_msg_kex_dh_gex_request{min = Min0, + }}; + {error,_} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("No possible diffie-hellman-group-exchange group found",[]) ++ io_lib:format("No possible diffie-hellman-group-exchange group found",[], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}]) + ) + end; + +@@ -691,8 +706,8 @@ handle_kex_dh_gex_request(#ssh_msg_kex_dh_gex_request_old{n = NBits}, + }}; + {error,_} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("No possible diffie-hellman-group-exchange group found",[]) +- ) ++ io_lib:format("No possible diffie-hellman-group-exchange group found",[], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + + handle_kex_dh_gex_request(_, _) -> +@@ -718,7 +733,6 @@ handle_kex_dh_gex_group(#ssh_msg_kex_dh_gex_group{p = P, g = G}, Ssh0) -> + {Public, Private} = generate_key(dh, [P,G,2*Sz]), + {SshPacket, Ssh1} = + ssh_packet(#ssh_msg_kex_dh_gex_init{e = Public}, Ssh0), % Pub = G^Priv mod P (def) +- + {ok, SshPacket, + Ssh1#ssh{keyex_key = {{Private, Public}, {G, P}}}}. + +@@ -749,19 +763,22 @@ handle_kex_dh_gex_init(#ssh_msg_kex_dh_gex_init{e = E}, + }}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end; + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- "Kexdh init failed, received 'k' out of bounds" +- ) ++ "Kexdh init failed, received 'k' out of bounds") + end; + true -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh gex init failed, received 'e' out of bounds~n E=~p~n P=~p", +- [E,P]) +- ) ++ MsgFun = ++ fun(debug) -> ++ io_lib:format("Kexdh gex init failed, received 'e' out of bounds~n" ++ " E=~p~n P=~p", [E,P]); ++ (_) -> ++ io_lib:format("Kexdh gex init failed, received 'e' out of bounds", []) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kex_dh_gex_reply(#ssh_msg_kex_dh_gex_reply{public_host_key = PeerPubHostKey, +@@ -786,20 +803,18 @@ handle_kex_dh_gex_reply(#ssh_msg_kex_dh_gex_reply{public_host_key = PeerPubHostK + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Kexdh gex reply failed. Verify host key: ~p",[Error]) +- ) ++ io_lib:format("Kexdh gex reply failed. Verify host key: ~p", ++ [Error], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end; + + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- "Kexdh gex init failed, 'K' out of bounds" +- ) ++ "Kexdh gex init failed, 'K' out of bounds") + end; + true -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Kexdh gex init failed, received 'f' out of bounds~n F=~p~n P=~p", +- [F,P]) +- ) ++ [F,P], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end. + + %%%---------------------------------------------------------------- +@@ -833,17 +848,25 @@ handle_kex_ecdh_init(#ssh_msg_kex_ecdh_init{q_c = PeerPublic}, + session_id = sid(Ssh1, H)}}; + {error,unsupported_sign_alg} -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("Unsupported algorithm ~p", [SignAlg]) +- ) ++ io_lib:format("Unsupported algorithm ~p", [SignAlg], ++ [{chars_limit, ssh_lib:max_log_len(Opts)}])) + end + catch +- Class:Error -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ Class:Reason0 -> ++ Reason = ssh_lib:trim_reason(Reason0), ++ MsgFun = ++ fun(debug) -> + io_lib:format("ECDH compute key failed in server: ~p:~p~n" + "Kex: ~p, Curve: ~p~n" + "PeerPublic: ~p", +- [Class,Error,Kex,Curve,PeerPublic]) +- ) ++ [Class,Reason,Kex,Curve,PeerPublic], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]); ++ (_) -> ++ io_lib:format("ECDH compute key failed in server: ~p:~p", ++ [Class,Reason], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}]) ++ end, ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ?SELECT_MSG(MsgFun)) + end. + + handle_kex_ecdh_reply(#ssh_msg_kex_ecdh_reply{public_host_key = PeerPubHostKey, +@@ -866,15 +889,14 @@ handle_kex_ecdh_reply(#ssh_msg_kex_ecdh_reply{public_host_key = PeerPubHostKey, + session_id = sid(Ssh, H)})}; + Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("ECDH reply failed. Verify host key: ~p",[Error]) +- ) ++ io_lib:format("ECDH reply failed. Verify host key: ~p",[Error], ++ [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end + catch + Class:Error -> + ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, + io_lib:format("Peer ECDH public key seem invalid: ~p:~p", +- [Class,Error]) +- ) ++ [Class,Error], [{chars_limit, ssh_lib:max_log_len(Ssh0)}])) + end. + + +diff --git a/lib/ssh/test/ssh_connection_SUITE.erl b/lib/ssh/test/ssh_connection_SUITE.erl +index 06d90cc..d529cf5 100644 +--- a/lib/ssh/test/ssh_connection_SUITE.erl ++++ b/lib/ssh/test/ssh_connection_SUITE.erl +@@ -1345,6 +1345,8 @@ gracefull_invalid_long_start_no_nl(Config) when is_list(Config) -> + end. + + kex_error(Config) -> ++ #{level := Level} = logger:get_primary_config(), ++ ok = logger:set_primary_config(level, debug), + PrivDir = proplists:get_value(priv_dir, Config), + UserDir = filename:join(PrivDir, nopubkey), % to make sure we don't use public-key-auth + file:make_dir(UserDir), +@@ -1365,6 +1367,10 @@ kex_error(Config) -> + ok % Other msg + end, + self()), ++ Cleanup = fun() -> ++ ok = logger:remove_handler(kex_error), ++ ok = logger:set_primary_config(level, Level) ++ end, + try + ssh_test_lib:connect(Host, Port, [{silently_accept_hosts, true}, + {user, "foo"}, +@@ -1382,7 +1388,7 @@ kex_error(Config) -> + %% ok + receive + {Ref, ErrMsgTxt} -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:log("ErrMsgTxt = ~n~s", [ErrMsgTxt]), + Lines = lists:map(fun string:trim/1, string:tokens(ErrMsgTxt, "\n")), + OK = (lists:all(fun(S) -> lists:member(S,Lines) end, +@@ -1400,12 +1406,12 @@ kex_error(Config) -> + ct:fail("unexpected error text msg", []) + end + after 20000 -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:fail("timeout", []) + end; + + error:{badmatch,{error,_}} -> +- ok = logger:remove_handler(kex_error), ++ Cleanup(), + ct:fail("unexpected error msg", []) + end. + diff --git a/debian/patches/CVE-2025-48041.patch b/debian/patches/CVE-2025-48041.patch new file mode 100644 index 0000000000..45ee9b7d0f --- /dev/null +++ b/debian/patches/CVE-2025-48041.patch @@ -0,0 +1,268 @@ +From: Jakub Witczak +Date: Wed, 20 Aug 2025 10:31:50 +0200 +Subject: ssh: max_handles option added to ssh_sftpd + +- add max_handles option and update tests (1000 by default) +- remove sshd_read_file redundant testcase + +Origin: https://github.com/erlang/otp/commit/d49efa2d4fa9e6f7ee658719cd76ffe7a33c2401 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-48041 +--- + lib/ssh/doc/src/ssh_sftpd.xml | 4 ++ + lib/ssh/src/ssh_sftpd.erl | 30 +++++++++++---- + lib/ssh/test/ssh_sftpd_SUITE.erl | 83 ++++++++++++++++++---------------------- + 3 files changed, 64 insertions(+), 53 deletions(-) + +diff --git a/lib/ssh/doc/src/ssh_sftpd.xml b/lib/ssh/doc/src/ssh_sftpd.xml +index efabf3f..7c250a9 100644 +--- a/lib/ssh/doc/src/ssh_sftpd.xml ++++ b/lib/ssh/doc/src/ssh_sftpd.xml +@@ -65,6 +65,10 @@ + If supplied, the number of filenames returned to the SFTP client per READDIR + request is limited to at most the given value.

+
++ max_handles ++ ++

The default value is 1000. Positive integer value represents the maximum number of file handles allowed for a connection.

++
+ max_path + +

The default value is 4096. Positive integer +diff --git a/lib/ssh/src/ssh_sftpd.erl b/lib/ssh/src/ssh_sftpd.erl +index 5632848..dfa566a 100644 +--- a/lib/ssh/src/ssh_sftpd.erl ++++ b/lib/ssh/src/ssh_sftpd.erl +@@ -52,6 +52,7 @@ + file_handler, % atom() - callback module + file_state, % state for the file callback module + max_files, % integer >= 0 max no files sent during READDIR ++ max_handles, % integer > 0 - max number of file handles + max_path, % integer > 0 - max length of path + options, % from the subsystem declaration + handles % list of open handles +@@ -66,6 +67,7 @@ + Options :: [ {cwd, string()} | + {file_handler, CbMod | {CbMod, FileState}} | + {max_files, integer()} | ++ {max_handles, integer()} | + {max_path, integer()} | + {root, string()} | + {sftpd_vsn, integer()} +@@ -117,11 +119,13 @@ init(Options) -> + {Root0, State0} + end, + MaxLength = proplists:get_value(max_files, Options, 0), ++ MaxHandles = proplists:get_value(max_handles, Options, 1000), + MaxPath = proplists:get_value(max_path, Options, 4096), + Vsn = proplists:get_value(sftpd_vsn, Options, 5), + {ok, State#state{cwd = CWD, + root = Root, + max_files = MaxLength, ++ max_handles = MaxHandles, + max_path = MaxPath, + options = Options, + handles = [], pending = <<>>, +@@ -286,14 +290,16 @@ handle_op(?SSH_FXP_REALPATH, ReqId, + end; + handle_op(?SSH_FXP_OPENDIR, ReqId, + <>, +- State0 = #state{xf = #ssh_xfer{vsn = Vsn}, +- file_handler = FileMod, file_state = FS0}) -> ++ State0 = #state{xf = #ssh_xfer{vsn = Vsn}, ++ file_handler = FileMod, file_state = FS0, ++ max_handles = MaxHandles}) -> + RelPath = unicode:characters_to_list(RPath), + AbsPath = relate_file_name(RelPath, State0), + + XF = State0#state.xf, + {IsDir, FS1} = FileMod:is_dir(AbsPath, FS0), + State1 = State0#state{file_state = FS1}, ++ HandlesCnt = length(State0#state.handles), + case IsDir of + false when Vsn > 5 -> + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_NOT_A_DIRECTORY, +@@ -303,8 +309,12 @@ handle_op(?SSH_FXP_OPENDIR, ReqId, + ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_FAILURE, + "Not a directory"), + State1; +- true -> +- add_handle(State1, XF, ReqId, directory, {RelPath,unread}) ++ true when HandlesCnt < MaxHandles -> ++ add_handle(State1, XF, ReqId, directory, {RelPath,unread}); ++ true -> ++ ssh_xfer:xf_send_status(XF, ReqId, ?SSH_FX_FAILURE, ++ "max_handles limit reached"), ++ State1 + end; + handle_op(?SSH_FXP_READDIR, ReqId, + <>, +@@ -755,7 +765,9 @@ open(Vsn, ReqId, Data, State) when Vsn >= 4 -> + do_open(ReqId, State, Path, Flags). + + do_open(ReqId, State0, Path, Flags) -> +- #state{file_handler = FileMod, file_state = FS0, xf = #ssh_xfer{vsn = Vsn}} = State0, ++ #state{file_handler = FileMod, file_state = FS0, xf = #ssh_xfer{vsn = Vsn}, ++ max_handles = MaxHandles} = State0, ++ HandlesCnt = length(State0#state.handles), + AbsPath = relate_file_name(Path, State0), + {IsDir, _FS1} = FileMod:is_dir(AbsPath, FS0), + case IsDir of +@@ -767,7 +779,7 @@ do_open(ReqId, State0, Path, Flags) -> + ssh_xfer:xf_send_status(State0#state.xf, ReqId, + ?SSH_FX_FAILURE, "File is a directory"), + State0; +- false -> ++ false when HandlesCnt < MaxHandles -> + OpenFlags = [binary | Flags], + {Res, FS1} = FileMod:open(AbsPath, OpenFlags, FS0), + State1 = State0#state{file_state = FS1}, +@@ -778,7 +790,11 @@ do_open(ReqId, State0, Path, Flags) -> + ssh_xfer:xf_send_status(State1#state.xf, ReqId, + ssh_xfer:encode_erlang_status(Error)), + State1 +- end ++ end; ++ false -> ++ ssh_xfer:xf_send_status(State0#state.xf, ReqId, ++ ?SSH_FX_FAILURE, "max_handles limit reached"), ++ State0 + end. + + %% resolve all symlinks in a path +diff --git a/lib/ssh/test/ssh_sftpd_SUITE.erl b/lib/ssh/test/ssh_sftpd_SUITE.erl +index f04cde3..fade45b 100644 +--- a/lib/ssh/test/ssh_sftpd_SUITE.erl ++++ b/lib/ssh/test/ssh_sftpd_SUITE.erl +@@ -52,7 +52,6 @@ + retrieve_attributes/1, + root_with_cwd/1, + set_attributes/1, +- sshd_read_file/1, + ver3_open_flags/1, + ver3_rename/1, + ver6_basic/1, +@@ -72,6 +71,7 @@ + -define(SSH_TIMEOUT, 10000). + -define(REG_ATTERS, <<0,0,0,0,1>>). + -define(UNIX_EPOCH, 62167219200). ++-define(MAX_HANDLES, 10). + -define(MAX_PATH, 200). + -define(is_set(F, Bits), ((F) band (Bits)) == (F)). + +@@ -98,8 +98,7 @@ all() -> + links, + ver3_rename, + ver3_open_flags, +- relpath, +- sshd_read_file, ++ relpath, + ver6_basic, + access_outside_root, + root_with_cwd, +@@ -182,7 +181,8 @@ init_per_testcase(TestCase, Config) -> + ssh:daemon(0, [{subsystems, SubSystems}|Options]); + _ -> + SubSystems = [ssh_sftpd:subsystem_spec( +- [{max_path, ?MAX_PATH}])], ++ [{max_handles, ?MAX_HANDLES}, ++ {max_path, ?MAX_PATH}])], + ssh:daemon(0, [{subsystems, SubSystems}|Options]) + end, + +@@ -318,22 +318,25 @@ open_close_dir(Config) when is_list(Config) -> + read_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), +- +- ReqId = 0, +- {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- open_file(FileName, Cm, Channel, ReqId, +- ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, +- ?SSH_FXF_OPEN_EXISTING), +- +- NewReqId = 1, +- +- {ok, <>, _} = +- read_file(Handle, 100, 0, Cm, Channel, NewReqId), +- +- {ok, Data} = file:read_file(FileName). ++ {Cm, Channel} = proplists:get_value(sftp, Config), ++ [begin ++ R1 = req_id(), ++ {ok, <>, _} = ++ open_file(FileName, Cm, Channel, R1, ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING), ++ R2 = req_id(), ++ {ok, <>, _} = ++ read_file(Handle, 100, 0, Cm, Channel, R2), ++ {ok, Data} = file:read_file(FileName) ++ end || _I <- lists:seq(0, ?MAX_HANDLES-1)], ++ ReqId = req_id(), ++ {ok, <>, _} = ++ open_file(FileName, Cm, Channel, ReqId, ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, ++ ?SSH_FXF_OPEN_EXISTING), ++ ct:log("Message: ~s", [Msg]), ++ ok. + + %%-------------------------------------------------------------------- + max_path(Config) when is_list(Config) -> +@@ -356,12 +359,21 @@ max_path(Config) when is_list(Config) -> + read_dir(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + {Cm, Channel} = proplists:get_value(sftp, Config), +- ReqId = 0, +- {ok, <>, _} = +- open_dir(PrivDir, Cm, Channel, ReqId), +- ok = read_dir(Handle, Cm, Channel, ReqId). ++ [begin ++ R1 = req_id(), ++ {ok, <>, _} = ++ open_dir(PrivDir, Cm, Channel, R1), ++ R2 = req_id(), ++ ok = read_dir(Handle, Cm, Channel, R2) ++ end || _I <- lists:seq(0, ?MAX_HANDLES-1)], ++ ReqId = req_id(), ++ {ok, <>, _} = ++ open_dir(PrivDir, Cm, Channel, ReqId), ++ ct:log("Message: ~s", [Msg]), ++ ok. + +-%%-------------------------------------------------------------------- + write_file(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + FileName = filename:join(PrivDir, "test.txt"), +@@ -661,27 +673,6 @@ relpath(Config) when is_list(Config) -> + Root = Path + end. + +-%%-------------------------------------------------------------------- +-sshd_read_file(Config) when is_list(Config) -> +- PrivDir = proplists:get_value(priv_dir, Config), +- FileName = filename:join(PrivDir, "test.txt"), +- +- ReqId = 0, +- {Cm, Channel} = proplists:get_value(sftp, Config), +- +- {ok, <>, _} = +- open_file(FileName, Cm, Channel, ReqId, +- ?ACE4_READ_DATA bor ?ACE4_READ_ATTRIBUTES, +- ?SSH_FXF_OPEN_EXISTING), +- +- NewReqId = 1, +- +- {ok, <>, _} = +- read_file(Handle, 100, 0, Cm, Channel, NewReqId), +- +- {ok, Data} = file:read_file(FileName). +-%%-------------------------------------------------------------------- + ver6_basic(Config) when is_list(Config) -> + PrivDir = proplists:get_value(priv_dir, Config), + %FileName = filename:join(PrivDir, "test.txt"), diff --git a/debian/patches/series b/debian/patches/series index 405ebf00f2..2fdd8154c8 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -16,3 +16,7 @@ ssh-early-RCE-fix.patch ssh-strict-KEX-exchange-hardening.patch zip-sanitize-paths.patch xslt-for-each.patch +CVE-2025-48038.patch +CVE-2025-48039.patch +CVE-2025-48040.patch +CVE-2025-48041.patch diff --git a/debian/salsa-ci.yml b/debian/salsa-ci.yml new file mode 100644 index 0000000000..f437b7ffa5 --- /dev/null +++ b/debian/salsa-ci.yml @@ -0,0 +1,6 @@ +--- +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml + +variables: + RELEASE: 'bookworm'