curl FTP状态机无限循环漏洞技术分析

本文深入分析了一个在curl项目中发现的FTP状态机无限循环问题。当curl从恶意FTP服务器下载文件时,由于服务器打开了EPSV端口但不发送任何数据,导致curl状态机陷入无限循环,无法正常退出。报告包含详细的代码路径分析、复现步骤和项目维护者的讨论。

报告 #3442060 - curl项目状态机中的无限循环问题

报告时间线

  • kak1 向curl提交了一份报告。14天前
  • bagder (curl工作人员) 发表了评论。14天前
  • dgustafsson (curl工作人员) 发表了评论。14天前
  • kak1 发表了评论。14天前
  • bagder (curl工作人员) 发表了评论。14天前
  • kak1 发表了评论。14天前
  • kak1 发表了评论。14天前
  • bagder (curl工作人员) 发表了评论并更新。14天前
  • bagder (curl工作人员) 关闭了报告并将状态更改为“不适用”。14天前
  • bagder (curl工作人员) 请求披露此报告。14天前
  • bagder (curl工作人员) 披露了此报告。14天前

漏洞摘要 漏洞影响:当curl尝试从恶意FTP服务器下载文件时,会触发代码执行中的无限循环。

我在curl项目的FTP功能中发现了这个问题。根据 https://github.com/curl/curl/blob/master/docs/cmdline-opts/disable-epsv.md 的描述,curl默认使用EPSV模式作为FTP文件传输方法。简而言之,EPSV模式的工作原理如下:FTP服务器打开一个TCP端口并等待客户端连接,然后通过该端口向客户端发送数据。

在curl的状态机中,/lib/multi.c 中的 state_performing 函数在 [1] 处调用 Curl_sendrecv 函数以从对等方接收数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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] 调用 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;
    }
  }
...

Curl_sendrecv 函数在 [2] 处调用 sendrecv_dl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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] 调用 sendrecv_dl
    if(result || data->req.done)
      goto out;
  }

...

sendrecv_dl 函数进一步调用 xfer_recv_resp 来接收数据。如果我们的恶意FTP服务器打开了一个EPSV端口但没有向客户端发送任何数据,xfer_recv_resp 将返回-1,并且 result 的值将被设置为 CURLE_AGAIN。随后,在 [4] 处,result 被设置为 CURLE_OK。这意味着即使 xfer_recv_resp 未能接收到任何数据,sendrecv_dl 函数仍然返回 CURLE_OK

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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] 调用 xfer_recv_resp
    if(nread < 0) {
      if(CURLE_AGAIN != result)
        goto out; /* real error */
      rcvd_eagain = TRUE;
      result = CURLE_OK; //[4] 将 result 设置为 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; /* 跳出循环 */
    }
...

让我们继续检查 state_performing 函数:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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 { /* 没有错误,也没有完成 */
    mspeed_check(data, nowp); // [8]
  }
  free(newurl);
  *resultp = result;
  return rc;
}

由于没有接收到任何数据,data->req.done 标志保持为0。此外,因为 resultCURLE_OK,所以 [5][6][7] 处的条件检查结果均为假。代码随后执行到 [8] 处(没有错误,也没有完成)。

这导致curl的当前状态机标志(data->mstate)保持不变,仍然设置为 MSTATE_PERFORMING。随后,curl代码反复进入 state_performing 函数,从而导致无限循环。

受影响版本

1
2
3
4
5
➜  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

复现步骤

  1. 下载附带的 ./ftp_poc.py 文件并运行:sudo python3 ./ftp_poc.py。这将在当前机器的21端口上启动一个恶意FTP服务。
  2. 运行以下curl命令,将命令中的 192.168.23.1 替换为您自己的恶意FTP服务器地址:
    1
    
    ./curl -u anonymous:123 'ftp://192.168.23.1/test' -o ./test
    
    curl程序将进入无限代码循环,并且不会自行退出。

影响 摘要:当curl尝试从恶意FTP服务器下载文件时,会触发代码执行中的无限循环。

附件

  • 1 个附件:F5049867: ftp_poc.py

评论记录

  • bagder (curl工作人员) 发表了评论。14天前

    感谢您的报告! 我们将花一些时间调查您的报告,并尽快向您反馈详细信息和可能的后续问题!很可能在接下来的24小时内。 我们始终努力尽快修复报告的问题。严重性为低或中的问题,我们会按常规发布周期合并到下一个版本中。只有更严重的问题我们才会提前发布修复。

  • bagder (curl工作人员) 发表了评论。14天前

    @kak1 这与服务器只是停止发送任何数据,并且curl只是坐在那里等待更多数据直到时间结束(除非提供了额外的超时选项)有什么不同?这似乎是curl设计的工作方式?

  • dgustafsson (curl工作人员) 发表了评论。14天前

    如果文件传输是无限的,那么curl的正确行为就是无限地下载它。

  • kak1 发表了评论。14天前

    不同之处在于,在此问题中,即使手动设置了超时,curl进程也不会终止。但是,我没有在循环中找到任何内存分配代码,因此不会发生内存泄漏。目前,它只会导致CPU使用。如果此类问题不被视为安全漏洞,请关闭此报告。

  • bagder (curl工作人员) 发表了评论。14天前

    即使手动设置了超时,curl进程也不会终止 这与我针对您的PoC服务器测试curl时的工作方式不符。它正常超时:

    1
    2
    3
    4
    5
    6
    
    $ curl ftp://localhost:9021/ -v -m2
    ...
    curl: (28) Operation timed out after 2002 milliseconds with 0 bytes received
    $ curl ftp://localhost:9021/RE -v -m2
    ...
    curl: (28) Operation timed out after 2002 milliseconds with 0 out of 4 bytes received
    
  • kak1 发表了评论。14天前

    请使用我提供的PoC文件和命令,因为触发此问题需要特定的协议交互。我已经为此目的调整了提供的命令。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    ➜  curl sudo python3 ./ftp_poc.py
    FTP server 0.0.0.0:21
    waiting for connect...
    client ('192.168.23.1', 28513) connect
    -> 220 Welcome to Simple FTP Server
    <- USER anonymous
    -> 331 Please specify the password.
    <- PASS 123
    -> 230 Login successful.
    <- PWD
    -> 257 "/" is the current directory
    <- EPSV
    -> 229 Entering Extended Passive Mode (|||32971|)
    EPSV port: 32971
    <- TYPE I
    -> 200 Switching to Binary mode.
    <- SIZE test
    -> 213 213 4
    <- RETR test
    -> 150 Opening BINARY mode data connection for test (4 bytes).
    -> 226 Transfer complete.
    
    1
    2
    3
    4
    
    ➜  src ./curl -u anonymous:123 'ftp://192.168.23.1/test' -o ./test --connect-timeout 30
      % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                     Dload  Upload   Total   Spent    Left  Speed
      0     4   0     0   0     0     0     0  --:--:--  0:09:32 --:--:--     0
    
  • kak1 发表了评论。14天前

    添加 -m 2 参数后,curl超时了。看来这个问题可以缓解。请关闭此报告。

  • bagder (curl工作人员) 发表了评论。更新于14天前

    您需要使用 -m (--max-time)--connect-timeout 对您的情况没有任何作用,因为它及时连接成功。

  • bagder (curl工作人员) 关闭了报告并将状态更改为“不适用”。14天前

    认为不是安全问题。

  • bagder (curl工作人员) 请求披露此报告。14天前

    根据项目的透明政策,我们希望所有报告都能被披露并公开。

  • bagder (curl工作人员) 披露了此报告。14天前

报告详情

  • 报告时间:2025年11月26日,UTC时间上午8:34
  • 报告人:kak1
  • 报告对象:curl
  • 报告ID:#3442060
  • 严重性:无评级 (—)
  • 披露时间:2025年11月26日,UTC时间上午9:32
  • 弱点:无
  • CVE ID:无
  • 赏金:无
  • 账户详情:无
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计