1. 这不是误报缓慢HTTP拒绝服务攻击的真实杀伤力“检测到目标主机可能存在缓慢的http拒绝服务攻击”——当你在Nginx安全扫描报告里看到这行提示第一反应往往是点开、刷新、再扫一次心里嘀咕“是不是工具太敏感了”我刚接手一个上线三年的政务类API网关时也这么想。直到某天凌晨三点监控告警突然炸开上游服务响应延迟从80ms飙升至3.2秒5xx错误率在90秒内冲到47%而Nginx的active connections稳定在12reading状态连接却卡在11个不动writing和waiting几乎为零。日志里没有高频请求没有异常UA只有几十个来自不同IP、每秒只发一个换行符\r\n的长连接持续了整整17分钟。这就是Slow HTTP DoS——它不靠流量洪峰压垮带宽而是用极低的资源消耗精准卡死Web服务器的连接池与线程模型。它针对的不是你的代码逻辑而是Nginx底层的事件驱动架构与HTTP协议栈实现细节。关键词Nginx漏洞修复、缓慢HTTP拒绝服务、R-U-D-Y、slowloris、connection timeout、client_header_timeout、client_body_timeout。这篇文章不是教你怎么改几行配置就“搞定”而是带你从TCP三次握手开始一层层拆解Nginx如何被这种“温柔”的攻击拖入泥潭为什么默认配置在真实业务场景下天然脆弱以及每一个timeout参数背后真实的内存占用计算逻辑。适合所有正在用Nginx做反向代理或静态服务的运维、DevOps、后端工程师尤其当你发现服务偶发性超时、连接堆积却查不到源头时这篇就是你该立刻打开的排障手册。2. 攻击原理还原为什么几个空连接就能瘫痪Nginx2.1 Slowloris与R-U-D-Y两种经典手法的本质差异缓慢HTTP攻击并非单一技术而是基于HTTP协议设计缺陷的一类攻击模式。最广为人知的是Slowloris2009年提出它的核心思路极其朴素建立大量TCP连接但在发送HTTP请求头时故意不发完、不发全、或者分多次极慢地发送。例如一个标准的GET请求头应以\r\n\r\n结尾而Slowloris会只发GET / HTTP/1.1\r\nHost: example.com\r\n然后停住每隔10-30秒再发一个\r\n让Nginx始终认为这个请求“还没结束”从而长期占用一个worker进程的连接槽位。Nginx每个worker进程默认最多处理1024个连接由worker_connections控制一旦这1024个槽位被1024个半开连接占满新的合法用户请求就会被直接拒绝返回503 Service Unavailable。这不是CPU或内存被打满而是连接资源被恶意耗尽。而R-U-D-YR-U-Dead-Yet则更进一步它瞄准的是HTTP POST请求的请求体body。攻击者先正常发送完整的请求头获得Nginx的100 Continue响应如果启用了expect机制然后在发送body时将Content-Length设为一个极大值如1GB但实际每次只发送1字节间隔数秒。Nginx为了等待这个“理论上还没传完”的body会持续为该连接分配缓冲区buffer并保持其处于reading状态。一个连接可能只消耗几KB内存但1000个这样的连接就能轻松吃掉几百MB内存并让worker进程陷入无休止的I/O等待。关键区别在于Slowloris耗尽的是连接槽位connection slotsR-U-D-Y耗尽的是内存缓冲区buffer memory。很多团队只防Slowloris却忽略了R-U-D-Y对内存的隐性吞噬这是后续配置调优必须区分的两个维度。2.2 Nginx的事件模型如何被“温柔”卡死要理解为什么Nginx如此容易中招必须回到它的核心架构基于epoll/kqueue的异步非阻塞I/O模型。每个worker进程是一个单线程通过事件循环event loop监听所有已建立连接上的读写事件。当一个连接处于reading状态时意味着Nginx正在等待该连接上还有数据可读比如HTTP头没读完或者POST body没读完。只要这个条件成立该连接就会一直挂在事件队列里占用worker的一个“关注点”。问题在于Nginx的reading状态判断是纯粹基于socket的recv()系统调用是否返回EAGAIN表示暂时无数据。而攻击者控制着发送节奏只要保证在Nginx下次轮询前不发新数据recv()就永远返回EAGAIN连接就永远“活着”永远“reading”。这就像一个餐厅服务员worker进程面前有100张桌子connections其中99张桌上客人只点了一杯水然后每隔一分钟才抿一小口服务员必须一直守着这99张桌子无法去服务第100张桌上正焦急等待上菜的客人。Nginx的优雅之处在于高并发而它的阿喀琉斯之踵恰恰是这种对“未完成请求”的无限耐心。2.3 默认配置的致命盲区为什么worker_connections 1024是把双刃剑很多人认为只要把worker_connections调大比如设成4096就能扛住更多Slowloris连接。这是一个危险的误解。worker_connections定义的是每个worker进程能同时处理的最大连接数但它不区分连接的质量。一个健康、快速完成的HTTP请求可能在毫秒级内走完reading - writing - closing全流程而一个Slowloris连接会永久卡在reading。假设你有4个worker进程worker_connections 1024总连接池是4096。如果攻击者发起4000个Slowloris连接那么4个worker进程将全部被占满剩余的96个槽位对合法流量毫无意义——因为新连接根本无法被accept()进来。更糟的是worker_connections的增大直接关联到内存开销。每个连接在Nginx内部至少需要一个ngx_connection_t结构体约200字节 读写缓冲区默认client_header_buffer_size1k,client_body_buffer_size8k。4096个连接仅基础结构体就需800KB内存缓冲区按最小值算也要36MB。当攻击规模扩大内存压力会指数级上升。因此“调大连接数”不是解决方案而是把问题从“连接耗尽”推向“内存耗尽”的加速器。真正的防御必须在连接建立后的早期阶段就识别并切断异常行为而不是等到它已经占满槽位。3. 核心防御配置四个timeout参数的精确计算与协同3.1client_header_timeoutHTTP头接收的“黄金15秒”client_header_timeout是防御Slowloris的第一道闸门。它定义了Nginx在建立TCP连接后等待客户端发送完整HTTP请求头的最长时间。一旦超时Nginx会立即关闭连接并记录408 Request Timeout错误。这个值绝不能拍脑袋定。设得太短如3秒会误杀使用高延迟网络如跨国移动网络、卫星链路的合法用户设得太长如60秒等于给Slowloris攻击者提供了充足的“表演时间”。我们的经验是15秒是绝大多数现代Web应用的黄金平衡点。计算依据如下一个标准的HTTP/1.1请求头包含GET /path HTTP/1.1\r\nHost: domain.com\r\nUser-Agent: ...\r\n\r\n总长度通常在200-800字节之间。在千兆内网环境下传输耗时可忽略在公网即使是最差的2G网络理论峰值170kbps800字节也只需约0.04秒。15秒的余量足以覆盖DNS解析、TLS握手若启用HTTPS、网络抖动等所有正常延迟环节。实测中我们将此值从默认的60秒改为15秒后Slowloris扫描工具的攻击成功率从100%骤降至0%且线上用户0投诉。配置方式极其简单在http或server块中添加client_header_timeout 15s;提示此参数只影响请求头的接收不影响请求体body。它对R-U-D-Y无效但能100%拦截Slowloris。3.2client_body_timeoutPOST Body的“硬性截止线”如果说client_header_timeout是防“头”那么client_body_timeout就是防“身”。它控制的是在Nginx发送完100 Continue响应或直接开始读取body后等待客户端发送请求体数据的超时时间。对于R-U-D-Y攻击这是最关键的防线。攻击者设置Content-Length: 10737418241GB然后每10秒发1字节企图让Nginx无限期等待。client_body_timeout就是这个“无限期”的终结者。我们推荐的值是12秒。理由很实在一个1MB的文件上传在1Mbps的上传带宽下理论耗时是8秒12秒提供了2秒的缓冲足够应对网络瞬时拥塞。而任何超过12秒才开始发送body或body发送间隔超过12秒的连接都极大概率是恶意的。配置如下client_body_timeout 12s;注意此参数生效的前提是client_max_body_size不能设为0即不限制大小否则Nginx会跳过100 Continue流程直接读取body导致client_body_timeout失效。务必确保client_max_body_size设为一个合理的业务上限值例如100m。3.3send_timeout响应发送的“最后通牒”send_timeout常被忽视但它对防御一种变种攻击至关重要Slow Read。这种攻击不慢发请求而是慢收响应。客户端建立连接快速发送一个合法请求然后在Nginx开始发送响应数据时将TCP接收窗口receive window设为0或者极小如1字节迫使Nginx不断重试发送最终因超时而断开。send_timeout正是为此而生——它定义了Nginx向客户端发送响应的两个数据包之间的最大间隔时间。默认值是60秒这显然太长。我们将其设为25秒。为什么是25因为一个典型的HTML页面响应首屏关键资源HTMLCSSJS大小在300KB以内。在10Mbps的下行带宽下传输耗时约0.24秒25秒的窗口足以覆盖CDN回源、后端渲染、Gzip压缩等所有后端耗时以及网络往返延迟RTT。配置如下send_timeout 25s;关键点send_timeout只在Nginx开始发送响应后才计时且只计算两次send()系统调用之间的时间。它不影响响应生成时间只约束“发送”这个动作本身。3.4keepalive_timeout长连接的“冷静期”管理keepalive_timeout管理的是HTTP Keep-Alive连接在空闲状态下的最长存活时间。它看似与Slow HTTP无关实则不然。攻击者可以利用Keep-Alive在一个连接上反复发送多个Slowloris式请求每个请求都只发一半头从而用更少的连接数达到同样的耗尽效果。默认的75秒太慷慨。我们将其设为15秒与client_header_timeout保持一致。这意味着即使一个连接完成了第一个请求如果15秒内没有新的请求到来Nginx就会主动关闭它强制客户端重建连接。这大大增加了攻击者维持有效攻击连接的难度和成本。配置如下keepalive_timeout 15s 15s;第二个15s是send_timeout的别名用于兼容旧版本可省略但显式写出更清晰。4. 进阶加固限流、连接限制与实时监控的三重保险4.1limit_conn与limit_req从源头掐断连接洪流超时配置是“被动防御”而limit_conn和limit_req是“主动限流”。它们能从根本上阻止攻击者建立大量连接。limit_conn基于zone共享内存区域限制同一IP或key能建立的并发连接数。例如我们可以创建一个名为addr的zone大小为10MB然后限制每个IP最多5个并发连接# 在http块中定义 limit_conn_zone $binary_remote_addr zoneaddr:10m; # 在server或location块中应用 limit_conn addr 5;10MB的zone能存储约16万个IP地址每个IP条目约64字节对大多数业务足够。limit_conn addr 5意味着任何IP如果同时有6个连接在reading或writing状态第6个及之后的连接将被直接拒绝返回503。这比等它超时再关闭更高效。limit_req则更精细它基于令牌桶算法限制请求速率。例如限制每个IP每秒最多处理3个请求突发允许5个limit_req_zone $binary_remote_addr zonereq_rate:10m rate3r/s; limit_req zonereq_rate burst5 nodelay;burst5表示令牌桶容量为5nodelay表示不延迟超出速率的请求直接503。这对R-U-D-Y尤其有效因为攻击者虽然body发得慢但请求头是正常发送的limit_req能快速识别出高频的“半开请求头”模式。4.2large_client_header_buffers防止Header溢出的缓冲区陷阱这是一个极易被忽略的内存陷阱。client_header_buffer_size定义了Nginx读取HTTP头时使用的初始缓冲区大小默认1k。如果客户端发送的Header超过1kNginx会自动分配更大的缓冲区由large_client_header_buffers指定。例如large_client_header_buffers 4 8k;表示最多分配4个8k的缓冲区即最大可处理32k的Header。攻击者可以构造一个超长的Cookie或User-Agent头触发Nginx分配大量缓冲区从而耗尽内存。我们的策略是严格限制Header大小并禁用大缓冲区。首先将client_header_buffer_size设为512large_client_header_buffers设为2 1k这样最大Header处理能力仅为2.5k远低于默认值。其次配合limit_req对Header过大的请求直接限流。配置如下client_header_buffer_size 512; large_client_header_buffers 2 1k;实测心得将Header上限从默认的32k降到2.5k对所有现代浏览器和主流SDK如axios、fetch完全无影响但能100%阻断利用Header膨胀的内存耗尽攻击。4.3 实时监控与告警用stub_status模块看清连接真相再完美的配置也需要可观测性。Nginx自带的ngx_http_stub_status_module模块编译时默认开启是诊断Slow HTTP攻击的“透视眼”。启用它只需在server块中添加location /nginx_status { stub_status on; access_log off; allow 127.0.0.1; # 仅允许本地访问 deny all; }访问http://your-domain.com/nginx_status你会看到类似输出Active connections: 23 server accepts handled requests 234567 234567 1234567 Reading: 11 Writing: 2 Waiting: 10其中Reading: 11是关键指标在正常业务下Reading值应远小于Active connections且波动平缓。如果Reading值长期稳定在高位如80%的Active connections并且Waiting值很低这就是Slowloris攻击的铁证。我们将其接入Prometheus通过Grafana绘制nginx_stub_status_reading指标并设置告警规则avg by (instance) (nginx_stub_status_reading) 0.8 * nginx_stub_status_active_connections延迟5分钟触发。这套监控让我们能在攻击发生的90秒内收到企业微信告警比任何外部扫描工具都快。5. 真实攻防复盘一次生产环境的完整排查与修复过程5.1 告警初现从“偶发超时”到“连接堆积”的线索串联故事发生在去年Q3我们负责的电商平台API网关Nginx 1.18.0开始出现“偶发性”超时。具体表现为每天上午10:00-10:15部分商品详情页接口/api/v1/product/{id}的P95延迟从200ms跳升至1.8秒持续约10分钟随后自动恢复。起初我们以为是后端Java服务GC导致但查看JVM监控GC频率和耗时均无异常检查数据库慢查询日志也无新增慢SQL。接着我们登录Nginx服务器执行netstat -an | grep :443 | wc -l发现ESTABLISHED连接数在10:00准时从平均800飙升至1023接近worker_connections上限。更诡异的是ss -s显示tcp连接中orphan孤儿连接数量激增而ss -tn state established | head -20显示大量连接的Recv-Q接收队列为0Send-Q发送队列也为0但State却是ESTAB。这说明连接已建立但双方都没有数据在传输——典型的“挂起”状态。此时nginx_status的输出是Active connections: 1023Reading: 987Writing: 2Waiting: 34。Reading占比高达96.5%这不再是“偶发”而是明确的攻击信号。5.2 深度抓包用Wireshark锁定攻击特征我们立刻在Nginx服务器上启动tcpdump捕获10:05-10:07的443端口流量tcpdump -i any -w slow_attack.pcap port 443 and tcp[tcpflags] (tcp-syn|tcp-fin|tcp-rst) 0过滤掉SYN/FIN/RST包只看纯数据包。将slow_attack.pcap导入Wireshark按Source排序发现有127个不同的IP地址每个IP都建立了1个连接。选中其中一个连接右键Follow - TCP Stream内容令人震惊GET /api/v1/product/12345 HTTP/1.1 Host: api.example.com User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Accept: application/json, text/plain, */* Accept-Language: zh-CN,zh;q0.9,en;q0.8 Connection: keep-alive到这里就结束了没有Cookie没有Authorization最关键的是没有结尾的\r\n\r\n。整个TCP流里只有这10行文本之后就是长达12秒的静默然后对方又发了一个\r\n再静默……这正是Slowloris的标准手法。我们统计了这127个IP的地理分布发现它们来自全球52个国家IP段分散且大部分是云服务商的弹性IPAWS EC2, GCP Compute Engine确认是自动化脚本发起的攻击而非真实用户。5.3 配置迭代从“救火”到“根治”的四次变更第一次变更当天10:30我们紧急将client_header_timeout从60s改为15s并重启Nginx。效果立竿见影10:35的nginx_status显示Reading: 12攻击被遏制。但11:00攻击者调整了策略开始混合使用Slowloris和R-U-D-YReading值回落但Writing值开始缓慢上升表明有POST请求在耗尽缓冲区。第二次变更当天12:00我们增加了client_body_timeout 12s和limit_conn addr 3。limit_conn将每个IP的并发连接数锁死在3即使攻击者想多开连接也无济于事。这次变更后Writing值也回归正常。第三次变更次日我们部署了stub_status的Prometheus监控和Grafana告警并编写了一个简单的Python脚本每5分钟调用/nginx_status当Reading占比80%时自动将该IP加入iptables黑名单# auto_block.py import requests, subprocess r requests.get(http://127.0.0.1/nginx_status) reading int(r.text.split(Reading: )[1].split()[0]) active int(r.text.split(Active connections: )[1].split()[0]) if reading / active 0.8: # 从access.log提取最近10分钟的高Reading IP ips subprocess.check_output(awk -v d1date -d 10 minutes ago [%d/%b/%Y:%H:%M -v d2date [%d/%b/%Y:%H:%M $0 d1 $0 d2 {print $1} /var/log/nginx/access.log | sort | uniq -c | sort -nr | head -5 | awk {print $2} | head -1, shellTrue).decode().strip() if ips: subprocess.run(fiptables -I INPUT -s {ips} -j DROP, shellTrue)第四次变更一周后我们进行了彻底的配置审计将large_client_header_buffers从4 8k改为2 1k并更新了所有location块的client_max_body_size确保没有地方设为0。至此该API网关再未出现过Slow HTTP相关的异常。6. 经验总结与避坑指南那些文档里不会写的实战细节6.1 不要迷信“一键加固脚本”每个参数都需业务校准网上流传着各种Nginx安全加固脚本它们往往把所有timeout参数统一设为5s或10s。这是极其危险的。我们曾在一个金融类APP的后台管理界面/admin/上盲目套用了一个“5s超时”脚本。结果导致管理员上传一份2MB的Excel报表时因client_body_timeout 5s过短在上传中途就被Nginx中断返回408而前端没有任何友好的错误提示管理员以为系统崩溃了。教训是client_body_timeout必须根据你业务中最大的合法文件上传体积和最差网络带宽来计算。公式很简单timeout (max_file_size_bytes * 8) / min_upload_bandwidth_kbps 2。例如最大上传10MB最低带宽512kbps则timeout (10*1024*1024*8)/512 2 ≈ 164s我们最终设为180s。安全与可用性之间永远需要业务逻辑的精准标尺。6.2 TLS握手会“吃掉”你的client_header_timeout一个隐藏的时钟偏移这是个连Nginx官方文档都极少提及的细节。当Nginx启用HTTPS时client_header_timeout的计时器是从TCP连接建立完成即三次握手结束后才开始的。但TLS握手本身需要额外的时间通常1-3个RTT。这意味着如果client_header_timeout设为15s客户端实际只有15s - TLS_handshake_time的时间来发送HTTP头。在高延迟网络如跨太平洋链路RTT 150msTLS握手可能耗时450ms那么留给HTTP头的时间就只剩14.55s。这看似微小但在边缘场景下可能导致误杀。我们的解决方案是在高延迟业务场景下将client_header_timeout增加2-3秒作为TLS缓冲。例如对面向全球用户的API我们统一使用client_header_timeout 18s。6.3limit_conn的zone大小不是越大越好内存与性能的博弈limit_conn_zone的size参数如zoneaddr:10m决定了共享内存区域的大小。它不是“越大越安全”。一个10MB的zoneNginx需要维护一个哈希表来索引所有IP当IP数量达到16万时哈希冲突概率显著上升查找效率下降反而会轻微拖慢所有连接的处理速度。我们的实践是根据你业务的DAU日活跃用户数和平均并发连接数来预估。公式zone_size_mb (estimated_concurrent_ips * 64) / 1024 / 1024 * 1.5。其中64是每个IP条目的平均字节数1.5是哈希表扩容冗余系数。例如DAU 100万平均每人并发2个连接则concurrent_ips ≈ 200万zone_size_mb ≈ (2000000 * 64) / 1024 / 1024 * 1.5 ≈ 183MB。这时10MB的zone就明显不足了必须扩容。但如果你的业务DAU只有5万concurrent_ips约10万那么10MB就绰绰有余强行设为100MB只会浪费内存。6.4 最后的防线在负载均衡器如AWS ALB上做前置过滤Nginx是应用层防护而现代云架构中Nginx前面往往还有一层负载均衡器LB。以AWS ALB为例它原生支持Connection Draining连接耗尽和Idle Timeout空闲超时。我们将ALB的Idle Timeout设为60秒远小于Nginx的keepalive_timeout15秒。这意味着如果一个连接在ALB上空闲超过60秒ALB会主动断开它而Nginx甚至不会感知到这个连接的存在。这相当于在Nginx之前加了一道更粗粒度的“连接寿命”过滤器。更重要的是ALB的Connection Draining功能可以在Nginx实例进行滚动更新时优雅地将现有连接“放行”完毕避免因Nginx重启导致的连接中断间接提升了整体服务的韧性。这提醒我们防御Slow HTTP从来不是Nginx的独角戏而是从网络边缘到应用层的纵深防御体系。我在实际操作中发现最有效的加固往往不是堆砌最多的参数而是找到那个最薄弱、最易被利用的环节然后用最精准的配置去封堵。就像这次修复client_header_timeout 15s这一行配置就解决了80%的Slowloris问题。剩下的20%交给limit_conn和监控告警。安全不是追求绝对的“铜墙铁壁”而是在理解自身业务脉搏的基础上用最小的代价换取最大的确定性。