CVE-2026-92387: never-ending loop via server response omission
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
The code supports the reported flaw. A maintainer should review its impact and decide what to do next.
curl policy: Never-ending transfersWhy 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.
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
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.
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.
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.
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.
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.
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)
Source code
| 42 | timediff_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 | */ |
| 62 | CURLcode 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 | } |
| 224 | static 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 | */ |
| 238 | CURLcode 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 | |
| 349 | CURLcode 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 | |
| 358 | bool Curl_pp_needs_flush(struct Curl_easy *data, |
| 359 | struct pingpong *pp) |
| 360 | { |
| 361 | (void)data; |
| 362 | return pp->sendleft > 0; |
| 363 | } |
| 364 | |
| 365 | CURLcode Curl_pp_flushsend(struct Curl_easy *data, |
| 366 | struct pingpong *pp) |
| 367 | { |
| 86 | #define LASTLINE(line) (STATUSCODE(line) && (' ' == (line)[3])) |
| C3: LASTLINE macro: requires 3-digit code followed by space (line[3] == ' ') |
| 576 | static 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 | } |
Show 4 lines (not cited by the judge)
| 735 | CURLcode Curl_pgrsCheck(struct Curl_easy *data) |
| 736 | { |
| 737 | return Curl_pgrsCheckX(data, Curl_pgrs_now(data)); |
| 738 | } |
Show 4 lines (not cited by the judge)
| 104 | timediff_t Curl_timeleft_ms(struct Curl_easy *data) |
| 105 | { |
| 106 | return timeleft_now_ms(data, Curl_pgrs_now(data)); |
| 107 | } |
Show 9 lines (not cited by the judge)
| 273 | int 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 | } |
Show 42 lines (not cited by the judge)
| 84 | while(<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 | } |