VYPR
Medium severityNVD Advisory· Published Jun 10, 2026

CVE-2026-48859

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

3

Patches

1
c342092ef4b3

ssh: Fix PBKDF2 timing oracle in password authentication

https://github.com/erlang/otpJakub WitczakJun 3, 2026via body-scan
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

5

News mentions

1