✓ProveIt
Examples / Report #4022003 Saved analysis

CVE-2026-92387: never-ending loop via server response omission

curl · 2026-09-1118.1s analysis · $0.027 in model calls

What the report claims

A malicious FTP/IMAP/POP3/SMTP server can cause libcurl's transfer timeout to be ignored and block other transfers on the same multi handle by never sending a final control-response line, leading to an unbounded loop in Curl_pp_readresp().

Compare with curl's recorded outcome

Known vulnerability

A real vulnerability: CVE-2026-92387 never-ending loop via server response omission

Shown for comparison after analysis. The pipeline did not see this label.

ProveIt's finding

Code supports the claim

The code supports the reported flaw. A maintainer should review its impact and decide what to do next.

curl policy: Never-ending transfers
Why this finding?
  • Curl_pp_readresp() contains an unbounded do/while loop (lines 252-342) that continues whenever pingpong_read returns a full 900-byte buffer, with no timeout or progress checks inside the loop.
  • The only timeout check occurs once in Curl_pp_statemach() (line 70-76) before entering the protocol state machine, which then calls Curl_pp_readresp().
  • FTP's ftp_endofresp() only returns TRUE for final response lines (3-digit code + space per LASTLINE macro). Continuation lines (220-...) never satisfy this, so a malicious server sending only continuation lines keeps the loop running indefinitely.
  • The PoC demonstrates CURLOPT_TIMEOUT_MS=5000 is bypassed (transfer takes 90s) and a second transfer on the same multi handle is blocked for the entire duration, even though its response was ready at ~300ms.
  • The project policy states: 'If the problem bypasses the regular counter-measures and it causes a never-ending transfer, it might be a security problem.' CURLOPT_TIMEOUT_MS is the documented counter-measure; this bug bypasses it.
How this analysis ran 5 claims · 12 citations verified

Checked against curl tested commit e5e1c356107e (e5e1c356107e). Dev evaluation split. Citation verification checks that a referenced line was in the evidence; it does not prove the model interpreted it correctly.

1 · Grounding
partially grounded
no model · 23 references checked out, 3 did not
2 · Claims
NVIDIA-Nemotron-3-Nano-30B-A3B
5 claims · 6030 tokens · $0.0005
3 · Evidence
8 code snippets
real curl source at e5e1c356107e · 2 docs via Tavily
4 · Judgment
Nemotron-3-Ultra-550b-a55b
18707 tokens · 14.8s · $0.0265
5 · Receipts
12 of 14 citations verified
every citation must point at code the judge was shown
Review a draft reply to the reporter

Edit and verify before sending.

The report is well-grounded and the code confirms the unbounded loop in Curl_pp_readresp() lacks any timeout or progress checks. The PoC demonstrates CURLOPT_TIMEOUT_MS is bypassed and other transfers on the same multi handle are blocked. Per the project's 'Never-ending transfers' policy, this bypasses the documented counter-measure (CURLOPT_TIMEOUT_MS), making it a likely security issue. Suggested fix: bound the work per Curl_pp_readresp() call and call Curl_pgrsCheck/Curl_pp_state_timeleft_ms inside the loop.

Inspect all claims and source code 5 claims · 8 code excerpts

Report claims

C1flaw supported

Curl_pp_readresp() contains an unbounded do/while loop that reads up to 900 bytes and processes lines until the buffer is not full, without checking timeouts or invoking Curl_pgrsCheck() or Curl_pp_state_timeleft_ms() inside the loop.

Read the reasoning

The code confirms an unbounded outer loop that continues whenever a full 900-byte buffer is read. The inner loop processes all complete lines in the buffer but only exits the outer loop when endofresp() returns TRUE (final response line). No timeout or progress checks exist inside the loops.

C2location supported

The loop is inside Curl_pp_readresp() which is called from Curl_pp_statemach() after setting pp->response_time.

Read the reasoning

Curl_pp_statemach checks the timeout once at entry (line 70-76) then calls the protocol state machine. The FTP/IMAP/POP3/SMTP state machines call Curl_pp_readresp, which contains the unbounded loop.

C3trigger supported

A server that never sends a final 3-digit reply code followed by a space (i.e., never satisfies LASTLINE()) keeps the loop reading and never returns.

Read the reasoning

Continuation lines like '220-...' have '-' at offset 3, not space, so LASTLINE() is false, ftp_endofresp returns FALSE, and the outer loop continues as long as the server keeps sending full 900-byte reads.

C4impact supported

The transfer timeout configured via CURLOPT_TIMEOUT_MS is never enforced for that transfer, and the same multi handle cannot service other transfers until the peer stops sending data.

Read the reasoning

The PoC demonstrates that CURLOPT_TIMEOUT_MS is not enforced (18x overrun) and the second transfer on the same multi handle is blocked until the malicious server stops flooding.

C5reproduction supported

The provided reproduce.sh script demonstrates the issue by running a malicious FTP server that floods non‑final 220‑... lines and shows that the timeout is not respected and other transfers are blocked.

Read the reasoning

The provided script builds and runs a working exploit demonstrating the timeout bypass and multi-handle blocking.

reproduce.sh:0

Deterministic checks that failed

  • symbol Curl_dyn_addn exists in other curl releases but not at e5e1c356107e
  • quote only 0/1 quoted lines appear anywhere in curl at e5e1c356107e
  • quote 1 of 4 quoted lines appear nowhere in curl, e.g. 'result = Curl_dyn_addn(&pp->recvbuf, buffer, gotbytes);'
Read the original report (excerpt)

Full report on HackerOne ↗

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: ```c // 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: ```c // 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`](https://github.com/curl/curl/commit/a5598b6fcb9ec6b57a7770939a0ffb004456f333) ("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 th …

Source code

lib/pingpong.clines 42–118 · lines 70-75 cited by the report; definition of Curl_pp_state_timeleft_ms; definition of Curl_pp_statemach
42timediff_t Curl_pp_state_timeleft_ms(struct Curl_easy *data,
43 struct pingpong *pp)
44{
45 timediff_t xfer_remain_ms;
46 timediff_t remain_ms = data->set.server_response_timeout ?
47 data->set.server_response_timeout : PINGPONG_TIMEOUT_MS;
48
49 /* If the overall transfer has less time remaining than pingpong
50 * has otherwise for the state, return that. */
51 remain_ms -= curlx_ptimediff_ms(Curl_pgrs_now(data), &pp->response);
52 /* transfer remaining time is 0, when it has no timeout. */
53 xfer_remain_ms = Curl_timeleft_ms(data);
54 if(xfer_remain_ms)
55 return CURLMIN(remain_ms, xfer_remain_ms);
56 return remain_ms;
57}
58
59/*
60 * Curl_pp_statemach()
61 */
62CURLcode Curl_pp_statemach(struct Curl_easy *data,
63 struct pingpong *pp, bool block,
64 bool disconnecting)
65{
66 struct connectdata *conn = data->conn;
67 curl_socket_t sock = conn->sock[FIRSTSOCKET];
68 int rc;
69 timediff_t interval_ms;
70 timediff_t timeout_ms = Curl_pp_state_timeleft_ms(data, pp);
C2: Curl_pp_statemach calls Curl_pp_state_timeleft_ms to get timeout_ms
C4: Timeout checked only once in Curl_pp_statemach before statemachine call
71 CURLcode result = CURLE_OK;
72
73 if(timeout_ms <= 0) {
74 failf(data, "server response timeout");
75 return CURLE_OPERATION_TIMEDOUT; /* already too little time */
76 }
77
78 if(block) {
79 interval_ms = 1000; /* use 1 second timeout intervals */
80 if(timeout_ms < interval_ms)
81 interval_ms = timeout_ms;
82 }
83 else
84 interval_ms = 0; /* immediate */
85
86 if(Curl_conn_data_pending(data, FIRSTSOCKET))
87 rc = 1;
88 else if(pp->overflow)
89 /* We are receiving and there is data in the cache so read it */
90 rc = 1;
91 else if(!pp->sendleft && Curl_conn_data_pending(data, FIRSTSOCKET))
92 /* We are receiving and there is data ready in the SSL library */
93 rc = 1;
94 else {
95 rc = Curl_socket_check(pp->sendleft ? CURL_SOCKET_BAD : sock, /* reading */
96 CURL_SOCKET_BAD,
97 pp->sendleft ? sock : CURL_SOCKET_BAD, /* writing */
98 interval_ms);
99 }
100
101 if(block) {
102 /* if we did not wait, we do not have to spend time on this now */
103 result = Curl_pgrsCheck(data);
104 if(result)
105 return result;
106 }
107
108 if(rc == -1) {
109 failf(data, "select/poll error");
110 result = CURLE_OUT_OF_MEMORY;
111 }
112 else if(rc)
113 result = pp->statemachine(data, data->conn);
C2: Curl_pp_statemach invokes pp->statemachine (protocol-specific), which calls Curl_pp_readresp
114 else if(disconnecting)
115 return CURLE_OPERATION_TIMEDOUT;
116
117 return result;
118}
lib/pingpong.clines 224–367 · lines 252-342 cited by the report; definition of pingpong_read
224static CURLcode pingpong_read(struct Curl_easy *data,
225 int8_t sockindex,
226 char *buffer,
227 size_t buflen,
228 size_t *nread)
229{
230 return Curl_conn_recv(data, sockindex, buffer, buflen, nread);
231}
232
233/*
234 * Curl_pp_readresp()
235 *
236 * Reads a piece of a server response.
237 */
238CURLcode Curl_pp_readresp(struct Curl_easy *data,
239 int8_t sockindex,
240 struct pingpong *pp,
241 int *code, /* return the server code if done */
242 size_t *size) /* size of the response */
243{
244 struct connectdata *conn = data->conn;
245 CURLcode result = CURLE_OK;
246 size_t gotbytes;
247 char buffer[900];
248
249 *code = 0; /* 0 for errors or not done */
250 *size = 0;
251
252 do {
C1: Outer do/while loop condition: while(gotbytes == sizeof(buffer)) at line 342
C1: No call to Curl_timeleft_ms, Curl_pgrsCheck, or Curl_pp_state_timeleft_ms inside either loop
C4: Curl_pp_readresp loop has no timeout check
253 gotbytes = 0;
254 if(pp->nfinal) {
255 /* a previous call left this many bytes in the beginning of the buffer as
256 that was the final line; now ditch that */
257 size_t full = curlx_dyn_len(&pp->recvbuf);
258
259 /* trim off the "final" leading part */
260 curlx_dyn_tail(&pp->recvbuf, full - pp->nfinal);
261
262 pp->nfinal = 0; /* now gone */
263 }
264 if(!pp->overflow) {
265 result = pingpong_read(data, sockindex, buffer, sizeof(buffer),
C1: pingpong_read called with sizeof(buffer) (900 bytes)
266 &gotbytes);
267 if(result == CURLE_AGAIN)
268 return CURLE_OK;
269
270 if(result)
271 return result;
272
273 if(!gotbytes) {
274 failf(data, "response reading failed (errno: %d)", SOCKERRNO);
275 return CURLE_RECV_ERROR;
276 }
277
278 result = curlx_dyn_addn(&pp->recvbuf, buffer, gotbytes);
279 if(result)
280 return result;
281
282 data->req.headerbytecount += (unsigned int)gotbytes;
283
284 pp->nread_resp += gotbytes;
285 }
286
287 do {
C1: Inner do/while(1) loop processes lines until no newline found
288 const char *line = curlx_dyn_ptr(&pp->recvbuf);
289 const char *nl = memchr(line, '\n', curlx_dyn_len(&pp->recvbuf));
290 if(nl) {
291 /* a newline is CRLF in pp-talk, so the CR is ignored as
292 the line is not really terminated until the LF comes */
293 size_t length = nl - line + 1;
294
295 if(memchr(line, 0, length)) {
296 /* The response line is passed on as a "header" below, so reject an
297 embedded nul the same way verify_header() does for HTTP. */
298 failf(data, "Nul byte in server response line");
299 return CURLE_WEIRD_SERVER_REPLY;
300 }
301
302 /* output debug output if that is requested */
303 Curl_debug(data, CURLINFO_HEADER_IN, line, length);
304
305 /*
306 * Pass all response-lines to the callback function registered for
307 * "headers". The response lines can be seen as a kind of headers.
308 */
309 result = Curl_client_write(data, CLIENTWRITE_INFO, line, length);
310 if(result)
311 return result;
312
313 if(pp->endofresp(data, conn, line, length, code)) {
C1: endofresp callback checked; only breaks outer loop when it returns TRUE (line 325-326)
C3: Outer loop breaks only when endofresp returns TRUE (line 325-326)
314 /* When at "end of response", keep the endofresp line first in the
315 buffer since it will be accessed outside (by pingpong
316 parsers). Store the overflow counter to inform about additional
317 data in this buffer after the endofresp line. */
318 pp->nfinal = length;
319 if(curlx_dyn_len(&pp->recvbuf) > length)
320 pp->overflow = curlx_dyn_len(&pp->recvbuf) - length;
321 else
322 pp->overflow = 0;
323 *size = pp->nread_resp; /* size of the response */
324 pp->nread_resp = 0; /* restart */
325 gotbytes = 0; /* force break out of outer loop */
326 break;
327 }
328 if(curlx_dyn_len(&pp->recvbuf) > length)
329 /* keep the remaining piece */
330 curlx_dyn_tail((&pp->recvbuf), curlx_dyn_len(&pp->recvbuf) - length);
331 else
332 curlx_dyn_reset(&pp->recvbuf);
333 }
334 else {
335 /* without a newline, there is no overflow */
336 pp->overflow = 0;
337 break;
338 }
339
340 } while(1); /* while there is buffer left to scan */
341
342 } while(gotbytes == sizeof(buffer));
343
344 pp->pending_resp = FALSE;
345
346 return result;
347}
348
349CURLcode Curl_pp_pollset(struct Curl_easy *data,
350 struct pingpong *pp,
351 struct easy_pollset *ps)
352{
353 int flags = pp->sendleft ? CURL_POLL_OUT : CURL_POLL_IN;
354 return Curl_pollset_change(data, ps, data->conn->sock[FIRSTSOCKET],
355 flags, 0);
356}
357
358bool Curl_pp_needs_flush(struct Curl_easy *data,
359 struct pingpong *pp)
360{
361 (void)data;
362 return pp->sendleft > 0;
363}
364
365CURLcode Curl_pp_flushsend(struct Curl_easy *data,
366 struct pingpong *pp)
367{
lib/ftp.clines 86–86 · definition of LASTLINE
86#define LASTLINE(line) (STATUSCODE(line) && (' ' == (line)[3]))
C3: LASTLINE macro: requires 3-digit code followed by space (line[3] == ' ')
lib/ftp.clines 576–589 · definition of ftp_endofresp
576static bool ftp_endofresp(struct Curl_easy *data, struct connectdata *conn,
577 const char *line, size_t len, int *code)
578{
579 curl_off_t status;
580 (void)data;
581 (void)conn;
582
583 if((len > 3) && LASTLINE(line) && !curlx_str_number(&line, &status, 999)) {
C3: ftp_endofresp returns TRUE only when LASTLINE() is satisfied
584 *code = (int)status;
585 return TRUE;
586 }
587
588 return FALSE;
589}
lib/progress.clines 735–738 · definition of Curl_pgrsCheck
Show 4 lines (not cited by the judge)
735CURLcode Curl_pgrsCheck(struct Curl_easy *data)
736{
737 return Curl_pgrsCheckX(data, Curl_pgrs_now(data));
738}
lib/connect.clines 104–107 · definition of Curl_timeleft_ms
Show 4 lines (not cited by the judge)
104timediff_t Curl_timeleft_ms(struct Curl_easy *data)
105{
106 return timeleft_now_ms(data, Curl_pgrs_now(data));
107}
src/curlinfo.clines 273–281 · definition of main
Show 9 lines (not cited by the judge)
273int main(void)
274{
275 size_t i;
276
277 for(i = 0; i < CURL_ARRAYSIZE(disabled); i++)
278 puts(disabled[i]);
279
280 return 0;
281}
lib/optiontable.pllines 84–125 · definition of while
Show 42 lines (not cited by the judge)
84while(<CURL_H>) {
85 my $l = $_;
86 if($fl) {
87 # continued deprecation
88 if($l =~ /(.*)\),/) {
89 $fl .= $1;
90
91 # the end
92 my @p = split(/, */, $fl);
93 add($p[0], $p[1], $p[2]);
94 undef $fl;
95 }
96 else {
97 # another line to append
98 chomp $l;
99 $fl .= $l;
100 }
101 }
102
103 if(/^ *CURLOPTDEPRECATED\((.*)/) {
104 $fl = $1;
105 chomp $fl;
106 }
107
108 if(/^ *CURLOPT\(([^,]*), ([^,]*), (\d+)\)/) {
109 my($opt, $type, $num) = ($1, $2, $3);
110 add($opt, $type, $num);
111 }
112
113 # alias for an older option
114 # old = new
115 if(/^#define (CURLOPT_[^ ]*) *(CURLOPT_\S*)/) {
116 my ($o, $n) = ($1, $2);
117 # skip obsolete ones
118 if(($n !~ /OBSOLETE/) && ($o !~ /OBSOLETE/)) {
119 $o =~ s/^CURLOPT_//;
120 $n =~ s/^CURLOPT_//;
121 $alias{$o} = $n;
122 push @names, $o;
123 }
124 }
125}