VYPR
Medium severityNVD Advisory· Published Jun 10, 2026

CVE-2026-48858

CVE-2026-48858

Description

Server-Side Request Forgery (SSRF) vulnerability in Erlang/OTP ftp (ftp_internal module) allows FTP bounce attacks and SSRF via an unvalidated PASV response IP address.

The ftp_internal:handle_ctrl_result/2 PASV handler (mode=passive, ipfamily=inet, ftp_extension=false) extracts the IP address from the server's 227 response and passes it directly to gen_tcp:connect/4 without validating it against the control connection peer address. The adjacent EPSV handlers correctly call peername(CSock) to derive the IP from the control connection, but the PASV handler does not. A malicious or compromised FTP server can redirect the client's data connection to an arbitrary internal host and port. On read operations (ftp:ls/1,2, ftp:nlist/1,2, ftp:recv/2,3), data from the redirected target is returned to the caller. On write operations (ftp:send/2,3, ftp:append/2,3), file content is sent to the redirected target. This enables SSRF against internal hosts, cloud metadata endpoints, and FTP bounce attacks against third-party hosts.

The vulnerable path is the default configuration (mode=passive, ipfamily=inet, ftp_extension=false). RFC 2577 section 3 explicitly recommends validating the PASV response IP against the control connection peer.

The ftp application is deprecated and scheduled for removal in OTP-30.

This vulnerability is associated with program files lib/inets/src/ftp/ftp_internal.erl (inets 5.10.4 through 6.5, OTP 17.4 through 20.3) and lib/ftp/src/ftp_internal.erl (ftp 1.0 and later, OTP 21.0 and later).

This issue affects OTP from OTP 17.4 before 29.0.2, 28.5.0.2 and 27.3.4.13 corresponding to inets from 5.10.4 before 7.0 and ftp from 1.0 before 1.2.6, 1.2.4.1 and 1.2.3.1.

Affected products

4
  • SSH/SSHreferences
  • Erlang/ftpllm-create
    Range: 1.0 and later, before 1.2.6, 1.2.4.1 and 1.2.3.1
  • Erlang/OTPllm-fuzzy
    Range: OTP 17.4 through 20.3, OTP 21.0 and later before 29.0.2, 28.5.0.2 and 27.3.4.13
  • Erlang/inetsllm-fuzzy
    Range: 5.10.4 through 6.5, before 7.0

Patches

2
521bcfa24407

ftp: validate PASV response IP against control connection peer

https://github.com/erlang/otpJonatan MännchenJun 3, 2026via body-scan
2 files changed · +121 3
  • lib/ftp/src/ftp_internal.erl+3 2 modified
    @@ -1505,6 +1505,7 @@ handle_ctrl_result({pos_compl, Lines},
                               ipfamily = inet,
                               client   = From,
                               caller   = {setup_data_connection, Caller},
    +                          csock    = CSock,
                               timeout  = Timeout,
                               sockopts_data_passive = SockOpts,
                               ftp_extension = false} = State) when is_list(Lines) ->
    @@ -1513,10 +1514,10 @@ handle_ctrl_result({pos_compl, Lines},
             lists:splitwith(fun(?LEFT_PAREN) -> false; (_) -> true end, Lines),
         {NewPortAddr, _} =
             lists:splitwith(fun(?RIGHT_PAREN) -> false; (_) -> true end, Rest),
    -    [A1, A2, A3, A4, P1, P2] =
    +    [_, _, _, _, P1, P2] =
             lists:map(fun(X) -> list_to_integer(X) end,
                       string:tokens(NewPortAddr, [$,])),
    -    IP   = {A1, A2, A3, A4},
    +    {ok, {IP, _}} = peername(CSock),
         Port = (P1 * 256) + P2,
     
         ?DBG('<--data tcp connect to ~p:~p, Caller=~p~n',[IP,Port,Caller]),
    
  • lib/ftp/test/ftp_SUITE.erl+118 1 modified
    @@ -63,7 +63,8 @@ all() ->
          appup,
          error_ehost,
          error_datafail,
    -     clean_shutdown
    +     clean_shutdown,
    +     pasv_ip_not_validated
         ].
     
     groups() ->
    @@ -317,6 +318,8 @@ init_per_testcase(Case, Config0) ->
             clean_shutdown ->
                 Config = start_ftpd(Config0),
                 init_per_testcase2(Case, Config);
    +        pasv_ip_not_validated ->
    +            Config0;
             _ ->
                 init_per_testcase2(Case, Config0)
         end.
    @@ -376,6 +379,7 @@ end_per_testcase(user, _Config) -> ok;
     end_per_testcase(bad_user, _Config) -> ok;
     end_per_testcase(error_elogin, _Config) -> ok;
     end_per_testcase(error_ehost, _Config) -> ok;
    +end_per_testcase(pasv_ip_not_validated, _Config) -> ok;
     end_per_testcase(T, Config) when T =:= error_datafail; T =:= clean_shutdown ->
         T == error_datafail andalso ftp__close(Config),
         stop_ftpd(Config),
    @@ -1099,6 +1103,69 @@ error_datafail(Config) ->
         Result = Recv(Recv),
         Result.
     
    +pasv_ip_not_validated() ->
    +    [{doc, "PASV response IP must be validated against the control connection "
    +      "peer address (CVE-2026-48858 / GHSA-24cv-hwgr-37fq). A malicious server "
    +      "must not be able to redirect the data connection to an arbitrary host."}].
    +
    +pasv_ip_not_validated(_Config) ->
    +    %% The victim service listens on 127.0.0.2 (a different loopback address).
    +    %% The malicious FTP server listens on 127.0.0.1.
    +    %% The PASV response will advertise 127.0.0.2:VictimPort.
    +    %% Without the fix the client connects to 127.0.0.2 (victim).
    +    %% With the fix the client ignores the IP in PASV and uses the control
    +    %% peer address (127.0.0.1) instead, so the victim never gets a connection.
    +    VictimIP = {127,0,0,2},
    +    {ok, VictimLSock} = gen_tcp:listen(0,
    +        [binary, {reuseaddr, true}, {active, false}, inet, {ip, VictimIP}]),
    +    {ok, VictimPort} = inet:port(VictimLSock),
    +
    +    Self = self(),
    +    spawn(fun() ->
    +        case gen_tcp:accept(VictimLSock, 3000) of
    +            {ok, Sock} ->
    +                {ok, Peer} = inet:peername(Sock),
    +                gen_tcp:close(Sock),
    +                Self ! {victim_connected, Peer};
    +            {error, _} ->
    +                Self ! victim_not_connected
    +        end
    +    end),
    +
    +    %% Malicious FTP server on 127.0.0.1.
    +    {ok, FtpLSock} = gen_tcp:listen(0,
    +        [binary, {reuseaddr, true}, {active, false}, inet, {ip, {127,0,0,1}}]),
    +    {ok, FtpPort} = inet:port(FtpLSock),
    +
    +    spawn_link(fun() -> malicious_ftp_server(FtpLSock, {VictimIP, VictimPort}) end),
    +
    +    application:ensure_started(ftp),
    +    {ok, Pid} = ftp:open("127.0.0.1", [{port, FtpPort}]),
    +    ok = ftp:user(Pid, "user", "pass"),
    +    %% The ls call will trigger PASV.  With the vulnerability present the
    +    %% client connects to VictimPort; with the fix it should refuse to do so
    +    %% and return an error instead.
    +    _Ignored = ftp:ls(Pid),
    +    catch ftp:close(Pid),
    +
    +    Result = receive
    +        {victim_connected, Peer} ->
    +            {fail, Peer};
    +        victim_not_connected ->
    +            ok
    +    end,
    +
    +    gen_tcp:close(FtpLSock),
    +    gen_tcp:close(VictimLSock),
    +
    +    case Result of
    +        {fail, FailPeer} ->
    +            ct:fail("ftp client connected data channel to redirected victim "
    +                    "address ~p instead of the FTP server (CVE-2026-48858)",
    +                    [FailPeer]);
    +        ok ->
    +            ok
    +    end.
     %%--------------------------------------------------------------------
     %% Internal functions  -----------------------------------------------
     %%--------------------------------------------------------------------
    @@ -1410,3 +1477,53 @@ unwanted_error_report(LogFile) ->
                 ct:fail({no_logfile, LogFile})
         end.
     
    +%% Minimal FTP server that injects a malicious PASV redirect.
    +malicious_ftp_server(LSock, VictimAddr) ->
    +    {ok, Ctrl} = gen_tcp:accept(LSock),
    +    gen_tcp:send(Ctrl, "220 PoC FTP Server\r\n"),
    +    malicious_ftp_loop(Ctrl, VictimAddr).
    +
    +malicious_ftp_loop(Ctrl, VictimAddr) ->
    +    case malicious_ftp_recv_line(Ctrl) of
    +        {ok, Line} ->
    +            [Cmd | _] = string:tokens(string:trim(Line), " "),
    +            malicious_ftp_handle(string:uppercase(Cmd), Ctrl, VictimAddr),
    +            malicious_ftp_loop(Ctrl, VictimAddr);
    +        {error, _} ->
    +            gen_tcp:close(Ctrl)
    +    end.
    +
    +malicious_ftp_handle("USER", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "331 Password required\r\n");
    +malicious_ftp_handle("PASS", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "230 Logged in\r\n");
    +malicious_ftp_handle("SYST", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "215 UNIX Type: L8\r\n");
    +malicious_ftp_handle("TYPE", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "200 Type set\r\n");
    +malicious_ftp_handle("PASV", Ctrl, {{A1,A2,A3,A4}, VictimPort}) ->
    +    %% Advertise the victim IP:port — a different host than the FTP server.
    +    P1 = VictimPort bsr 8,
    +    P2 = VictimPort band 16#FF,
    +    Resp = io_lib:format(
    +        "227 Entering Passive Mode (~b,~b,~b,~b,~b,~b)\r\n",
    +        [A1, A2, A3, A4, P1, P2]),
    +    gen_tcp:send(Ctrl, Resp);
    +malicious_ftp_handle(Cmd, Ctrl, _) when Cmd =:= "LIST"; Cmd =:= "NLST" ->
    +    gen_tcp:send(Ctrl, "150 Opening data connection\r\n"),
    +    timer:sleep(200),
    +    gen_tcp:send(Ctrl, "226 Transfer complete\r\n");
    +malicious_ftp_handle("QUIT", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "221 Goodbye\r\n"),
    +    gen_tcp:close(Ctrl);
    +malicious_ftp_handle(_, Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "500 Unknown command\r\n").
    +
    +malicious_ftp_recv_line(Sock) ->
    +    malicious_ftp_recv_line(Sock, <<>>).
    +malicious_ftp_recv_line(Sock, Acc) ->
    +    case gen_tcp:recv(Sock, 1, 5000) of
    +        {ok, <<"\n">>} -> {ok, binary_to_list(<<Acc/binary, "\n">>)};
    +        {ok, Byte}     -> malicious_ftp_recv_line(Sock, <<Acc/binary, Byte/binary>>);
    +        {error, _} = E -> E
    +    end.
    
2691a806231f

ftp: validate PASV response IP against control connection peer

https://github.com/erlang/otpJonatan MännchenJun 3, 2026via body-scan
2 files changed · +121 3
  • lib/ftp/src/ftp_internal.erl+3 2 modified
    @@ -1507,6 +1507,7 @@ handle_ctrl_result({pos_compl, Lines},
                               ipfamily = inet,
                               client   = From,
                               caller   = {setup_data_connection, Caller},
    +                          csock    = CSock,
                               timeout  = Timeout,
                               sockopts_data_passive = SockOpts,
                               ftp_extension = false} = State) when is_list(Lines) ->
    @@ -1515,10 +1516,10 @@ handle_ctrl_result({pos_compl, Lines},
             lists:splitwith(fun(?LEFT_PAREN) -> false; (_) -> true end, Lines),
         {NewPortAddr, _} =
             lists:splitwith(fun(?RIGHT_PAREN) -> false; (_) -> true end, Rest),
    -    [A1, A2, A3, A4, P1, P2] =
    +    [_, _, _, _, P1, P2] =
             lists:map(fun(X) -> list_to_integer(X) end,
                       string:tokens(NewPortAddr, [$,])),
    -    IP   = {A1, A2, A3, A4},
    +    {ok, {IP, _}} = peername(CSock),
         Port = (P1 * 256) + P2,
     
         ?DBG('<--data tcp connect to ~p:~p, Caller=~p~n',[IP,Port,Caller]),
    
  • lib/ftp/test/ftp_SUITE.erl+118 1 modified
    @@ -65,7 +65,8 @@ all() ->
          appup,
          error_ehost,
          error_datafail,
    -     clean_shutdown
    +     clean_shutdown,
    +     pasv_ip_not_validated
         ].
     
     groups() ->
    @@ -319,6 +320,8 @@ init_per_testcase(Case, Config0) ->
             clean_shutdown ->
                 Config = start_ftpd(Config0),
                 init_per_testcase2(Case, Config);
    +        pasv_ip_not_validated ->
    +            Config0;
             _ ->
                 init_per_testcase2(Case, Config0)
         end.
    @@ -378,6 +381,7 @@ end_per_testcase(user, _Config) -> ok;
     end_per_testcase(bad_user, _Config) -> ok;
     end_per_testcase(error_elogin, _Config) -> ok;
     end_per_testcase(error_ehost, _Config) -> ok;
    +end_per_testcase(pasv_ip_not_validated, _Config) -> ok;
     end_per_testcase(T, Config) when T =:= error_datafail; T =:= clean_shutdown ->
         T == error_datafail andalso ftp__close(Config),
         stop_ftpd(Config),
    @@ -1101,6 +1105,69 @@ error_datafail(Config) ->
         Result = Recv(Recv),
         Result.
     
    +pasv_ip_not_validated() ->
    +    [{doc, "PASV response IP must be validated against the control connection "
    +      "peer address (CVE-2026-48858 / GHSA-24cv-hwgr-37fq). A malicious server "
    +      "must not be able to redirect the data connection to an arbitrary host."}].
    +
    +pasv_ip_not_validated(_Config) ->
    +    %% The victim service listens on 127.0.0.2 (a different loopback address).
    +    %% The malicious FTP server listens on 127.0.0.1.
    +    %% The PASV response will advertise 127.0.0.2:VictimPort.
    +    %% Without the fix the client connects to 127.0.0.2 (victim).
    +    %% With the fix the client ignores the IP in PASV and uses the control
    +    %% peer address (127.0.0.1) instead, so the victim never gets a connection.
    +    VictimIP = {127,0,0,2},
    +    {ok, VictimLSock} = gen_tcp:listen(0,
    +        [binary, {reuseaddr, true}, {active, false}, inet, {ip, VictimIP}]),
    +    {ok, VictimPort} = inet:port(VictimLSock),
    +
    +    Self = self(),
    +    spawn(fun() ->
    +        case gen_tcp:accept(VictimLSock, 3000) of
    +            {ok, Sock} ->
    +                {ok, Peer} = inet:peername(Sock),
    +                gen_tcp:close(Sock),
    +                Self ! {victim_connected, Peer};
    +            {error, _} ->
    +                Self ! victim_not_connected
    +        end
    +    end),
    +
    +    %% Malicious FTP server on 127.0.0.1.
    +    {ok, FtpLSock} = gen_tcp:listen(0,
    +        [binary, {reuseaddr, true}, {active, false}, inet, {ip, {127,0,0,1}}]),
    +    {ok, FtpPort} = inet:port(FtpLSock),
    +
    +    spawn_link(fun() -> malicious_ftp_server(FtpLSock, {VictimIP, VictimPort}) end),
    +
    +    application:ensure_started(ftp),
    +    {ok, Pid} = ftp:open("127.0.0.1", [{port, FtpPort}]),
    +    ok = ftp:user(Pid, "user", "pass"),
    +    %% The ls call will trigger PASV.  With the vulnerability present the
    +    %% client connects to VictimPort; with the fix it should refuse to do so
    +    %% and return an error instead.
    +    _Ignored = ftp:ls(Pid),
    +    catch ftp:close(Pid),
    +
    +    Result = receive
    +        {victim_connected, Peer} ->
    +            {fail, Peer};
    +        victim_not_connected ->
    +            ok
    +    end,
    +
    +    gen_tcp:close(FtpLSock),
    +    gen_tcp:close(VictimLSock),
    +
    +    case Result of
    +        {fail, FailPeer} ->
    +            ct:fail("ftp client connected data channel to redirected victim "
    +                    "address ~p instead of the FTP server (CVE-2026-48858)",
    +                    [FailPeer]);
    +        ok ->
    +            ok
    +    end.
     %%--------------------------------------------------------------------
     %% Internal functions  -----------------------------------------------
     %%--------------------------------------------------------------------
    @@ -1412,3 +1479,53 @@ unwanted_error_report(LogFile) ->
                 ct:fail({no_logfile, LogFile})
         end.
     
    +%% Minimal FTP server that injects a malicious PASV redirect.
    +malicious_ftp_server(LSock, VictimAddr) ->
    +    {ok, Ctrl} = gen_tcp:accept(LSock),
    +    gen_tcp:send(Ctrl, "220 PoC FTP Server\r\n"),
    +    malicious_ftp_loop(Ctrl, VictimAddr).
    +
    +malicious_ftp_loop(Ctrl, VictimAddr) ->
    +    case malicious_ftp_recv_line(Ctrl) of
    +        {ok, Line} ->
    +            [Cmd | _] = string:tokens(string:trim(Line), " "),
    +            malicious_ftp_handle(string:uppercase(Cmd), Ctrl, VictimAddr),
    +            malicious_ftp_loop(Ctrl, VictimAddr);
    +        {error, _} ->
    +            gen_tcp:close(Ctrl)
    +    end.
    +
    +malicious_ftp_handle("USER", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "331 Password required\r\n");
    +malicious_ftp_handle("PASS", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "230 Logged in\r\n");
    +malicious_ftp_handle("SYST", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "215 UNIX Type: L8\r\n");
    +malicious_ftp_handle("TYPE", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "200 Type set\r\n");
    +malicious_ftp_handle("PASV", Ctrl, {{A1,A2,A3,A4}, VictimPort}) ->
    +    %% Advertise the victim IP:port — a different host than the FTP server.
    +    P1 = VictimPort bsr 8,
    +    P2 = VictimPort band 16#FF,
    +    Resp = io_lib:format(
    +        "227 Entering Passive Mode (~b,~b,~b,~b,~b,~b)\r\n",
    +        [A1, A2, A3, A4, P1, P2]),
    +    gen_tcp:send(Ctrl, Resp);
    +malicious_ftp_handle(Cmd, Ctrl, _) when Cmd =:= "LIST"; Cmd =:= "NLST" ->
    +    gen_tcp:send(Ctrl, "150 Opening data connection\r\n"),
    +    timer:sleep(200),
    +    gen_tcp:send(Ctrl, "226 Transfer complete\r\n");
    +malicious_ftp_handle("QUIT", Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "221 Goodbye\r\n"),
    +    gen_tcp:close(Ctrl);
    +malicious_ftp_handle(_, Ctrl, _) ->
    +    gen_tcp:send(Ctrl, "500 Unknown command\r\n").
    +
    +malicious_ftp_recv_line(Sock) ->
    +    malicious_ftp_recv_line(Sock, <<>>).
    +malicious_ftp_recv_line(Sock, Acc) ->
    +    case gen_tcp:recv(Sock, 1, 5000) of
    +        {ok, <<"\n">>} -> {ok, binary_to_list(<<Acc/binary, "\n">>)};
    +        {ok, Byte}     -> malicious_ftp_recv_line(Sock, <<Acc/binary, Byte/binary>>);
    +        {error, _} = E -> E
    +    end.
    

Vulnerability mechanics

Root cause

"The ftp_internal:handle_ctrl_result/2 PASV handler does not validate the IP address from the server's 227 response against the control connection peer address."

Attack vector

An attacker can set up a malicious FTP server that responds to a PASV command with an IP address pointing to an internal host or an arbitrary third-party host. The vulnerable client, when processing this response, will attempt to establish a data connection to the attacker-controlled IP address instead of the expected server IP. This allows for Server-Side Request Forgery (SSRF) against internal services or FTP bounce attacks against other hosts [ref_id=1].

Affected code

The vulnerability resides in the `ftp_internal:handle_ctrl_result/2` function within the `ftp_internal.erl` file. Specifically, the PASV handler logic for `mode=passive`, `ipfamily=inet`, and `ftp_extension=false` is affected. The issue is present in the `inets` application from version 5.10.4 up to 6.5 and in the `ftp` application from version 1.0 onwards.

What the fix does

The patch modifies the PASV handler to validate the IP address received in the server's 227 response against the IP address of the control connection peer. This ensures that the data connection is only established to the legitimate FTP server, preventing redirection to arbitrary internal or external hosts [ref_id=1, patch_id=5502033, patch_id=5502034].

Preconditions

  • configThe FTP client must be configured with `mode=passive`, `ipfamily=inet`, and `ftp_extension=false` [ref_id=1].
  • networkThe client must connect to an FTP server that is either malicious or compromised.

Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

1