HTTP请求走私-CL.TE与TE.TE(黑市smuggling题解)
一.请求走私原理
为什么会存在:反向代理架构里,前端代理(counter/柜台)收到请求后要转发给后端应用(warehouse/后仓),两边都要确定”这个请求的 body 在哪结束、下一个请求从哪开始”。如果前端和后端对同一个请求的边界判定不一致,攻击者就可以把一个请求”藏”进 body 里,让它在前端眼里是数据、在后端眼里是独立请求——这就是 HTTP 请求走私(HTTP Request Smuggling)。
判定边界的两个头:
Content-Length: N:body 固定 N 字节Transfer-Encoding: chunked:body 按分块编码,读到0\r\n\r\n结束
RFC 9112 要求两者同时出现时应以 TE 为准(或直接拒绝),但现实中的组件各不相同,于是有了三种经典形态:
| 形态 | 前端 | 后端 | 说明 |
|---|---|---|---|
| CL.TE | 信 CL | 信 TE | 前端按 CL 转发,后端读完 0\r\n\r\n 后把剩余字节当新请求 |
| TE.CL | 信 TE | 信 CL | 后端按 CL 只读一部分,剩余的成走私请求 |
| TE.TE | 对 TE 头解析不同 | 同左 | 头混淆(空格/大小写/重复),一边认为有 TE 一边认为没有 |
危害:绕过前端 ACL(前端拦截的路径走私直达后端)、劫持其他用户的请求/响应、缓存投毒等。本题考的就是第一条:/flag1 /flag2 被前端巡逻队拦截(直接访问 403 逮捕页),走私绕过它。
二.题目侦察
浏览器抓包逛一遍站点,拿到地图:
/黑市主页:暗示/hint有引路人/hint:给了一张 guest JWT 和一张候选密钥表/stage1黑商1:“先让柜台只看货箱长度,再让后仓按分块清点。目标货物:/flag1 记得带上能证明你是高级买家的票据。” → CL.TE 提示/stage2黑商2:“冒号前多了一粒灰……Transfer-Encoding 后的空隙,可能就是黑市小巷” → TE.TE 提示/flag1/flag2:直接 GET → 403 逮捕页(”你已因尝试获取flag被逮捕!”)
/hint 给出的关键信息:
1 | guest JWT: |
三.前置:JWT弱密钥爆破与伪造
HS256 对称签名,密钥就在 8 个候选词里。脚本逐词验签:
1 | import hmac, hashlib, base64, json |
得到 admin 票据(后面全程用它):
1 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0cmF2ZWxlciIsInJvbGUiOiJhZG1pbiIsInNjb3BlIjoiZmxhZ3MifQ.EQmuoZNLf5aUF-WJ1MdjUFZJgPilOkgJRjXawdvIooI |
四.Stage1:CL.TE 走私
1.构造走私报文
浏览器随便抓一个 /stage1 的包 Send to Repeater,改成如下结构:
1 | POST / |
要点(bp 里操作):
- 方法改
POST,路径随意(//stage1都行) - 同时保留
Content-Length和Transfer-Encoding: chunked两个头 - body 先写一个 chunked 终止块:
0+ 空行(实际字节是0\r\n\r\n) - 终止块后面紧跟走私请求:请求行 + Host +
Authorization: Bearer <JWT>+ 结束空行 - bp 的 Repeater 默认勾着
Update Content-Length,Send 时会自动把外层 CL 重算成 body 总字节数——这个一定要开着,手数必错(后面有血泪教训)
原理:前端(柜台)信 CL,把整个 body(终止块+走私请求,230 字节)当作数据整体转发;后端(后仓)信 TE,读到 0\r\n\r\n 认为第一个请求结束,剩下的字节就是它视角里的下一个请求——绕过了前端对 /flag1 的拦截。
2.单级走私 → 还是403
Send 之后拿到第二个响应,却是 403。这里踩了几个坑,每个坑的”验尸”过程比结果更有复习价值:
坑一:没有 TE 头 → 服务器挂死
只写 Content-Length: 999 不写 Transfer-Encoding,走私根本不发生:后端老老实实等 999 字节 body,实际只发了 230,连接直接超时无响应。CL+TE 同时存在才是走私开关。
坑二:JWT 断行 → 「票据无效」
从浏览器复制 JWT 时被折成三行:
1 | Authorization: Bearer |
HTTP 头的值不允许裸换行。走私请求到达后端验票失败,返回 403 标题是「票据无效」的页面。注意这和「你已因尝试获取flag被逮捕」是两个不同的 403——这两种 403 页面是本题最重要的 oracle:出现”票据无效”说明走私通道已通、票据没被读对;出现”逮捕”说明走私结构本身没过关。
坑三:走私深度不够
JWT 完整一行了,单级走私还是 403「逮捕」。这道题(旧实例)要求”隐秘的小道”= 多级走私:走私出去的请求自己又是一个 CL+TE 矛盾请求,再走私一层,第二级才到达真正验票的后仓。
3.多级走私 → flag1
在单级外面再套一层,即”走私请求 = POST /stage1 且自带 CL+TE 矛盾”:
1 | POST / |
同一个连接后面再跟一个正常请求(走私请求的响应会顶到它的响应位),Send:
1 | 200 OK ← 外层 POST / 的响应 |
4.插叙:Content-Length 到底从哪开始数
手搓走私死得最惨的就是 CL。规则一句话:CL = 从请求头结束空行之后的第一个字节,到报文最后一个字节。头部分(含 CL 行自己、含头部结束空行)一个字节都不算。
以上面单级走私为例,body 的字节构成:
1 | 3 0\r\n 终止块大小行 |
血泪点:
\r\n是 2 字节不是 1,每行末尾都有- 走私请求最后的空行也算在 CL 里,漏了正好差 2
- CL 大了 → 后端等数据挂起无响应;CL 小了 → 走私请求被拦腰截断
- 手数 JWT/Host 行极容易错(我手算 145,实际 147),永远让程序算或让 bp 的 Update Content-Length 代劳
五.Stage2:TE.TE 头混淆走私
黑商2 的暗号:“两边都认识 TE 的名号,可他们读招牌的脾气不同。让前门觉得这不是那张招牌,让后门照常按 TE 的规矩收货。” —— 经典 TE.TE。
「冒号前多了一粒灰」:把 TE 头写成冒号前带空格的非法形式:
1 | POST /stage2 |
严格解析的前端认为 Transfer-Encoding (带尾空格的头名)不是 TE 头,于是按 CL 读 body 转发;宽松解析的后端仍然认它是 TE,按 chunked 清点——走私成立。这里有两个实例相关的坑:
- 外层路径要发
POST /stage2(发POST /不触发这个走私分支,直接变成普通请求) - 标准写法
Transfer-Encoding: chunked反而 403(前端也认了,走的是 Stage1 的拦截逻辑)
Send,第二个响应:
1 | flag2: 3d_http_5mu6g1!n6 |
六.拼合与总结
1 | flag1: moectf{Y0u_have_m4r |
(Y0u_have_m4r3d_http_5mu6g1!n6 = “You have mastered http smuggling”)
复习清单:
- 走私开关 =
Content-Length+Transfer-Encoding同时存在的矛盾;只写 CL 就是被当普通请求处理 - CL 计数起点 = 头部结束空行之后;
\r\n算 2 字节;走私请求末尾空行要算;交给 bp 的 Update Content-Length - JWT 必须完整一行;走私请求的结束
\r\n\r\n不能丢(少了走私数据会被丢弃) - 票据只走
Authorization: Bearer,Cookie后端根本不读 - 两种 403 是诊断 oracle:「逮捕」= 走私结构没过,「票据无效」= 通道通了票没传对
- 动态靶机会重部署,走私深度(单级/多级)、触发路径(POST / 还是 POST /stage2)都可能变,先小步探测再上完整 payload
- TE.TE 常见混淆位:冒号前空格、冒号后 tab、值大小写、值前后空格——逐个试,看哪边”不认识”
参考与延伸
- PortSwigger Web Security Academy - HTTP request smuggling(bp 官方教程,全套实验强烈推荐)
- RFC 9112 §6(Message Body 的 CL/TE 处理规则)
