CVE-2026-48859
Description
Erlang/OTP SSH server's password authentication has a timing discrepancy that allows unauthenticated remote username enumeration.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Erlang/OTP SSH server's password authentication has a timing discrepancy that allows unauthenticated remote username enumeration.
Vulnerability
An observable timing discrepancy exists in Erlang/OTP's ssh module, specifically within the ssh_auth and ssh_options modules. When the SSH daemon is configured with the user_passwords or password option, the ssh_auth:check_password/3 function performs a computationally intensive PBKDF2-SHA256 calculation (approximately 300ms) for valid usernames. However, for invalid usernames, it returns almost immediately (approximately 0ms) via the ssh_options:get_password_option/2 path. This timing difference is detectable in a single authentication attempt. This vulnerability affects OTP versions from 29.0 before 29.0.2, corresponding to ssh versions from 6.0 before 6.0.1 [2].
Exploitation
An unauthenticated remote attacker can exploit this vulnerability by sending authentication requests to the SSH daemon. By measuring the time it takes for the server to respond to authentication attempts with different usernames, the attacker can distinguish between valid and invalid usernames. This timing side-channel allows for username enumeration without needing any prior authentication or special privileges [2].
Impact
Successful exploitation of this vulnerability allows an unauthenticated remote attacker to enumerate valid usernames on the target system. This information can be a crucial first step in more sophisticated attacks, such as brute-force password guessing or targeted social engineering, by reducing the attack surface for subsequent stages [2].
Mitigation
The vulnerability is fixed in OTP 29.0.2 and ssh 6.0.1. The recommended alternative for password authentication is the pwdfun option, which is not affected by this issue and allows for custom timing behavior [4]. As a workaround, administrators can restrict SSH port access to trusted networks via firewall rules to limit the potential attack surface [4]. The user_passwords and password options are intended for testing purposes only [2].
AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3Patches
1c342092ef4b3ssh: Fix PBKDF2 timing oracle in password authentication
5 files changed · +127 −9
lib/ssh/doc/guides/hardening.md+9 −1 modified@@ -378,7 +378,7 @@ handling plugin `m:ssh_file`. The alternatives are: ```erlang fun(User, Password, _PeerAddress, State) -> - case lists:member({User,Password}, my_user_pwds()) of + case check_credentials(User, Password) of true -> {true, undefined}; % Reset delay time false when State == undefined -> @@ -391,6 +391,14 @@ handling plugin `m:ssh_file`. The alternatives are: end. ``` + > #### Warning {: .warning } + > + > A `pwdfun` implementation should take care to execute in approximately + > the same time regardless of whether the username is valid or invalid. + > A timing difference allows attackers to enumerate valid usernames. + > Use constant-time comparison for password checking and avoid early + > returns based on username validity. + If a public key is used for logging in, there is normally no checking of the user name. It could be enabled by setting the option [`pk_check_user`](`m:ssh#option-pk_check_user`) to `true`. In that case the
lib/ssh/src/ssh_auth.erl+3 −1 modified@@ -519,6 +519,8 @@ check_password(User, Password, #ssh{opts=Opts} = Ssh) -> Checker when is_function(Checker, 1) -> {Checker(Password), Ssh}; _ -> + %% Run fake PBKDF2 to prevent timing oracle (GHSA-3w6p-vwhf-wvp4) + _ = (?GET_INTERNAL_OPT(fake_passwd_checker, Opts))(Password), {false, Ssh} end; @@ -550,7 +552,7 @@ get_password_option(Opts, User) -> {value, {User, Pw}} -> Pw; false -> ?GET_OPT(password, Opts) end. - + pre_verify_sig(User, KeyBlob, #ssh{opts=Opts}) -> try Key = ssh_message:ssh2_pubkey_decode(KeyBlob), % or exception
lib/ssh/src/ssh_options.erl+13 −6 modified@@ -43,8 +43,7 @@ no_sensitive/2, initial_default_algorithms/2, check_preferred_algorithms/1, - merge_options/3 - ]). + merge_options/3]). -export_type([private_options/0 ]). @@ -231,11 +230,10 @@ handle_options(Role, OptsList0, Opts0) when is_map(Opts0), ] }, OptionDefinitions), - - %% Enter the user's values into the map; unknown keys are %% treated as socket options - check_and_save(OptsList2, OptionDefinitions, InitialMap) + maybe_add_fake_passwd_checker(Role, + check_and_save(OptsList2, OptionDefinitions, InitialMap)) catch error:{EO, KV, Reason} when EO == eoptions ; EO == eerl_env -> if @@ -253,7 +251,16 @@ check_and_save(OptsList, OptionDefinitions, InitialMap) -> lists:foldl(fun(KV, Vals) -> save(KV, OptionDefinitions, Vals) end, InitialMap, OptsList)). - + +maybe_add_fake_passwd_checker(server, Options) -> + case ?GET_OPT(pwdfun, Options) of + undefined -> + ?PUT_INTERNAL_OPT({fake_passwd_checker, make_passwd_fun("fake")}, Options); + _ -> + Options + end; +maybe_add_fake_passwd_checker(_Client, Options) -> + Options. cnf_key(server) -> server_options; cnf_key(client) -> client_options.
lib/ssh/test/ssh_options_SUITE.erl+66 −0 modified@@ -57,6 +57,9 @@ max_sessions_drops_tcp_connects/0, server_password_option/1, server_userpassword_option/1, + server_userpassword_timing/0, + server_userpassword_timing/1, + server_pubkey_timing/1, server_pwdfun_option/1, server_pwdfun_4_option/1, server_keyboard_interactive/1, @@ -124,6 +127,8 @@ all() -> connectfun_disconnectfun_client, server_password_option, server_userpassword_option, + server_userpassword_timing, + server_pubkey_timing, server_pwdfun_option, server_pwdfun_4_option, server_keyboard_interactive, @@ -2294,3 +2299,64 @@ test_not_connect(Config, Host, Port, Opts) -> error:{badmatch, {error,_}} -> ok end. + +%%-------------------------------------------------------------------- +%% Verify that password auth timing does not reveal username validity. +%% Regression test for GHSA-3w6p-vwhf-wvp4. +server_userpassword_timing() -> + [{timetrap, {seconds,60}}]. + +server_userpassword_timing(Config) when is_list(Config) -> + UserDir = proplists:get_value(user_dir, Config), + SysDir = proplists:get_value(data_dir, Config), + {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SysDir}, + {user_dir, UserDir}, + {auth_methods, "password"}, + {user_passwords, [{"alice", "s3cret"}]}]), + Opts = [{silently_accept_hosts, true}, + {user_interaction, false}, + {save_accepted_host, false}, + {auth_methods, "password"}, + {user_dir, UserDir}, + {password, "wrong"}], + F = fun(User) -> time_auth(Host, Port, User, Opts) end, + ssh_test_lib:assert_timing_symmetry(F, "alice", "invalid"), + ssh:stop_daemon(Pid). + +%%-------------------------------------------------------------------- +%% Verify that pubkey auth with pk_check_user timing does not reveal +%% username validity. Regression test for GHSA-3w6p-vwhf-wvp4. +server_pubkey_timing(Config) when is_list(Config) -> + UserDir = proplists:get_value(priv_dir, Config), + SysDir = proplists:get_value(data_dir, Config), + %% Server user_dir has no authorized_keys, so pubkey auth fails + %% for both users. This isolates the pk_check_user timing from + %% auth success/failure differences. + ServerUserDir = filename:join(UserDir, "srv_no_authkeys"), + ok = file:make_dir(ServerUserDir), + ssh_test_lib:setup_all_user_host_keys(Config), + {Pid, Host, Port} = ssh_test_lib:daemon([{system_dir, SysDir}, + {user_dir, ServerUserDir}, + {auth_methods, "publickey"}, + {user_passwords, [{"alice", "s3cret"}]}, + {pk_check_user, true}]), + Opts = [{silently_accept_hosts, true}, + {user_interaction, false}, + {save_accepted_host, false}, + {auth_methods, "publickey"}, + {user_dir, UserDir}], + F = fun(User) -> time_auth(Host, Port, User, Opts) end, + ssh_test_lib:assert_timing_symmetry(F, "alice", "invalid"), + ssh:stop_daemon(Pid). + +time_auth(Host, Port, User, Opts) -> + T0 = erlang:monotonic_time(millisecond), + Result = ssh:connect(Host, Port, [{user, User} | Opts]), + T1 = erlang:monotonic_time(millisecond), + case Result of + {ok, C} -> ssh:close(C); + _ -> ok + end, + Delta = T1 - T0, + ?CT_LOG("Connection result = ~p in ~p ms", [Result, Delta]), + Delta.
lib/ssh/test/ssh_test_lib.erl+36 −1 modified@@ -142,7 +142,9 @@ remove_comment/1 -export([log/2, get_log_level/0, set_log_level/1, add_log_handler/2, rm_log_handler/1, - get_log_events/1]). + get_log_events/1, + median/1, + assert_timing_symmetry/3]). -include_lib("common_test/include/ct.hrl"). -include("ssh_transport.hrl"). @@ -1593,3 +1595,36 @@ get_public_key_algorithms_with_valid_host_key(Config, Options) -> Opts = #{key_cb => KeyCb, key_cb_options => [{system_dir, system_dir(Config)}]}, PubKeyAlgs = ssh_transport:supported_algorithms(public_key), lists:filter(fun(Alg) -> ?HAS_HOST_KEY(Alg, Opts) end, PubKeyAlgs). + +median(List) -> + Sorted = lists:sort(List), + Len = length(Sorted), + case Len rem 2 of + 1 -> lists:nth((Len + 1) div 2, Sorted); + 0 -> (lists:nth(Len div 2, Sorted) + + lists:nth(Len div 2 + 1, Sorted)) / 2 + end. + +assert_timing_symmetry(MeasureFun, ValidInput, InvalidInput) -> + N = 8, + Warmup = 3, + CollectSamples = + fun(Input) -> + lists:sublist( + [begin + ?CT_LOG("Collecting sample #~p of total ~p", [I, N]), + MeasureFun(Input) + end || I <- lists:seq(1, N)], + Warmup + 1, N - Warmup) + end, + MV = median(CollectSamples(ValidInput)), + MI = median(CollectSamples(InvalidInput)), + Ratio = max(MV / MI, MI / MV), + ?CT_LOG("Valid(~p) median=~p ms, Invalid(~p) median=~p ms, Ratio=~.2f", + [ValidInput, MV, InvalidInput, MI, Ratio]), + case Ratio > 3.0 of + true -> + ct:fail("Timing ratio ~.2f exceeds 3.0 — possible timing oracle", [Ratio]); + false -> + ok + end.
Vulnerability mechanics
Root cause
"The SSH daemon exhibits a timing discrepancy between handling valid and invalid usernames during password authentication."
Attack vector
An unauthenticated remote attacker can enumerate valid usernames by observing the response time of the SSH daemon during password authentication attempts. The vulnerability is triggered when the SSH daemon is configured with the `user_passwords` or `password` option. The attacker sends authentication requests with different usernames and measures the response time. A quick response indicates an invalid username, while a delayed response suggests a valid username [ref_id=1].
Affected code
The vulnerability resides in the `ssh_auth` and `ssh_options` modules, specifically within the `ssh_auth:check_password/3` function and the `ssh_options:get_password_option/2` path. The patch modifies tests related to `server_userpassword_timing` and `server_pubkey_timing` in the test suite to verify the fix [ref_id=1].
What the fix does
The patch modifies the SSH daemon to ensure consistent timing for both valid and invalid usernames during password authentication. Previously, valid usernames triggered a computationally intensive PBKDF2-SHA256 calculation, causing a delay, while invalid usernames returned immediately. The fix equalizes the processing time, removing the timing side-channel that allowed username enumeration [patch_id=5502031]. The advisory notes that `pwdfun` is the recommended alternative and is not affected.
Preconditions
- configThe SSH daemon must be configured with either the `user_passwords` or `password` option.
- authThe attacker is unauthenticated.
- networkThe attacker has network access to the SSH daemon.
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
1- Erlang OTP: Seven Vulnerabilities Disclosed, Including High-Severity FlawsVypr Intelligence · Jun 10, 2026