请求走私发生在不同组件对 HTTP 消息边界理解不一致,而且攻击者利用了这种分歧的时候。在代理链中,被前端当作一个请求正文的字节,可能被后端当成另一个请求。关键证据是解析和转发结果不一致,而不只是抓包中出现两条 HTTP 状态行。理解问题时要把字节归属、连接复用和实际影响分开,不能直接用响应数量代替边界分析。
同一字节流,不相容的边界
HTTP/1.1 的组帧有明确规则。合法 Content-Length 可以指定正文长度;chunked 传输编码通过终止块和尾部字段结束。两种机制都没有的请求不包含正文。两种字段同时存在时,Transfer-Encoding 优先,但这是一种可疑组合,需要专门处理,而不是随意接受后继续复用连接。
RFC 9112 允许服务器拒绝同时携带两个字段的请求,或者仅按 Transfer-Encoding 处理;无论如何,响应后都必须关闭连接。中间组件若选择继续转发,必须先移除收到的 Content-Length,并在转发前处理 Transfer-Encoding。入口直接拒绝双重组帧是有用的严格策略,但不能把它说成标准描述的唯一合法行为。
CL.TE 表示前端使用 Content-Length、后端使用 Transfer-Encoding;TE.CL 则相反。其他分歧可能来自冲突长度,或对传输编码语法的不同处理。这些名称描述的是解析器实际行为,不代表报文有两种同样正确的解释。面对不合法输入,不能让每一跳各猜一次,再期待最终边界碰巧一致。
失步可以产生不同影响
完整的隐藏请求可能马上被后端处理,并不必然等待下一位受害者。另一些攻击留下不完整前缀,再与后续流量组合。响应队列污染只是可能后果之一;绕过路由过滤、缓存污染和未授权的后端请求,也可能在没有相同响应错配步骤时造成影响。实际后果取决于连接复用、路由、认证和应用行为,不能用一个实验情节概括全部攻击。
发现额外的后端请求时,应追溯具体字节和连接。仅靠数量不足,因为重试、健康检查、缓存和异步日志都可能让各层记录合理地不一致。后端出现没有对应边缘记录的请求,是调查线索,不是自动确证。应结合关联数据,并在可控实验中使用可辨认的合成请求,排除正常机制之后再判断边界是否被突破。
坏掉的样子
表面的请求头过滤可能漏掉协议转换时产生的歧义。HTTP/2 使用帧,不采用 HTTP/1.1 的 chunked 分界,但降级到 HTTP/1.1 又引入一次必须正确完成的序列化。HTTP/2 也允许 Content-Length,其值必须与内容长度一致,因此不能说换成 HTTP/2 就不再存在任何相关长度检查。
端到端 HTTP/2 能避免部分降级特有的问题,却不是普遍免疫证明。应检查协议转换、统一拒绝畸形消息,并避免在不可恢复的组帧错误后继续复用连接。严格入口可以挡住某条具体攻击路径,即使另一组件仍然宽松;应按真实可达路径评估,不能断言只修一跳永远没有作用,也不能因为某一入口表现正确就忽略其他入口。
curl 显示 HTTP/2,只说明客户端这一段连接使用的协议,无法揭示 CDN 或反向代理后面的链路。类似地,两份响应可能只是正常回答了两个流水线请求。可靠评估必须展示每个组件如何归类字节,以及结果是否跨越原本的请求边界。状态码、计数和协议标签都提供线索,但单独一项无法证明整条代理链的行为。
快速验证
保存为 framing_demo.py,执行 python3 framing_demo.py。离线模型在内存里构造字节串,计算两种刻意不一致的边界,并测试一种严格拒绝策略。不向服务器发送任何东西,不创建套接字,合成路径也不指向真实管理端点。Content-Length 根据实际正文计算,而不是复制一个难以复核的固定数字,从而使例子中的计数可以直接核对。
# An in-memory boundary model, not an HTTP parser or network probe.
followup = b'GET /synthetic-next HTTP/1.1\r\nHost: example.test\r\n\r\n'
body = b'0\r\n\r\n' + followup
head = (b'POST /upload HTTP/1.1\r\nHost: example.test\r\nContent-Length: '
+ str(len(body)).encode() + b'\r\nTransfer-Encoding: chunked\r\n\r\n')
wire = head + body
header_end = wire.index(b'\r\n\r\n') + 4
cl_boundary = header_end + len(body)
# This fixture has precisely one zero chunk and an empty trailer section.
te_boundary = header_end + len(b'0\r\n\r\n')
if wire[cl_boundary:] != b'' or wire[te_boundary:] != followup:
raise RuntimeError('boundary expectations failed')
if cl_boundary == te_boundary:
raise RuntimeError('the two interpretations must differ')
def reject_dual_framing(header):
names = {line.split(b':', 1)[0].lower() for line in header.split(b'\r\n')[1:] if b':' in line}
if {b'content-length', b'transfer-encoding'} <= names:
raise ValueError('dual framing rejected')
try:
reject_dual_framing(head)
except ValueError as error:
print(error)
else:
raise RuntimeError('ambiguous message accepted')
ordinary_first = b'GET /first HTTP/1.1\r\nHost: example.test\r\n\r\n'
ordinary_pipeline = ordinary_first + followup
first_boundary = ordinary_pipeline.index(b'\r\n\r\n') + 4
if ordinary_pipeline[first_boundary:] != followup:
raise RuntimeError('ordinary pipeline example failed')
print('CL remaining bytes:', len(wire[cl_boundary:]))
print('TE remaining bytes:', len(wire[te_boundary:]))
print('ordinary pipeline: two intentionally separate requests')
print('PASS: different framing boundaries; dual framing rejected; pipelining distinguished')
按 Content-Length 解释时,剩余字节数是零;按这个固定 chunked 例子解释时,剩余部分是完整的五十二字节合成请求。严格策略拒绝两种字段并存。另一个正常流水线样本有意包含两个请求,不能因为二者同时出现就称为请求走私。PASS 只确认这些有限的关系,不会声称探测到了真实漏洞。
这不是通用 HTTP 解析器。chunked 用例只有一个零长度块和空尾部区,头部检查也没有实现完整字段语法、重复头规则或协议转换。它在不接触线上服务的条件下说明边界分歧为何重要。集成测试仍需得到授权的隔离链路、真实解析器版本、连接生命周期观察,以及能证明歧义输入无法触发非预期后端操作的负面用例,不能把内存模型当作线上系统的安全报告。
来源
- RFC 9112: HTTP/1.1,组帧优先级、中间组件处理、错误与持久连接,已核验 2026-10-15。
- PortSwigger: HTTP request smuggling,解析分歧分类及其后果,已核验 2026-10-15。
- RFC 9113: HTTP/2,帧内容、畸形消息与 Content-Length 一致性,已核验 2026-10-15。