Hi! I would like to amend the erlang/1:25.2.3+dfsg-1+deb12u2 with additional patch which fixes CVE-2025-4748 (insufficient sanitizing of filepaths when extracting files from archives, see [1]). I'm attaching the patch itself and a cumulative difference to erlang/1:25.2.3+dfsg-1+deb12u1 which is currently in Debian stable.
Cheers! -- Sergei Golovan
diff --git a/debian/changelog b/debian/changelog index 21f9a2b7b..04f8dd176 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,8 +1,10 @@ erlang (1:25.2.3+dfsg-1+deb12u2) bookworm-proposed-updates; urgency=medium * ssh: fix strict KEX hardening (CVE-2025-46712) (closes: #1104963). + * zip: sanitize pathnames when extracting files with absolute pathnames + (CVE-2025-4748) (closes: #1107939). - -- Sergei Golovan <sgolo...@debian.org> Fri, 09 May 2025 09:29:41 +0300 + -- Sergei Golovan <sgolo...@debian.org> Thu, 26 Jun 2025 13:14:36 +0300 erlang (1:25.2.3+dfsg-1+deb12u1) bookworm-security; urgency=high diff --git a/debian/patches/series b/debian/patches/series index 77019206c..6ab90acb4 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -14,3 +14,4 @@ ssh-use-chars_limit-for-bad-packets-error-messages.patch ssh-custom_kexinit-test-added.patch ssh-early-RCE-fix.patch ssh-strict-KEX-exchange-hardening.patch +zip-sanitize-paths.patch diff --git a/debian/patches/zip-sanitize-paths.patch b/debian/patches/zip-sanitize-paths.patch new file mode 100644 index 000000000..804693b92 --- /dev/null +++ b/debian/patches/zip-sanitize-paths.patch @@ -0,0 +1,133 @@ +From: Lukas Backstrom <lu...@erlang.org> +Date: Tue, 27 May 2025 21:50:01 +0200 +Subject: [PATCH] stdlib: Properly sanatize filenames when (un)zipping + According to the Zip APPNOTE filenames "MUST NOT contain a drive or + device letter, or a leading slash.". So we strip those when zipping + and unzipping. +Origin: https://github.com/erlang/otp/commit/ee67d46285394db95133709cef74b0c462d665aa +Bug-Debian: https://bugs.debian.org/1107939 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-4748 + +--- a/lib/stdlib/src/zip.erl ++++ b/lib/stdlib/src/zip.erl +@@ -826,12 +826,12 @@ + get_filename({Name, _, _}, Type) -> + get_filename(Name, Type); + get_filename(Name, regular) -> +- Name; ++ sanitize_filename(Name); + get_filename(Name, directory) -> + %% Ensure trailing slash + case lists:reverse(Name) of +- [$/ | _Rev] -> Name; +- Rev -> lists:reverse([$/ | Rev]) ++ [$/ | _Rev] -> sanitize_filename(Name); ++ Rev -> sanitize_filename(lists:reverse([$/ | Rev])) + end. + + add_cwd(_CWD, {_Name, _} = F) -> F; +@@ -1531,12 +1531,25 @@ + get_file_name_extra(FileNameLen, ExtraLen, B, GPFlag) -> + try + <<BFileName:FileNameLen/binary, BExtra:ExtraLen/binary>> = B, +- {binary_to_chars(BFileName, GPFlag), BExtra} ++ {sanitize_filename(binary_to_chars(BFileName, GPFlag)), BExtra} + catch + _:_ -> + throw(bad_file_header) + end. + ++sanitize_filename(Filename) -> ++ case filename:pathtype(Filename) of ++ relative -> Filename; ++ _ -> ++ %% With absolute or volumerelative, we drop the prefix and rejoin ++ %% the path to create a relative path ++ Relative = filename:join(tl(filename:split(Filename))), ++ error_logger:format("Illegal absolute path: ~ts, converting to ~ts~n", ++ [Filename, Relative]), ++ relative = filename:pathtype(Relative), ++ Relative ++ end. ++ + %% get compressed or stored data + get_z_data(?DEFLATED, In0, FileName, CompSize, Input, Output, OpO, Z) -> + ok = zlib:inflateInit(Z, -?MAX_WBITS), +--- a/lib/stdlib/test/zip_SUITE.erl ++++ b/lib/stdlib/test/zip_SUITE.erl +@@ -22,7 +22,7 @@ + -export([all/0, suite/0,groups/0,init_per_suite/1, end_per_suite/1, + init_per_group/2,end_per_group/2, borderline/1, atomic/1, + bad_zip/1, unzip_from_binary/1, unzip_to_binary/1, +- zip_to_binary/1, ++ zip_to_binary/1, sanitize_filenames/1, + unzip_options/1, zip_options/1, list_dir_options/1, aliases/1, + openzip_api/1, zip_api/1, open_leak/1, unzip_jar/1, + unzip_traversal_exploit/1, +@@ -40,7 +40,8 @@ + unzip_to_binary, zip_to_binary, unzip_options, + zip_options, list_dir_options, aliases, openzip_api, + zip_api, open_leak, unzip_jar, compress_control, foldl, +- unzip_traversal_exploit,fd_leak,unicode,test_zip_dir]. ++ unzip_traversal_exploit,fd_leak,unicode,test_zip_dir, ++ sanitize_filenames]. + + groups() -> + []. +@@ -90,22 +91,27 @@ + {ok, Archive} = zip:zip(Archive, [Name]), + ok = file:delete(Name), + ++ RelName = filename:join(tl(filename:split(Name))), ++ + %% Verify listing and extracting. + {ok, [#zip_comment{comment = []}, +- #zip_file{name = Name, ++ #zip_file{name = RelName, + info = Info, + offset = 0, + comp_size = _}]} = zip:list_dir(Archive), + Size = Info#file_info.size, +- {ok, [Name]} = zip:extract(Archive, [verbose]), ++ TempRelName = filename:join(TempDir, RelName), ++ {ok, [TempRelName]} = zip:extract(Archive, [verbose, {cwd, TempDir}]), + +- %% Verify contents of extracted file. +- {ok, Bin} = file:read_file(Name), +- true = match_byte_list(X0, binary_to_list(Bin)), ++ %% Verify that absolute file was not created ++ {error, enoent} = file:read_file(Name), + ++ %% Verify that relative contents of extracted file. ++ {ok, Bin} = file:read_file(TempRelName), ++ true = match_byte_list(X0, binary_to_list(Bin)), + + %% Verify that Unix zip can read it. (if we have a unix zip that is!) +- zipinfo_match(Archive, Name), ++ zipinfo_match(Archive, RelName), + + ok. + +@@ -1052,3 +1058,21 @@ + end + end)(). + ++sanitize_filenames(Config) -> ++ RootDir = proplists:get_value(priv_dir, Config), ++ TempDir = filename:join(RootDir, "borderline"), ++ ok = file:make_dir(TempDir), ++ ++ %% Create a zip archive /tmp/absolute in it ++ %% This file was created using the command below on Erlang/OTP 28.0 ++ %% 1> rr(file), {ok, {_, Bin}} = zip:zip("absolute.zip", [{"/tmp/absolute",<<>>,#file_info{ type=regular, mtime={{1970,1,1},{0,0,0}}, size=0 }}], [memory]), rp(base64:encode(Bin)). ++ AbsZip = base64:decode(<<"UEsDBBQAAAAAAAAAIewAAAAAAAAAAAAAAAANAAAAL3RtcC9hYnNvbHV0ZVBLAQIUAxQAAAAAAAAAIewAAAAAAAAAAAAAAAANAAAAAAAAAAAAAACkAQAAAAAvdG1wL2Fic29sdXRlUEsFBgAAAAABAAEAOwAAACsAAAAAAA==">>), ++ Archive = filename:join(TempDir, "absolute.zip"), ++ ok = file:write_file(Archive, AbsZip), ++ ++ TmpAbs = filename:join([TempDir, "tmp", "absolute"]), ++ {ok, [TmpAbs]} = zip:unzip(Archive, [verbose, {cwd, TempDir}]), ++ {error, enoent} = file:read_file("/tmp/absolute"), ++ {ok, <<>>} = file:read_file(TmpAbs), ++ ++ ok. +\ No newline at end of file
diff --git a/debian/changelog b/debian/changelog index d269d536f..04f8dd176 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +erlang (1:25.2.3+dfsg-1+deb12u2) bookworm-proposed-updates; urgency=medium + + * ssh: fix strict KEX hardening (CVE-2025-46712) (closes: #1104963). + * zip: sanitize pathnames when extracting files with absolute pathnames + (CVE-2025-4748) (closes: #1107939). + + -- Sergei Golovan <sgolo...@debian.org> Thu, 26 Jun 2025 13:14:36 +0300 + erlang (1:25.2.3+dfsg-1+deb12u1) bookworm-security; urgency=high [ Salvatore Bonaccorso ] diff --git a/debian/patches/series b/debian/patches/series index 68d1a9d42..6ab90acb4 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -13,3 +13,5 @@ ssh-ignore-too-long-names.patch ssh-use-chars_limit-for-bad-packets-error-messages.patch ssh-custom_kexinit-test-added.patch ssh-early-RCE-fix.patch +ssh-strict-KEX-exchange-hardening.patch +zip-sanitize-paths.patch diff --git a/debian/patches/ssh-strict-KEX-exchange-hardening.patch b/debian/patches/ssh-strict-KEX-exchange-hardening.patch new file mode 100644 index 000000000..e6b5f8444 --- /dev/null +++ b/debian/patches/ssh-strict-KEX-exchange-hardening.patch @@ -0,0 +1,607 @@ +From: Jakub Witczak <k...@erlang.org> +Date: Tue, 6 May 2025 17:01:29 +0200 +Subject: ssh: KEX strict implementation fixes + - fixed KEX strict implementation + - draft-miller-sshm-strict-kex-01.txt + - ssh_dbg added to ssh_fsm_kexinit module + - CVE-2025-46712 +Origin: https://github.com/erlang/otp/commit/e4b56a9f4a511aa9990dd86c16c61439c828df83 +Bug-Debian: https://bugs.debian.org/1104963 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-46712 + +--- a/lib/ssh/src/ssh_connection_handler.erl ++++ b/lib/ssh/src/ssh_connection_handler.erl +@@ -34,7 +34,6 @@ + -include("ssh_transport.hrl"). + -include("ssh_auth.hrl"). + -include("ssh_connect.hrl"). +- + -include("ssh_fsm.hrl"). + + %%==================================================================== +@@ -705,16 +704,6 @@ + disconnect_fun("Received disconnect: "++Desc, D), + {stop_and_reply, {shutdown,Desc}, Actions, D}; + +-handle_event(internal, #ssh_msg_ignore{}, {_StateName, _Role, init}, +- #data{ssh_params = #ssh{kex_strict_negotiated = true, +- send_sequence = SendSeq, +- recv_sequence = RecvSeq}}) -> +- ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, +- io_lib:format("strict KEX violation: unexpected SSH_MSG_IGNORE " +- "send_sequence = ~p recv_sequence = ~p", +- [SendSeq, RecvSeq]) +- ); +- + handle_event(internal, #ssh_msg_ignore{}, _StateName, _) -> + keep_state_and_data; + +@@ -1118,11 +1107,14 @@ + of + {packet_decrypted, DecryptedBytes, EncryptedDataRest, Ssh1} -> + D1 = D0#data{ssh_params = +- Ssh1#ssh{recv_sequence = ssh_transport:next_seqnum(Ssh1#ssh.recv_sequence)}, +- decrypted_data_buffer = <<>>, +- undecrypted_packet_length = undefined, +- aead_data = <<>>, +- encrypted_data_buffer = EncryptedDataRest}, ++ Ssh1#ssh{recv_sequence = ++ ssh_transport:next_seqnum(StateName, ++ Ssh1#ssh.recv_sequence, ++ SshParams)}, ++ decrypted_data_buffer = <<>>, ++ undecrypted_packet_length = undefined, ++ aead_data = <<>>, ++ encrypted_data_buffer = EncryptedDataRest}, + try + ssh_message:decode(set_kex_overload_prefix(DecryptedBytes,D1)) + of +--- a/lib/ssh/src/ssh_fsm_kexinit.erl ++++ b/lib/ssh/src/ssh_fsm_kexinit.erl +@@ -43,6 +43,11 @@ + -export([callback_mode/0, handle_event/4, terminate/3, + format_status/2, code_change/4]). + ++-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]). ++ + %%==================================================================== + %% gen_statem callbacks + %%==================================================================== +@@ -53,8 +58,13 @@ + + %%-------------------------------------------------------------------- + +-%%% ######## {kexinit, client|server, init|renegotiate} #### + ++handle_event(Type, Event = prepare_next_packet, StateName, D) -> ++ ssh_connection_handler:handle_event(Type, Event, StateName, D); ++handle_event(Type, Event = {send_disconnect, _, _, _, _}, StateName, D) -> ++ ssh_connection_handler:handle_event(Type, Event, StateName, D); ++ ++%%% ######## {kexinit, client|server, init|renegotiate} #### + handle_event(internal, {#ssh_msg_kexinit{}=Kex, Payload}, {kexinit,Role,ReNeg}, + D = #data{key_exchange_init_msg = OwnKex}) -> + Ssh1 = ssh_transport:key_init(peer_role(Role), D#data.ssh_params, Payload), +@@ -67,11 +77,10 @@ + end, + {next_state, {key_exchange,Role,ReNeg}, D#data{ssh_params=Ssh}}; + +- + %%% ######## {key_exchange, client|server, init|renegotiate} #### +- + %%%---- diffie-hellman + handle_event(internal, #ssh_msg_kexdh_init{} = Msg, {key_exchange,server,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, KexdhReply, Ssh1} = ssh_transport:handle_kexdh_init(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(KexdhReply, D), + {ok, NewKeys, Ssh2} = ssh_transport:new_keys_message(Ssh1), +@@ -81,6 +90,7 @@ + {next_state, {new_keys,server,ReNeg}, D#data{ssh_params=Ssh}}; + + handle_event(internal, #ssh_msg_kexdh_reply{} = Msg, {key_exchange,client,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, NewKeys, Ssh1} = ssh_transport:handle_kexdh_reply(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(NewKeys, D), + {ok, ExtInfo, Ssh} = ssh_transport:ext_info_message(Ssh1), +@@ -89,24 +99,28 @@ + + %%%---- diffie-hellman group exchange + handle_event(internal, #ssh_msg_kex_dh_gex_request{} = Msg, {key_exchange,server,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, GexGroup, Ssh1} = ssh_transport:handle_kex_dh_gex_request(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(GexGroup, D), + Ssh = ssh_transport:parallell_gen_key(Ssh1), + {next_state, {key_exchange_dh_gex_init,server,ReNeg}, D#data{ssh_params=Ssh}}; + + handle_event(internal, #ssh_msg_kex_dh_gex_request_old{} = Msg, {key_exchange,server,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, GexGroup, Ssh1} = ssh_transport:handle_kex_dh_gex_request(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(GexGroup, D), + Ssh = ssh_transport:parallell_gen_key(Ssh1), + {next_state, {key_exchange_dh_gex_init,server,ReNeg}, D#data{ssh_params=Ssh}}; + + handle_event(internal, #ssh_msg_kex_dh_gex_group{} = Msg, {key_exchange,client,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, KexGexInit, Ssh} = ssh_transport:handle_kex_dh_gex_group(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(KexGexInit, D), + {next_state, {key_exchange_dh_gex_reply,client,ReNeg}, D#data{ssh_params=Ssh}}; + + %%%---- elliptic curve diffie-hellman + handle_event(internal, #ssh_msg_kex_ecdh_init{} = Msg, {key_exchange,server,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, KexEcdhReply, Ssh1} = ssh_transport:handle_kex_ecdh_init(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(KexEcdhReply, D), + {ok, NewKeys, Ssh2} = ssh_transport:new_keys_message(Ssh1), +@@ -116,16 +130,25 @@ + {next_state, {new_keys,server,ReNeg}, D#data{ssh_params=Ssh}}; + + handle_event(internal, #ssh_msg_kex_ecdh_reply{} = Msg, {key_exchange,client,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, NewKeys, Ssh1} = ssh_transport:handle_kex_ecdh_reply(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(NewKeys, D), + {ok, ExtInfo, Ssh} = ssh_transport:ext_info_message(Ssh1), + ssh_connection_handler:send_bytes(ExtInfo, D), + {next_state, {new_keys,client,ReNeg}, D#data{ssh_params=Ssh}}; + ++%%% ######## handle KEX strict ++handle_event(internal, _Event, {key_exchange,_Role,init}, ++ #data{ssh_params = #ssh{algorithms = #alg{kex_strict_negotiated = true}, ++ send_sequence = SendSeq, ++ recv_sequence = RecvSeq}}) -> ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ io_lib:format("KEX strict violation: send_sequence = ~p recv_sequence = ~p", ++ [SendSeq, RecvSeq])); + + %%% ######## {key_exchange_dh_gex_init, server, init|renegotiate} #### +- + handle_event(internal, #ssh_msg_kex_dh_gex_init{} = Msg, {key_exchange_dh_gex_init,server,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, KexGexReply, Ssh1} = ssh_transport:handle_kex_dh_gex_init(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(KexGexReply, D), + {ok, NewKeys, Ssh2} = ssh_transport:new_keys_message(Ssh1), +@@ -133,20 +156,33 @@ + {ok, ExtInfo, Ssh} = ssh_transport:ext_info_message(Ssh2), + ssh_connection_handler:send_bytes(ExtInfo, D), + {next_state, {new_keys,server,ReNeg}, D#data{ssh_params=Ssh}}; +- ++%%% ######## handle KEX strict ++handle_event(internal, _Event, {key_exchange_dh_gex_init,_Role,init}, ++ #data{ssh_params = #ssh{algorithms = #alg{kex_strict_negotiated = true}, ++ send_sequence = SendSeq, ++ recv_sequence = RecvSeq}}) -> ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ io_lib:format("KEX strict violation: send_sequence = ~p recv_sequence = ~p", ++ [SendSeq, RecvSeq])); + + %%% ######## {key_exchange_dh_gex_reply, client, init|renegotiate} #### +- + handle_event(internal, #ssh_msg_kex_dh_gex_reply{} = Msg, {key_exchange_dh_gex_reply,client,ReNeg}, D) -> ++ ok = check_kex_strict(Msg, D), + {ok, NewKeys, Ssh1} = ssh_transport:handle_kex_dh_gex_reply(Msg, D#data.ssh_params), + ssh_connection_handler:send_bytes(NewKeys, D), + {ok, ExtInfo, Ssh} = ssh_transport:ext_info_message(Ssh1), + ssh_connection_handler:send_bytes(ExtInfo, D), + {next_state, {new_keys,client,ReNeg}, D#data{ssh_params=Ssh}}; +- ++%%% ######## handle KEX strict ++handle_event(internal, _Event, {key_exchange_dh_gex_reply,_Role,init}, ++ #data{ssh_params = #ssh{algorithms = #alg{kex_strict_negotiated = true}, ++ send_sequence = SendSeq, ++ recv_sequence = RecvSeq}}) -> ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ io_lib:format("KEX strict violation: send_sequence = ~p recv_sequence = ~p", ++ [SendSeq, RecvSeq])); + + %%% ######## {new_keys, client|server} #### +- + %% First key exchange round: + handle_event(internal, #ssh_msg_newkeys{} = Msg, {new_keys,client,init}, D0) -> + {ok, Ssh1} = ssh_transport:handle_new_keys(Msg, D0#data.ssh_params), +@@ -162,6 +198,15 @@ + %% ssh_connection_handler:send_bytes(ExtInfo, D), + {next_state, {ext_info,server,init}, D#data{ssh_params=Ssh}}; + ++%%% ######## handle KEX strict ++handle_event(internal, _Event, {new_keys,_Role,init}, ++ #data{ssh_params = #ssh{algorithms = #alg{kex_strict_negotiated = true}, ++ send_sequence = SendSeq, ++ recv_sequence = RecvSeq}}) -> ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ io_lib:format("KEX strict violation (send_sequence = ~p recv_sequence = ~p)", ++ [SendSeq, RecvSeq])); ++ + %% Subsequent key exchange rounds (renegotiation): + handle_event(internal, #ssh_msg_newkeys{} = Msg, {new_keys,Role,renegotiate}, D) -> + {ok, Ssh} = ssh_transport:handle_new_keys(Msg, D#data.ssh_params), +@@ -183,7 +228,6 @@ + handle_event(internal, #ssh_msg_newkeys{}=Msg, {ext_info,_Role,renegotiate}, D) -> + {ok, Ssh} = ssh_transport:handle_new_keys(Msg, D#data.ssh_params), + {keep_state, D#data{ssh_params = Ssh}}; +- + + handle_event(internal, Msg, {ext_info,Role,init}, D) when is_tuple(Msg) -> + %% If something else arrives, goto next state and handle the event in that one +@@ -217,3 +261,70 @@ + peer_role(client) -> server; + peer_role(server) -> client. + ++check_kex_strict(Msg, ++ #data{ssh_params = ++ #ssh{algorithms = ++ #alg{ ++ kex = Kex, ++ kex_strict_negotiated = KexStrictNegotiated}, ++ send_sequence = SendSeq, ++ recv_sequence = RecvSeq}}) -> ++ case check_msg_group(Msg, get_alg_group(Kex), KexStrictNegotiated) of ++ ok -> ++ ok; ++ error -> ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ io_lib:format("KEX strict violation: send_sequence = ~p recv_sequence = ~p", ++ [SendSeq, RecvSeq])) ++ end. ++ ++get_alg_group(Kex) when Kex == 'diffie-hellman-group16-sha512'; ++ Kex == 'diffie-hellman-group18-sha512'; ++ Kex == 'diffie-hellman-group14-sha256'; ++ Kex == 'diffie-hellman-group14-sha1'; ++ Kex == 'diffie-hellman-group1-sha1' -> ++ dh_alg; ++get_alg_group(Kex) when Kex == 'diffie-hellman-group-exchange-sha256'; ++ Kex == 'diffie-hellman-group-exchange-sha1' -> ++ dh_gex_alg; ++get_alg_group(Kex) when Kex == 'curve25519-sha256'; ++ Kex == 'curve25519-sha...@libssh.org'; ++ Kex == 'curve448-sha512'; ++ Kex == 'ecdh-sha2-nistp521'; ++ Kex == 'ecdh-sha2-nistp384'; ++ Kex == 'ecdh-sha2-nistp256' -> ++ ecdh_alg. ++ ++check_msg_group(_Msg, _AlgGroup, false) -> ok; ++check_msg_group(#ssh_msg_kexdh_init{}, dh_alg, true) -> ok; ++check_msg_group(#ssh_msg_kexdh_reply{}, dh_alg, true) -> ok; ++check_msg_group(#ssh_msg_kex_dh_gex_request_old{}, dh_gex_alg, true) -> ok; ++check_msg_group(#ssh_msg_kex_dh_gex_request{}, dh_gex_alg, true) -> ok; ++check_msg_group(#ssh_msg_kex_dh_gex_group{}, dh_gex_alg, true) -> ok; ++check_msg_group(#ssh_msg_kex_dh_gex_init{}, dh_gex_alg, true) -> ok; ++check_msg_group(#ssh_msg_kex_dh_gex_reply{}, dh_gex_alg, true) -> ok; ++check_msg_group(#ssh_msg_kex_ecdh_init{}, ecdh_alg, true) -> ok; ++check_msg_group(#ssh_msg_kex_ecdh_reply{}, ecdh_alg, true) -> ok; ++check_msg_group(_Msg, _AlgGroup, _) -> error. ++ ++%%%################################################################ ++%%%# ++%%%# Tracing ++%%%# ++ ++ssh_dbg_trace_points() -> [connection_events]. ++ ++ssh_dbg_flags(connection_events) -> [c]. ++ ++ssh_dbg_on(connection_events) -> dbg:tp(?MODULE, handle_event, 4, x). ++ ++ssh_dbg_off(connection_events) -> dbg:ctpg(?MODULE, handle_event, 4). ++ ++ssh_dbg_format(connection_events, {call, {?MODULE,handle_event, [EventType, EventContent, State, _Data]}}) -> ++ ["Connection event\n", ++ io_lib:format("[~w] EventType: ~p~nEventContent: ~p~nState: ~p~n", [?MODULE, EventType, EventContent, State]) ++ ]; ++ssh_dbg_format(connection_events, {return_from, {?MODULE,handle_event,4}, Ret}) -> ++ ["Connection event result\n", ++ io_lib:format("[~w] ~p~n", [?MODULE, ssh_dbg:reduce_state(Ret, #data{})]) ++ ]. +--- a/lib/ssh/src/ssh_transport.erl ++++ b/lib/ssh/src/ssh_transport.erl +@@ -26,12 +26,11 @@ + + -include_lib("public_key/include/public_key.hrl"). + -include_lib("kernel/include/inet.hrl"). +- + -include("ssh_transport.hrl"). + -include("ssh.hrl"). + + -export([versions/2, hello_version_msg/1]). +--export([next_seqnum/1, ++-export([next_seqnum/3, + supported_algorithms/0, supported_algorithms/1, + default_algorithms/0, default_algorithms/1, + clear_default_algorithms_env/0, +@@ -297,7 +296,12 @@ + hello_version_msg(Data) -> + [Data,"\r\n"]. + +-next_seqnum(SeqNum) -> ++next_seqnum({State, _Role, init}, 16#ffffffff, ++ #ssh{algorithms = #alg{kex_strict_negotiated = true}}) ++ when State == kexinit; State == key_exchange; State == new_keys -> ++ ?DISCONNECT(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED, ++ io_lib:format("KEX strict violation: recv_sequence = 16#ffffffff", [])); ++next_seqnum(_State, SeqNum, _) -> + (SeqNum + 1) band 16#ffffffff. + + is_valid_mac(_, _ , #ssh{recv_mac_size = 0}) -> +@@ -1082,7 +1086,7 @@ + %% algorithm. Each string MUST contain at least one algorithm name. + select_algorithm(Role, Client, Server, + #ssh{opts = Opts, +- kex_strict_negotiated = KexStrictNegotiated0}, ++ kex_strict_negotiated = KexStrictNegotiated0}, + ReNeg) -> + KexStrictNegotiated = + case ReNeg of +@@ -1108,7 +1112,6 @@ + _ -> + KexStrictNegotiated0 + end, +- + {Encrypt0, Decrypt0} = select_encrypt_decrypt(Role, Client, Server), + {SendMac0, RecvMac0} = select_send_recv_mac(Role, Client, Server), + +--- a/lib/ssh/test/ssh_protocol_SUITE.erl ++++ b/lib/ssh/test/ssh_protocol_SUITE.erl +@@ -54,7 +54,9 @@ + ext_info_c/1, + ext_info_s/1, + kex_strict_negotiated/1, +- kex_strict_msg_ignore/1, ++ kex_strict_violation_key_exchange/1, ++ kex_strict_violation_new_keys/1, ++ kex_strict_violation/1, + kex_strict_msg_unknown/1, + gex_client_init_option_groups/1, + gex_client_init_option_groups_file/1, +@@ -143,7 +145,9 @@ + gex_client_old_request_exact, + gex_client_old_request_noexact, + kex_strict_negotiated, +- kex_strict_msg_ignore, ++ kex_strict_violation_key_exchange, ++ kex_strict_violation_new_keys, ++ kex_strict_violation, + kex_strict_msg_unknown]}, + {service_requests, [], [bad_service_name, + bad_long_service_name, +@@ -930,22 +934,145 @@ + logger:set_primary_config(Level), + ok. + +-%% Connect to an erlang server and inject unexpected SSH ignore +-kex_strict_msg_ignore(Config) -> +- ct:log("START: ~p~n=================================", [?FUNCTION_NAME]), +- ExpectedReason = "strict KEX violation: unexpected SSH_MSG_IGNORE", +- TestMessages = +- [{send, ssh_msg_ignore}, +- {match, #ssh_msg_kexdh_reply{_='_'}, receive_msg}, +- {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}], +- kex_strict_helper(Config, TestMessages, ExpectedReason). ++%% Connect to an erlang server and inject unexpected SSH message ++%% ssh_fsm_kexinit in key_exchange state ++kex_strict_violation_key_exchange(Config) -> ++ ExpectedReason = "KEX strict violation", ++ Injections = [ssh_msg_ignore, ssh_msg_debug, ssh_msg_unimplemented], ++ TestProcedure = ++ fun(M) -> ++ ct:log( ++ "=================== START: ~p Message: ~p Expected Fail =================================", ++ [?FUNCTION_NAME, M]), ++ [receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {send, M}, ++ {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}] ++ end, ++ [kex_strict_helper(Config, TestProcedure(Msg), ExpectedReason) || ++ Msg <- Injections], ++ ct:log("========== END ========"), ++ ok. ++ ++%% Connect to an erlang server and inject unexpected SSH message ++%% ssh_fsm_kexinit in new_keys state ++kex_strict_violation_new_keys(Config) -> ++ ExpectedReason = "KEX strict violation", ++ Injections = [ssh_msg_ignore, ssh_msg_debug, ssh_msg_unimplemented], ++ TestProcedure = ++ fun(M) -> ++ ct:log( ++ "=================== START: ~p Message: ~p Expected Fail =================================", ++ [?FUNCTION_NAME, M]), ++ [receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {send, ssh_msg_kexdh_init}, ++ {send, M}, ++ {match, #ssh_msg_kexdh_reply{_='_'}, receive_msg}, ++ {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}] ++ end, ++ [kex_strict_helper(Config, TestProcedure(Msg), ExpectedReason) || ++ Msg <- Injections], ++ ct:log("========== END ========"), ++ ok. ++ ++%% Connect to an erlang server and inject unexpected SSH message ++%% duplicated KEXINIT ++kex_strict_violation(Config) -> ++ KexDhReply = ++ #ssh_msg_kexdh_reply{ ++ public_host_key = {{{'ECPoint',<<73,72,235,162,96,101,154,59,217,114,123,192,96,105,250,29,214,76,60,63,167,21,221,118,246,168,152,2,7,172,137,125>>}, ++ {namedCurve,{1,3,101,112}}}, ++ 'ssh-ed25519'}, ++ f = 18504393053016436370762156176197081926381112956345797067569792020930728564439992620494295053804030674742529174859108487694089045521619258420515443400605141150065440678508889060925968846155921972385560196703381004650914261218463420313738628465563288022895912907728767735629532940627575655703806353550720122093175255090704443612257683903495753071530605378193139909567971489952258218767352348904221407081210633467414579377014704081235998044497191940270966762124544755076128392259615566530695493013708460088312025006678879288856957348606386230195080105197251789635675011844976120745546472873505352732719507783227210178188, ++ h_sig = <<90,247,44,240,136,196,82,215,56,165,53,33,230,101,253, ++ 34,112,201,21,131,162,169,10,129,174,14,69,25,39,174, ++ 92,210,130,249,103,2,215,245,7,213,110,235,136,134,11, ++ 124,248,139,79,17,225,77,125,182,204,84,137,167,99,186, ++ 167,42,192,10>>}, ++ TestFlows = ++ [ ++ {kexinit, "KEX strict violation", ++ [receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {send, ssh_msg_kexinit}, ++ {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}]}, ++ {ssh_msg_kexdh_init, "KEX strict violation", ++ [receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {send, ssh_msg_kexdh_init_dup}, ++ {match,# ssh_msg_kexdh_reply{_='_'}, receive_msg}, ++ {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}]}, ++ {new_keys, "Message ssh_msg_newkeys in wrong state", ++ [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}, ++ {send, #ssh_msg_newkeys{}}, ++ {match, disconnect(?SSH_DISCONNECT_PROTOCOL_ERROR), receive_msg}]}, ++ {ssh_msg_unexpected_dh_gex, "KEX strict violation", ++ [receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ %% dh_alg is expected but dh_gex_alg is provided ++ {send, #ssh_msg_kex_dh_gex_request{min = 1000, n = 3000, max = 4000}}, ++ {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}]}, ++ {wrong_role, "KEX strict violation", ++ [receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ %% client should not send message below ++ {send, KexDhReply}, ++ {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}]}, ++ {wrong_role2, "KEX strict violation", ++ [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}, ++ %% client should not send message below ++ {send, KexDhReply}, ++ {match, #ssh_msg_newkeys{_='_'}, receive_msg}, ++ {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}]} ++ ], ++ TestProcedure = ++ fun({Msg, _, P}) -> ++ ct:log( ++ "==== START: ~p (duplicated ~p) Expected Fail ====~n~p", ++ [?FUNCTION_NAME, Msg, P]), ++ P ++ end, ++ [kex_strict_helper(Config, TestProcedure(Procedure), Reason) || ++ Procedure = {_, Reason, _} <- TestFlows], ++ ct:log("==== END ====="), ++ ok. + + %% Connect to an erlang server and inject unexpected non-SSH binary + kex_strict_msg_unknown(Config) -> + ct:log("START: ~p~n=================================", [?FUNCTION_NAME]), + ExpectedReason = "Bad packet: Size", + TestMessages = +- [{send, ssh_msg_unknown}, ++ [receive_hello, ++ {send, hello}, ++ {send, ssh_msg_kexinit}, ++ {match, #ssh_msg_kexinit{_='_'}, receive_msg}, ++ {send, ssh_msg_kexdh_init}, ++ {send, ssh_msg_unknown}, + {match, #ssh_msg_kexdh_reply{_='_'}, receive_msg}, + {match, disconnect(?SSH_DISCONNECT_KEY_EXCHANGE_FAILED), receive_msg}], + kex_strict_helper(Config, TestMessages, ExpectedReason). +@@ -970,12 +1097,7 @@ + {user_dir, user_dir(Config)}, + {user_interaction, false} + | proplists:get_value(extra_options,Config,[]) +- ]}, +- receive_hello, +- {send, hello}, +- {send, ssh_msg_kexinit}, +- {match, #ssh_msg_kexinit{_='_'}, receive_msg}, +- {send, ssh_msg_kexdh_init}] ++ ++ ]}] ++ + TestMessages, + InitialState), + ct:sleep(100), +--- a/lib/ssh/test/ssh_trpt_test_lib.erl ++++ b/lib/ssh/test/ssh_trpt_test_lib.erl +@@ -90,7 +90,8 @@ + report_trace(throw, Term, S1), + throw({Term,Op}); + +- error:Error -> ++ error:Error:St -> ++ ct:log("Stacktrace=~n~p", [St]), + report_trace(error, Error, S1), + error({Error,Op}); + +@@ -335,6 +336,17 @@ + Msg = #ssh_msg_ignore{data = "unexpected_ignore_message"}, + send(S0, Msg); + ++send(S0, ssh_msg_debug) -> ++ Msg = #ssh_msg_debug{ ++ always_display = true, ++ message = "some debug message", ++ language = "en"}, ++ send(S0, Msg); ++ ++send(S0, ssh_msg_unimplemented) -> ++ Msg = #ssh_msg_unimplemented{sequence = 123}, ++ send(S0, Msg); ++ + send(S0, ssh_msg_unknown) -> + Msg = binary:encode_hex(<<"0000000C060900000000000000000000">>), + send(S0, Msg); +@@ -382,6 +394,26 @@ + end), + send_bytes(NextKexMsgBin, S#s{ssh = C}); + ++send(S0, ssh_msg_kexdh_init_dup) when ?role(S0) == client -> ++ {OwnMsg, PeerMsg} = S0#s.alg_neg, ++ {ok, NextKexMsgBin, C} = ++ try ssh_transport:handle_kexinit_msg(PeerMsg, OwnMsg, S0#s.ssh, init) ++ catch ++ Class:Exc -> ++ fail("Algorithm negotiation failed!", ++ {"Algorithm negotiation failed at line ~p:~p~n~p:~s~nPeer: ~s~n Own: ~s", ++ [?MODULE,?LINE,Class,format_msg(Exc),format_msg(PeerMsg),format_msg(OwnMsg)]}, ++ S0) ++ end, ++ S = opt(print_messages, S0, ++ fun(X) when X==true;X==detail -> ++ #ssh{keyex_key = {{_Private, Public}, {_G, _P}}} = C, ++ Msg = #ssh_msg_kexdh_init{e = Public}, ++ {"Send (reconstructed)~n~s~n",[format_msg(Msg)]} ++ end), ++ send_bytes(NextKexMsgBin, S#s{ssh = C}), ++ send_bytes(NextKexMsgBin, S#s{ssh = C}); ++ + send(S0, ssh_msg_kexdh_reply) -> + Bytes = proplists:get_value(ssh_msg_kexdh_reply, S0#s.reply), + S = opt(print_messages, S0, +@@ -531,7 +563,10 @@ + S0#s.ssh) + of + {packet_decrypted, DecryptedBytes, EncryptedDataRest, Ssh1} -> +- S1 = S0#s{ssh = Ssh1#ssh{recv_sequence = ssh_transport:next_seqnum(Ssh1#ssh.recv_sequence)}, ++ S1 = S0#s{ssh = Ssh1#ssh{recv_sequence = ++ ssh_transport:next_seqnum(undefined, ++ Ssh1#ssh.recv_sequence, ++ false)}, + decrypted_data_buffer = <<>>, + undecrypted_packet_length = undefined, + aead_data = <<>>, diff --git a/debian/patches/zip-sanitize-paths.patch b/debian/patches/zip-sanitize-paths.patch new file mode 100644 index 000000000..804693b92 --- /dev/null +++ b/debian/patches/zip-sanitize-paths.patch @@ -0,0 +1,133 @@ +From: Lukas Backstrom <lu...@erlang.org> +Date: Tue, 27 May 2025 21:50:01 +0200 +Subject: [PATCH] stdlib: Properly sanatize filenames when (un)zipping + According to the Zip APPNOTE filenames "MUST NOT contain a drive or + device letter, or a leading slash.". So we strip those when zipping + and unzipping. +Origin: https://github.com/erlang/otp/commit/ee67d46285394db95133709cef74b0c462d665aa +Bug-Debian: https://bugs.debian.org/1107939 +Bug-Debian-Security: https://security-tracker.debian.org/tracker/CVE-2025-4748 + +--- a/lib/stdlib/src/zip.erl ++++ b/lib/stdlib/src/zip.erl +@@ -826,12 +826,12 @@ + get_filename({Name, _, _}, Type) -> + get_filename(Name, Type); + get_filename(Name, regular) -> +- Name; ++ sanitize_filename(Name); + get_filename(Name, directory) -> + %% Ensure trailing slash + case lists:reverse(Name) of +- [$/ | _Rev] -> Name; +- Rev -> lists:reverse([$/ | Rev]) ++ [$/ | _Rev] -> sanitize_filename(Name); ++ Rev -> sanitize_filename(lists:reverse([$/ | Rev])) + end. + + add_cwd(_CWD, {_Name, _} = F) -> F; +@@ -1531,12 +1531,25 @@ + get_file_name_extra(FileNameLen, ExtraLen, B, GPFlag) -> + try + <<BFileName:FileNameLen/binary, BExtra:ExtraLen/binary>> = B, +- {binary_to_chars(BFileName, GPFlag), BExtra} ++ {sanitize_filename(binary_to_chars(BFileName, GPFlag)), BExtra} + catch + _:_ -> + throw(bad_file_header) + end. + ++sanitize_filename(Filename) -> ++ case filename:pathtype(Filename) of ++ relative -> Filename; ++ _ -> ++ %% With absolute or volumerelative, we drop the prefix and rejoin ++ %% the path to create a relative path ++ Relative = filename:join(tl(filename:split(Filename))), ++ error_logger:format("Illegal absolute path: ~ts, converting to ~ts~n", ++ [Filename, Relative]), ++ relative = filename:pathtype(Relative), ++ Relative ++ end. ++ + %% get compressed or stored data + get_z_data(?DEFLATED, In0, FileName, CompSize, Input, Output, OpO, Z) -> + ok = zlib:inflateInit(Z, -?MAX_WBITS), +--- a/lib/stdlib/test/zip_SUITE.erl ++++ b/lib/stdlib/test/zip_SUITE.erl +@@ -22,7 +22,7 @@ + -export([all/0, suite/0,groups/0,init_per_suite/1, end_per_suite/1, + init_per_group/2,end_per_group/2, borderline/1, atomic/1, + bad_zip/1, unzip_from_binary/1, unzip_to_binary/1, +- zip_to_binary/1, ++ zip_to_binary/1, sanitize_filenames/1, + unzip_options/1, zip_options/1, list_dir_options/1, aliases/1, + openzip_api/1, zip_api/1, open_leak/1, unzip_jar/1, + unzip_traversal_exploit/1, +@@ -40,7 +40,8 @@ + unzip_to_binary, zip_to_binary, unzip_options, + zip_options, list_dir_options, aliases, openzip_api, + zip_api, open_leak, unzip_jar, compress_control, foldl, +- unzip_traversal_exploit,fd_leak,unicode,test_zip_dir]. ++ unzip_traversal_exploit,fd_leak,unicode,test_zip_dir, ++ sanitize_filenames]. + + groups() -> + []. +@@ -90,22 +91,27 @@ + {ok, Archive} = zip:zip(Archive, [Name]), + ok = file:delete(Name), + ++ RelName = filename:join(tl(filename:split(Name))), ++ + %% Verify listing and extracting. + {ok, [#zip_comment{comment = []}, +- #zip_file{name = Name, ++ #zip_file{name = RelName, + info = Info, + offset = 0, + comp_size = _}]} = zip:list_dir(Archive), + Size = Info#file_info.size, +- {ok, [Name]} = zip:extract(Archive, [verbose]), ++ TempRelName = filename:join(TempDir, RelName), ++ {ok, [TempRelName]} = zip:extract(Archive, [verbose, {cwd, TempDir}]), + +- %% Verify contents of extracted file. +- {ok, Bin} = file:read_file(Name), +- true = match_byte_list(X0, binary_to_list(Bin)), ++ %% Verify that absolute file was not created ++ {error, enoent} = file:read_file(Name), + ++ %% Verify that relative contents of extracted file. ++ {ok, Bin} = file:read_file(TempRelName), ++ true = match_byte_list(X0, binary_to_list(Bin)), + + %% Verify that Unix zip can read it. (if we have a unix zip that is!) +- zipinfo_match(Archive, Name), ++ zipinfo_match(Archive, RelName), + + ok. + +@@ -1052,3 +1058,21 @@ + end + end)(). + ++sanitize_filenames(Config) -> ++ RootDir = proplists:get_value(priv_dir, Config), ++ TempDir = filename:join(RootDir, "borderline"), ++ ok = file:make_dir(TempDir), ++ ++ %% Create a zip archive /tmp/absolute in it ++ %% This file was created using the command below on Erlang/OTP 28.0 ++ %% 1> rr(file), {ok, {_, Bin}} = zip:zip("absolute.zip", [{"/tmp/absolute",<<>>,#file_info{ type=regular, mtime={{1970,1,1},{0,0,0}}, size=0 }}], [memory]), rp(base64:encode(Bin)). ++ AbsZip = base64:decode(<<"UEsDBBQAAAAAAAAAIewAAAAAAAAAAAAAAAANAAAAL3RtcC9hYnNvbHV0ZVBLAQIUAxQAAAAAAAAAIewAAAAAAAAAAAAAAAANAAAAAAAAAAAAAACkAQAAAAAvdG1wL2Fic29sdXRlUEsFBgAAAAABAAEAOwAAACsAAAAAAA==">>), ++ Archive = filename:join(TempDir, "absolute.zip"), ++ ok = file:write_file(Archive, AbsZip), ++ ++ TmpAbs = filename:join([TempDir, "tmp", "absolute"]), ++ {ok, [TmpAbs]} = zip:unzip(Archive, [verbose, {cwd, TempDir}]), ++ {error, enoent} = file:read_file("/tmp/absolute"), ++ {ok, <<>>} = file:read_file(TmpAbs), ++ ++ ok. +\ No newline at end of file