cURL FTP协议状态机无限循环漏洞深度剖析

本文详细分析了在cURL项目中,当客户端从恶意FTP服务器下载文件时,由于状态机逻辑缺陷导致的无限循环漏洞。文章深入探讨了漏洞触发的代码路径、相关函数调用及条件,并提供了复现步骤与影响评估。

报告时间线

  • kak1curl 提交了一份报告。 (6天前)
  • bagder (curl staff) 发表评论。 (6天前)
  • bagder (curl staff) 发表评论。 (6天前)
  • dgustafsson (curl staff) 发表评论。 (6天前)
  • kak1 发表评论。 (6天前)
  • bagder (curl staff) 发表评论。 (6天前)
  • kak1 发表评论。 (6天前)
  • kak1 发表评论。 (6天前)
  • bagder (curl staff) 发表评论(已更新)。 (6天前)
  • bagder (curl staff) 关闭报告并将状态更改为 不适用 (Not Applicable)。 (6天前)
  • bagder (curl staff) 请求披露此报告。 (6天前)
  • bagder (curl staff) 披露了此报告。 (6天前)

报告详情

  • 报告于: November 26, 2025, 8:34am UTC
  • 报告人: kak1
  • 报告至: curl
  • 报告ID: #3442060
  • 严重性: No rating (—)
  • 披露时间: November 26, 2025, 9:32am UTC
  • 弱点: None
  • CVE ID: None
  • 赏金: None

漏洞摘要: 漏洞影响:当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] 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;
    }
  }
...

Curl_sendrecv 函数在 [2] 处调用 sendrecv_dl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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;
  }
...

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] 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 */
    }
...

让我们继续检查 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 { /* not errored, not done */
    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 staff) 发表评论。 (6天前) 感谢您的报告! 我们将花些时间调查您的报告,并尽快向您提供详情和可能的后续问题!很可能在接下来的24小时内。 我们始终致力于尽快修复报告的问题。严重性为低或中等的问题,我们会将其合并到常规发布周期的下一个版本中。只有更严重的问题我们才可能提前发布修复。

bagder (curl staff) 发表评论。 (6天前) @kak1 这与服务器只是停止发送任何数据,而curl只是坐在那里等待更多数据直到天荒地老(除非设置了额外的超时选项)有什么不同吗?这似乎是curl的设计工作方式?

dgustafsson (curl staff) 发表评论。 (6天前) 如果文件传输是无限的,那么curl的正确行为就是无限期地下载它。

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

bagder (curl staff) 发表评论。 (6天前)

即使手动设置了超时,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 发表评论。 (6天前) 请使用我提供的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 发表评论。 (6天前) 添加 -m 2 参数后,curl超时了。看来这个问题可以缓解。请关闭此报告。

bagder (curl staff) 发表评论(已更新)。 (6天前) 您需要使用 -m (--max-time)。--connect-timeout 对您的情况无效,因为它能按时连接。

bagder (curl staff) 关闭报告并将状态更改为 不适用 (Not Applicable)。 (6天前) 认为这不是一个安全问题。

bagder (curl staff) 请求披露此报告。 (6天前) 根据项目的透明政策,我们希望所有报告都被披露并公开。

bagder (curl staff) 披露了此报告。 (6天前)

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计