HexBrain

curl · Report #4122567

passive FTPS data connection handshake blocks curl_multi_perform() with no timeout (regression in 8.19.0)

Informativestatus
lowseverity
4upvotes
23 Oct 2026disclosed

How it was found

Summary:

On a passive FTPS transfer libcurl runs the TLS handshake of the data connection inside a blocking Curl_conn_connect() call after the server's 150 (lib/ftp.c line 550 in curl-8_23_0, ftp_initiate_transfer()), and that blocking loop (lib/connect.c line 334) bounds itself with Curl_timeleft_ms(data), which since 8ce16e7bf2 (first shipped in 8.19.0) returns "no limit" for anything at or past MSTATE_DO unless the application set CURLOPT_TIMEOUT. So a server that answers 150 and never completes the handshake on the data connection keeps curl_multi_perform() from returning at all, CURLOPT_CONNECTTIMEOUT, CURLOPT_SERVER_RESPONSE_TIMEOUT and the low speed limit never get to fire, and every other transfer on that multi handle is stuck with it. Before that commit the loop called Curl_timeleft_ms(data, TRUE) and the connect timeout (or its 300 s default) always bounded the wait, which 8.18.0 still does. Reproduced with the attached local server (implicit FTPS, normal replies, 150 then hold the passive data socket without a handshake): on curl-8_23_0, curl --connect-timeout 2 --cacert cert.pem ftps://127.0.0.1:8322/file runs until killed (20 s and 90 s wrappers, the 150 is the last line of --trace-time output) and -m 5 ends it with "curl: (28) connect timeout" at 5.0 s, which is the failf inside that same loop, while curl-8_18_0 fails with that error at 2.0 s. With the attached multi program (the ftps transfer plus a plain http transfer with a 3 s server delay, added one second in) 8.18.0 completes both with the longest curl_multi_perform() call at 0.45 s, 8.23.0 never returns from the call that entered the handshake and the http transfer never finishes, and a plain FTP server doing the same thing is the control and keeps the loop turning. The attached 18 line patch applies the connect timeout to a blocking wait that happens past the connect phase, measured from the start of that wait and taking the smaller of it and the total timeout, which makes 8.23.0 behave like 8.18.0 again with the test suite unchanged; AI tooling helped with the fixture scripts along with this report draft, the reproduction and the numbers are mine.

Affected version

curl 8.23.0 built from the curl-8_23_0 tag (3163ec0656) on Linux x86_64 with OpenSSL 3.6.4, --enable-debug. By the commit it is 8.19.0 through 8.23.0; curl-8_18_0 built the same way is not affected. curl -V: curl 8.23.0-DEV (x86_64-pc-linux-gnu) libcurl/8.23.0-DEV OpenSSL/3.6.4 zlib/1.3.2 brotli/1.2.0 zstd/1.5.7 libidn2/2.3.8 libpsl/0.23.3 nghttp2/1.70.0 OpenLDAP/2.6.14

Steps To Reproduce:

  1. In one dir: openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 3 -subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1'
  2. python3 ftps_stall.py 8322 --implicit --delay150 1.5 (from the attachment; it logs every command and "data conn accepted, holding without handshake" when the ClientHello arrives)
  3. timeout 30 curl --connect-timeout 2 --cacert cert.pem -o /dev/null ftps://127.0.0.1:8322/file ; echo $? -> 124 after 30 s on 8.23.0, "curl: (28) connect timeout" after 2 s on 8.18.0
  4. Control: the same with -m 5 instead of --connect-timeout 2 -> "curl: (28) connect timeout" at 5 s on 8.23.0, so the wait is that blocking loop and only the total timeout reaches it
  5. Multi: python3 slow_http.py 8325 3, build multi_starve.c against libcurl, then timeout 30 ./multi_starve ftps://127.0.0.1:8322/file http://127.0.0.1:8325/ cert.pem 2 -> on 8.23.0 the output stops after "1.05s http handle added" and the process is killed at 30 s; swap in ftps_stall.py 8324 --plain --delay150 1.5 and ftp://127.0.0.1:8324/file and the http transfer completes at 4 s
  6. Apply fix-conn-connect-blocking-ctimeout.patch to 8.23.0, rebuild, repeat 3 and 5 -> "connect timeout" at 3.6 s (the fixture's 1.5 s plus the 2 s connect timeout counted from the start of the wait), the multi program finishes both transfers with the longest perform call at 2.0 s

Impact

Summary:

A server, or anything that can hold the data connection open without completing TLS, stops the whole multi handle for as long as it likes: no transfer on that handle makes progress, the documented counter-measures for a connect that never completes (CURLOPT_CONNECTTIMEOUT, default 300 s) and for a silent server (CURLOPT_SERVER_RESPONSE_TIMEOUT) do nothing, and only an application-set total timeout gets it back. This is the same class as CVE-2026-92387, the timeout mechanism is bypassed and all concurrent transfers on the multi are blocked, it needs no option set, it hits the plain ftps:// scheme in passive mode which is the default, and 8.18.0 enforced the bound that 8.19.0 dropped.

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