
Security Advisory: HTTP Request Smuggling via Unparsed Transfer-Encoding Values (tiny_http)
分配的 CVE ID: CVE-2026-66752
tiny_http 只检查是否存在 Transfer-Encoding 请求头,从不检查其值。任何值(包括不是 chunked 的编码,以及最终元素不是 chunked 的编码列表)都会导致请求体被按 chunked 解码,同时 Content-Length 会被丢弃。
正确解析传输编码的前端会以与 tiny_http 不同的方式界定此类请求的边界。同一连接上两个参与者使用两种不同的边界界定方式,是请求走私的前提条件。发送带有非 chunked 编码的非 chunked 请求体,也会使 tiny_http 读取请求体失败,并且完全不返回任何响应。
仓库 URL:https://github.com/tiny-http/tiny-http
| 受影响 | 所有已发布版本,直至并包括 0.12.0(2022-10-06),即当前版本 |
| 已验证 | 0.6.2, 0.6.3, 0.8.0, 0.9.0, 0.10.0, 0.11.0, 0.12.0 |
| 修复版本 | 撰写本文时尚无修复版本 |
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:L/SC:L/SI:L/SA:N
远程、未认证的客户端发送原始 HTTP 请求。无需凭据或用户交互。
走私场景要求 tiny_http 位于正确转发 Transfer-Encoding 和 Content-Length 并正确应用 RFC 9112 第 6.1 节的前端或 CDN 之后,也就是说,该前端会将最终元素不是 chunked 的编码列表视为非 chunked,并回退到 Content-Length。这样一来,前端和 tiny_http 会对请求体结束的位置产生分歧。
无响应请求场景除了能够建立连接外,不需要任何其他条件。
tiny_http-0.12.0/src/request.rs 第 143 至 153 行。该请求头被找到并克隆,但其值仅被检查是否存在:
143 // finding the transfer-encoding header
144 let transfer_encoding = headers
145 .iter()
146 .find(|h: &&Header| h.field.equiv("Transfer-Encoding"))
147 .map(|h| h.value.clone());
148
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
tiny_http-0.12.0/src/request.rs 第 218 至 221 行随后无条件应用 chunked 解码器:
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>
transfer_encoding 是一个保存原始值的 Option<AsciiString>,但没有任何代码检查其内容。RFC 9112 第 6.1 节要求请求的最终编码为 chunked,并要求服务器在否则的情况下拒绝该消息;第 6.3 节要求拒绝同时带有 Transfer-Encoding 和 Content-Length 的请求。
步骤 1. 启动一个服务器,用于报告界定请求边界的请求头及其读取到的请求体。
use std::io::Read;
use tiny_http::{Response, Server};
fn main() {
let server = Server::http("127.0.0.1:8005").unwrap();
for mut request in server.incoming_requests() {
let te = request.headers().iter()
.find(|h| h.field.equiv("Transfer-Encoding"))
.map(|h| h.value.as_str().to_string());
let cl = request.headers().iter()
.find(|h| h.field.equiv("Content-Length"))
.map(|h| h.value.as_str().to_string());
let mut body = Vec::new();
let r = request.as_reader().read_to_end(&mut body);
println!("TE={:?} CL={:?} read={:?} body={:?}",
te, cl, r, String::from_utf8_lossy(&body));
let _ = request.respond(Response::from_string("ok"));
}
}
步骤 2. 发送一个带有 Transfer-Encoding: identity、匹配的 Content-Length 以及普通请求体的请求。任何前端都会将该请求体界定为 hello 这五个字节。
printf 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: identity\r\nContent-Length: 5\r\n\r\nhello' | nc -w 2 127.0.0.1 8005
结果:tiny_http 对一个并非 chunked 的请求体进行了 chunked 解码,将其丢弃,并且没有发送任何响应。客户端会一直等到自己的超时时间,连接被消耗。
TE=Some("identity") CL=Some("5") read=Err(Custom { kind: InvalidInput, error: DecoderError }) body=""
步骤 3. 发送一个最终元素不是 chunked 的编码列表。
printf 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked, identity\r\n\r\n5\r\nhello\r\n0\r\n\r\n' | nc -w 2 127.0.0.1 8005
结果:tiny_http 接受该请求并按 chunked 解码,而 RFC 9112 第 6.1 节要求拒绝该请求:
TE=Some("chunked, identity") CL=None read=Ok(5) body="hello"
作为对比,仅使用 Content-Length 以及正确的 Transfer-Encoding: chunked 都能正常处理,并得到 body="hello"。
当 tiny_http 部署在正确解析传输编码的前端之后时,两个组件会对请求体结束的位置产生分歧。攻击者可以选择该分歧两侧的字节,这是将请求走私绕过前端路由和访问控制的标准设置。
与任何前端无关,步骤 2 和 3 展示了一个明显格式错误的请求,它会占用一个工作线程,并且根本收不到任何响应,因此客户端无法判断请求是否被拒绝。
在 src/request.rs 第 144 行附近,按 Transfer-Encoding 的实际形式(逗号分隔的列表)对其进行解析:
对于带有请求体的请求,要求最终编码为 chunked,并对任何其他编码返回 400 拒绝,而不是按 chunked 解码。对于同时带有 Transfer-Encoding 和 Content-Length 的请求,应按照 RFC 9112 第 6.3 节对非代理服务器的要求予以拒绝,而不是在第 150 行静默忽略 Content-Length。
当请求体读取失败时,应返回 400,而不是丢弃请求且不发送任何响应。