前端在前面加了一层 Nginx 或 CDN 之后,安全边界其实就往前挪了一层。很多站点的 WAF、限流、鉴权规则都写在最外面那台代理上,后端 PHP 默认「反正前面有人拦」。一旦代理和后端对同一个请求的边界判定不一致,这个差异就变成了攻击面。HTTP 请求走私(Request Smuggling,常简称 HRS)就是这一类问题里最典型、也最容易被扫出来的一种。
它不是某个组件的高危漏洞,而是「两端解析规则不同」导致的协议层错位。所以不升级某个版本就完事,得先把自己的链路摸清楚。这篇按「先讲清错位怎么产生、再讲怎么自测、最后讲怎么在配置层把边界焊死」的顺序写。
一、走私的本质:一条 TCP 连接上放两个请求
HTTP/1.1 允许长连接,一条 TCP 连接上可以连续传多个请求,靠什么区分「第一个请求到哪结束」?靠两个头部:
Content-Length(CL):明确说 body 有多少字节;Transfer-Encoding: chunked(TE):body 被切成若干块,每块以「十六进制长度 + CRLF + 数据 + CRLF」组成,最后一块长度是 0。
问题在于 RFC 只说「同时出现时 TE 优先」,但历史上各家的实现宽松程度不同:有的取 CL、有的取 TE、有的遇到重复头取第一个、有的取最后一个、有的对畸形值直接忽略。于是同一个报文,代理认为 body 长度是 A,后端认为长度是 B。A 比 B 大出来的那一段,就被后端当成了「下一个请求」的开头留在了连接缓冲区里 —— 这就是走私。
一句更直观的话:走私不是伪造请求被放行,而是把一段「本应是上一个请求 body」的字节,塞进了下一个请求的位置。
二、三种最常见的错位形态
不必背论文,日常排查见到的就这三种,记住它们各自的触发条件即可。
CL.TE:代理信 CL,后端信 TE
POST /search HTTP/1.1
Host: www.example.com
Content-Length: 6
Transfer-Encoding: chunked
0
G代理按 CL 只读 6 字节(0 加上 CRLF、空行、以及一个 G),认为请求结束,把这一段整体转给后端。后端看到 TE 头,按 chunked 解析,读到 0 就认为 body 结束,于是剩下那个孤零零的 G 留在缓冲区。代理把这条连接复用给下一个正常用户时,后端正准备读请求行,第一个字符就是 G,请求直接畸形;如果攻击者精心构造,能让「残留字节 + 下一个用户的真实请求」拼成一条完整的、带攻击者前缀的请求。
TE.CL:代理信 TE,后端信 CL
POST /search HTTP/1.1
Host: www.example.com
Content-Length: 4
Transfer-Encoding: chunked
5c
GPOST /admin HTTP/1.1
Host: www.example.com
...
0
代理按 chunked 解析到 0 结束,把 chunk 解码后的内容转给后端;后端看到 CL: 4 只读 4 字节,剩下的 GPOST /admin ... 就留在缓冲区成为下一个请求。这条路可以直接绕过前端的路径鉴权 —— 因为前端看到的是 POST /search。
TE.TE:两端都认 TE,但「谁处理畸形 TE」不一致
把 TE 头写成 Transfer-Encoding: chunked 和 Transfer-Encoding: xchunked 两行,或者写成 Transfer-Encoding : chunked(冒号前多空格)、Transfer-Encoding: chunked, identity。一端认为「这是无效 TE,忽略,转用 CL」,另一端认为「有效,按 chunked 处理」,错位就此产生。
三种形态的共同点是:必须有至少两个解析 HTTP 的组件,且它们对同一个报文的解释不同。单层 Nginx 直接到 PHP-FPM,PHP 不自己解析 HTTP,理论上没有这条链;但只要有 CDN → Nginx → 另一层 Nginx(或 OpenResty / HAProxy / Apache)就有。
三、先确认你的链路里到底有几层解析
# 1. 看响应头里泄露的中间层线索
curl -sI https://www.example.com/ | grep -iE 'server|via|x-cache|x-powered|cf-ray'
# 2. 看 TLS 终结点和真实回源 IP(DNS 只告诉你前面那层)
dig +short www.example.com
dig +short www.example.com @1.1.1.1
# 3. 用畸形报文看下游报错的「方言」——不同组件的 400 页面文字不一样
printf 'GET / HTTP/1.1\r\nHost: www.example.com\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\n\r\n0\r\n\r\n' \
| timeout 10 openssl s_client -quiet -connect www.example.com:443 -servername www.example.com 2>/dev/null | head -20第 3 条是最有效的一步:如果返回 400 Bad Request 且 body 是 Nginx 的 <center>nginx</center>,说明畸形报文被最外层拦住了;如果返回的是另一种错误页,说明外层放行了、被后端拦下 —— 放行这件事本身就是信号。
把链路上的组件写成一行,标注每个组件是否有独立的 HTTP 解析器:
- CDN(葫芦娃/Cloudflare 之类):有,且通常会把畸形报文归一化;
- Nginx:有,且
proxy_pass转发时默认按解析结果重写 CL/TE; - HAProxy / Varnish:有,各自的严格程度不同;
- PHP-FPM:没有,它信任 web server 传过来的
CONTENT_LENGTH; - 应用框架里的
Request对象:没有独立解析,只读环境变量。
所以「CDN → Nginx → PHP-FPM」这条常见链路上,解析器只有两个,风险点就在 CDN 和 Nginx 之间那一跳;而「Nginx → 内网另一台 Nginx → PHP-FPM」有三层,每一跳都要对齐。
四、配置层的收口:让两端只有一种读法
思路很简单:不要试图让两端「都正确」,而是让「有歧义的报文在边界就被丢掉」。所有请求都先被最外层规范化,后端只看规范化后的结果。
Nginx 侧的第一道门
server {
listen 443 ssl http2;
server_name www.example.com;
# 拒绝同时携带 CL 和 TE 的请求(HRS 的前提条件)
if ($http_transfer_encoding !~* "^chunked$") {
set $te_bad 1;
}
location / {
# 上游按 HTTP/1.1 通信,避免 chunked 在 1.0 里被拆解成未知形态
proxy_http_version 1.1;
proxy_set_header Connection "";
# 关键:不要透传客户端的 TE 头,让 Nginx 自己决定 body 形态
proxy_set_header Transfer-Encoding "";
proxy_set_header Content-Length $content_length;
proxy_pass http://backend;
}
}几点说明,都是实践中反复确认过的:
proxy_http_version 1.1+proxy_set_header Connection ""是 upstream keepalive 的标准配置,和走私没直接关系,但它决定了 body 用 chunked 还是 CL 传给后端 —— 统一成 1.1 + 清空 TE,后端只会按 CL 读,前端的判断就是唯一判断。- 如果业务上确实需要 chunked(大文件流式上传),不要把 TE 清空,改为在 Nginx 里做严格校验:
Transfer-Encoding值必须是单个chunked,不带逗号、不带其它 token,出现多次直接 400。 if在 server 级用set是安全的(不进 rewrite 阶段),不要用它做return之外的复杂逻辑;要拒绝就用if ($http_transfer_encoding ~ ",") { return 400; }这种单条形式。
更稳的做法是「前置拒绝 + 只留一条路径」
# 拒绝所有重复头(重复 CL 或重复 TE 是最经典的走私载体)
if ($http_content_length ~ ",") { return 400; }
if ($http_transfer_encoding ~ ",") { return 400; }
# 拒绝带空格的伪头部("Transfer-Encoding : chunked" 会被某些实现当合法头)
# Nginx 本身不会把带空格的写法解析成 TE 头,但如果前置是别的组件就不好说
Nginx 变量 $http_content_length 在出现两个 CL 头时是「逗号拼接」的形式,例如 6, 6,用 ~ "," 判一下就能拦掉。这一条成本极低,建议所有站都加。
中间的 CDN 要确认三件事
CDN 是第三方,改不了配置,只能确认行为。登进控制台或者用报文自测这三点:
- 是否会把
Transfer-Encoding透传给源站(很多 CDN 会)? - 是否会规范化 chunked 报文为 CL?如果会,那 CL.TE 形态在 CDN 这一跳就被消解了。
- 是否会拒绝同时带 CL 和 TE 的请求?现代 CDN 基本都会,这是最省事的防线。
如果 CDN 什么都不做,那源站的 Nginx 就是唯一的防线,上面那几条 if 就必须加。
五、自测:不出网也能测的方法
不要在生产上直接打。最稳的方式是在本机起一个隔离环境,用 Docker 复现「前端 Nginx + 后端 Nginx + PHP-FPM」三层,然后发畸形报文看哪一端报错。
# 本机 8080 起一个只回显请求行的后端,用来观察「实际到达的是什么」
python3 -c '
import socketserver
class H(socketserver.BaseRequestHandler):
def handle(self):
data = self.request.recv(8192)
print(repr(data[:400]))
self.request.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
socketserver.ThreadingTCPServer(("127.0.0.1", 9000), H).serve_forever()
'
# 再发一个 CL+TE 同时存在的报文,看后端收到了多少字节
printf 'POST / HTTP/1.1\r\nHost: t\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nG' \
| timeout 5 nc 127.0.0.1 8080判据非常直接:后端 repr 里如果出现了那个孤立的 G,说明前端按 CL 读、把残留字节放行了,这条链路有走私风险;如果残留字节被丢弃、或者连接被前端直接关掉,说明前端做了规范化。
线上环境更推荐「被动观测」而不是主动扫:在 Nginx 里记一条 $http_transfer_encoding 值非空的日志,跑一周看看有没有奇怪的形态(带逗号、带多个值、值不是纯 chunked)。个人站正常业务几乎不会出现这些,一旦出现就是有人在探。
六、日志里怎么发现有人在做这件事
走私探测的特征很集中:短时间内大量 400、501,且 URI 都是同一路径、UA 是扫描器特征。把这些落成一条可观测的规则:
# 统计最近一小时每个 IP 的 400/501 次数,超过 20 次就是可疑
awk '$9 ~ /^(400|501)$/ {c[$1]++} END {for (i in c) if (c[i] > 20) print c[i], i}' \
/var/log/nginx/access.log | sort -rn | head -20同时做两件事:
- 把
Transfer-Encoding的值加入log_format,便于回溯是哪种形态; - 对「同一 IP 在 1 分钟内触发的 400 超过阈值」直接临时封禁(
limit_req的nodelay不行,要专门配一条按状态码限速的规则,或者交给 fail2ban 读日志)。
七、一份可直接照做的收口清单
- 画出链路上的所有 HTTP 解析组件,标出哪几个会读 CL/TE。
- 最外层加两条拒绝规则:CL 里含逗号、TE 里含逗号,直接 400。
- Nginx
proxy_pass处统一proxy_http_version 1.1并清空Transfer-Encoding。 - 确认 CDN 是否已做规范化;没做就在源站补,别指望它。
- 在本机隔离环境跑一次 CL.TE / TE.CL 报文,确认残留在前端就被丢弃。
- 线上加速度规则,观察一周。
这套东西加起来不到 20 行配置,但它把「协议层错位」这个攻击面直接关掉了,比装一个 WAF 规则要干净得多。
八、小结
请求走私不神秘,它只是「两层解析器对同一个报文的理解不同」。所以防护思路也不是「识别攻击 payload」,而是让歧义报文在边界就被拒绝、让 body 形态在每一跳都是确定的。对个人站长来说,真正需要做的往往只有三件事:确认链路上有几层解析、在最外层拒绝重复头和逗号形式的 TE、把 proxy_http_version 1.1 和清空 TE 固定进模板。做完这三件,剩下的就交给日志观测 —— 有异常形态出现,说明有人在探,处理掉 IP 即可。