HACKERONE

curl: Infinite loop issue in the state machine of the curl project_H1:3442060

Description

## Summary:

Vulnerability impact: When curl attempts to download files from a malicious FTP server, it triggers an infinite loop in the code execution.

I discovered this issue in the FTP functionality of the curl project .As described in https://github.com/curl/curl/blob/master/docs/cmdline-opts/disable-epsv.md, curl uses EPSV mode as the default FTP file transfer method. In simple terms, the EPSV mode works as follows: the FTP server opens a TCP port and waits for the client to connect, then sends data to the client through this port.

In the state machine of curl, the `state_performing` function in `/lib/multi.c` calls the `Curl_sendrecv` function at [1] to receive data from the peer:
```
static CURLMcode state_performing(struct Curl_easy *data,
struct curltime *nowp,
bool *stream_errorp,
CURLcode *resultp)
{
char *newurl = NULL;
bool retry = FALSE;
CURLMcode rc = CURLM_OK;
CURLcode result = *resultp = CURLE_OK;
*stream_errorp = FALSE;

if(mspeed_check(data, nowp) == CURLE_AGAIN)
return CURLM_OK;

/* read/write data if it is ready to do so */
result = Curl_sendrecv(data, nowp); // [1] call Curl_sendrecv

if(data->req.done || (result == CURLE_RECV_ERROR)) {
/* If CURLE_RECV_ERROR happens early enough, we assume it was a race
* condition and the server closed the reused connection exactly when we
* wanted to use it, so figure out if that is indeed the case.
*/
CURLcode ret = Curl_retry_request(data, &newurl);
if(!ret)
retry = !!newurl;
else if(!result)
result = ret;

if(retry) {
/* if we are to retry, set the result to OK and consider the
request as done */
result = CURLE_OK;
data->req.done = TRUE;
}
}
...
```


The `Curl_sendrecv` function calls `sendrecv_dl` at [2]:
```
CURLcode Curl_sendrecv(struct Curl_easy *data, struct curltime *nowp)
{
struct SingleRequest *k = &data->req;
CURLcode result = CURLE_OK;

DEBUGASSERT(nowp);
if(Curl_xfer_is_blocked(data)) {
result = CURLE_OK;
goto out;
}

/* We go ahead and do a read if we have a readable socket or if the stream
was rewound (in which case we have data in a buffer) */
if(k->keepon & KEEP_RECV) {
result = sendrecv_dl(data, k); //[2] call sendrecv_dl
if(result || data->req.done)
goto out;
}

...
```

The `sendrecv_dl` function further calls `xfer_recv_resp` to receive data. If our malicious FTP server opens an EPSV port but does not send any data to the client, `xfer_recv_resp` will return `-1`, and the value of `result` will be set to `CURLE_AGAIN`. Subsequently, at [4], the `result` is set to `CURLE_OK`. This means that even if `xfer_recv_resp` fails to receive any data, the `sendrecv_dl` function still returns `CURLE_OK`.

```
static CURLcode sendrecv_dl(struct Curl_easy *data,
struct SingleRequest *k)
{
...
rcvd_eagain = FALSE;
nread = xfer_recv_resp(data, buf, bytestoread, is_multiplex, &result); // [3] call xfer_recv_resp
if(nread < 0) {
if(CURLE_AGAIN != result)
goto out; /* real error */
rcvd_eagain = TRUE;
result = CURLE_OK; //[4]set result to CURLE_OK
if(data->req.download_done && data->req.no_body &&
!data->req.resp_trailer) {
DEBUGF(infof(data, "EAGAIN, download done, no trailer announced, "
"not waiting for EOS"));
nread = 0;
/* continue as if we received the EOS */
}
else
break; /* get out of loop */
}
...
```


Let's continue examining the `state_performing` function:
```
static CURLMcode state_performing(struct Curl_easy *data,
struct curltime *nowp,
bool *stream_errorp,
CURLcode *resultp)
{
char *newurl = NULL;
bool retry = FALSE;
CURLMcode rc = CURLM_OK;
CURLcode result = *resultp = CURLE_OK;
*stream_errorp = FALSE;

if(mspeed_check(data, nowp) == CURLE_AGAIN)
return CURLM_OK;

/* read/write data if it is ready to do so */
result = Curl_sendrecv(data, nowp);

if(data->req.done || (result == CURLE_RECV_ERROR)) { // [5] data->req.done == 0, result==CURLE_OK
/* If CURLE_RECV_ERROR happens early enough, we assume it was a race
* condition and the server closed the reused connection exactly when we
* wanted to use it, so figure out if that is indeed the case.
*/
CURLcode ret = Curl_retry_request(data, &newurl);
if(!ret)
retry = !!newurl;
else if(!result)
result = ret;

if(retry) {
/* if we are to retry, set the result to OK and consider the
request as done */
result = CURLE_OK;
data->req.done = TRUE;
}
}
#ifndef CURL_DISABLE_HTTP
else if((CURLE_HTTP2_STREAM == result) &&
Curl_h2_http_1_1_error(data)) {
CURLcode ret = Curl_retry_request(data, &newurl);

if(!ret) {
infof(data, "Downgrades to HTTP/1.1");
streamclose(data->conn, "Disconnect HTTP/2 for HTTP/1");
data->state.http_neg.wanted = CURL_HTTP_V1x;
data->state.http_neg.allowed = CURL_HTTP_V1x;
/* clear the error message bit too as we ignore the one we got */
data->state.errorbuf = FALSE;
if(!newurl)
/* typically for HTTP_1_1_REQUIRED error on first flight */
newurl = strdup(data->state.url);
if(!newurl) {
result = CURLE_OUT_OF_MEMORY;
}
else {
/* if we are to retry, set the result to OK and consider the request
as done */
retry = TRUE;
result = CURLE_OK;
data->req.done = TRUE;
}
}
else
result = ret;
}
#endif

if(result) { //[6] result==CURLE_OK
/*
* The transfer phase returned error, we mark the connection to get closed
* to prevent being reused. This is because we cannot possibly know if the
* connection is in a good shape or not now. Unless it is a protocol which
* uses two "channels" like FTP, as then the error happened in the data
* connection.
*/

if(!(data->conn->handler->flags & PROTOPT_DUAL) &&
result != CURLE_HTTP2_STREAM)
streamclose(data->conn, "Transfer returned error");

multi_posttransfer(data);
multi_done(data, result, TRUE);
}
else if(data->req.done && !Curl_cwriter_is_paused(data)) { //[7] data->req.done == 0
const struct Curl_handler *handler = data->conn->handler;

/* call this even if the readwrite function returned error */
multi_posttransfer(data);

/* When we follow redirects or is set to retry the connection, we must to
go back to the CONNECT state */
if(data->req.newurl || retry) {
followtype follow = FOLLOW_NONE;
if(!retry) {
/* if the URL is a follow-location and not just a retried request then
figure out the URL here */
free(newurl);
newurl = data->req.newurl;
data->req.newurl = NULL;
follow = FOLLOW_REDIR;
}
else
follow = FOLLOW_RETRY;
(void)multi_done(data, CURLE_OK, FALSE);
/* multi_done() might return CURLE_GOT_NOTHING */
result = multi_follow(data, handler, newurl, follow);
if(!result) {
multistate(data, MSTATE_SETUP);
rc = CURLM_CALL_MULTI_PERFORM;
}
}
else {
/* after the transfer is done, go DONE */

/* but first check to see if we got a location info even though we are
not following redirects */
if(data->req.location) {
free(newurl);
newurl = data->req.location;
data->req.location = NULL;
result = multi_follow(data, handler, newurl, FOLLOW_FAKE);
if(result) {
*stream_errorp = TRUE;
result = multi_done(data, result, TRUE);
}
}

if(!result) {
multistate(data, MSTATE_DONE);
rc = CURLM_CALL_MULTI_PERFORM;
}
}
}
else { /* not errored, not done */
mspeed_check(data, nowp); // [8]
}
free(newurl);
*resultp = result;
return rc;
}

```

Since no data was received, the `data->req.done` flag remains 0. Furthermore, because the result is `CURLE_OK`, the conditional checks at [5], [6], and [7] all evaluate to false. The code then proceeds to [8] (not errored, not done).

This causes curl's current state machine flag (`data->mstate`) to remain unchanged, still set to `MSTATE_PERFORMING`. Subsequently, the curl code repeatedly enters the `state_performing` function, resulting in an infinite loop.



## Affected version

curl : https://github.com/curl/curl/archive/refs/tags/curl-8_17_0.tar.gz
platform : ubuntu22.04

```
➜ src ./curl -V
curl 8.17.0-DEV (x86_64-pc-linux-gnu) libcurl/8.17.0-DEV zlib/1.2.11 libpsl/0.19.1
Release-Date: [unreleased]
Protocols: dict file ftp gopher http imap ipfs ipns mqtt pop3 rtsp smtp telnet tftp ws
Features: alt-svc AsynchDNS IPv6 Largefile libz PSL threadsafe UnixSockets
```
## Steps To Reproduce:

1. Download the attached ./ftp_poc.py file and run: sudo python3 ./ftp_poc.py. This will start a malicious FTP service on port 21 of the current machine.
2. Run the following curl command, replacing 192.168.23.1 in the command with the address of your own malicious FTP server:
```
./curl -u anonymous:123 'ftp://192.168.23.1/test' -o ./test
```

3. The curl program will enter an infinite code loop and will not exit on its own.

## Impact

## Summary:
When curl attempts to download files from a malicious FTP server, it triggers an infinite loop in the code execution.
Visit Original Source

Basic Information

ID H1:3442060
Published Nov 26, 2025 at 08:34
Modified Nov 26, 2025 at 09:32

💭 Join the Security Discussion

🔒 Your email address will not be published. Required fields are marked *

⚠️ Please be respectful and constructive in your comments. Security discussions should remain professional.