HexBrain

curl · Report #3442060

Infinite loop issue in the state machine of the curl project

N/Astatus
17upvotes
26 Nov 2025disclosed

How it was found

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
  1. 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.

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