HTB Forge SSRF — обход фильтра localhost и DNS rebinding на практике

За последний год на трёх пентестах из двенадцати я находил SSRF именно в функции загрузки файлов по URL — тот же паттерн, что на машине Forge с HackTheBox. Во всех трёх случаях защита строилась на чёрном списке запрещённых адресов, и во всех трёх я обходил фильтр за считаные минуты. Forge (рейтинг Medium, Linux) показывает эту цепочку в чистом виде: SSRF в загрузчике на Flask, обход denylist, доступ к внутренней admin-панели, извлечение SSH-ключа через FTP и повышение привилегий до root. Разберём каждый шаг — что работает, что нет и почему denylist-защита от SSRF обречена.
Разведка: открытые порты и виртуальные хосты
Прежде чем атаковать — нужно понять, с чем имеешь дело. Какие сервисы запущены, есть ли скрытые точки входа. Разведка даст три факта, на которых строится вся дальнейшая атака.
Требования к окружению: Kali Linux или Parrot OS, активное VPN-подключение к HTB, инструменты nmap и wfuzz или ffuf (предустановлены в Kali). IP цели: 10.10.11.111.
Первый шаг — добавить IP в /etc/hosts, иначе браузер не резолвит доменное имя forge.htb: echo "10.10.11.111 forge.htb" | sudo tee -a /etc/hosts.
Запускаем сканирование портов: nmap -p- --min-rate 10000 10.10.11.111. Результат — три порта:
- 22/tcp (SSH) — OpenSSH 8.2p1. Без учётных данных или ключа тут делать нечего.
- 80/tcp (HTTP) — Apache 2.4.41 с редиректом на
http://forge.htb. - 21/tcp (FTP) — статус
filtered. Файрвол блокирует внешний доступ, но сам FTP-сервис работает.
Отфильтрованный FTP — первая зацепка. Если найдём способ отправлять запросы от имени сервера (SSRF), сможем обратиться к FTP изнутри, минуя файрвол.
Дальше — поиск виртуальных хостов. Vhosts — это когда на одном IP живут несколько сайтов, а сервер различает их по заголовку Host. Запускаем wfuzz -u http://10.10.11.111 -H "Host: FUZZ.forge.htb" -w subdomains-top1million-20000.txt --hw 26. Флаг --hw 26 отсекает стандартные ответы с 26 словами (дефолтная страница). Среди результатов всплывает субдомен admin.forge.htb. Добавляем его в /etc/hosts.
Заходим в браузере на http://admin.forge.htb — и получаем: «Only localhost is allowed!» Admin-панель доступна только с 127.0.0.1. Вторая зацепка: нужен SSRF, чтобы сервер сам обратился к своей admin-панели от имени localhost.
SSRF в загрузчике файлов на Flask — как работает уязвимость
На forge.htb есть фотогалерея и форма загрузки по адресу /upload с двумя режимами: загрузка локального файла и загрузка по URL. Второй режим — классическая точка входа для SSRF-атаки на веб-приложение.
SSRF (Server-Side Request Forgery) — атака, при которой злоумышленник заставляет сервер отправить HTTP-запрос на произвольный адрес. Проще говоря: ты даёшь серверу URL, он по нему ходит — а ты контролируешь, куда именно. В MITRE ATT&CK (открытая база тактик и техник атак; T-коды вроде T1190 — её идентификаторы) SSRF попадает под технику T1190 — Exploit Public-Facing Application из тактики Initial Access. OWASP выделил SSRF отдельной позицией A10:2021 в Top 10 критических рисков веб-приложений, классифицируя её как CWE-918 — приложение получает URL от пользователя и отправляет запрос без должной валидации назначения.
Зачем атакующему SSRF: сервер находится во внутренней сети и имеет доступ к ресурсам, закрытым снаружи — admin-панелям, базам данных, облачным метаданным (AWS 169.254.169.254), FTP-серверам за файрволом. По сути, SSRF превращает веб-приложение в твой прокси.
Для подтверждения SSRF запускаем на своей машине python3 -m http.server 80 и вставляем свой IP в форму «Upload from URL». В логах Python-сервера появляется входящий запрос с User-Agent: python-requests/<version>. Это подтверждает две вещи: SSRF работает (сервер действительно ходит по указанному URL) и бэкенд написан на Python (библиотека requests — стандарт для Flask-приложений; Apache здесь работает как реверс-прокси).
[Применимо: внешний пентест, веб-приложения с функцией загрузки/импорта по URL, webhook-интеграции]
Обход чёрного списка: от смены регистра до DNS rebinding
Попытка подставить http://admin.forge.htb или http://127.0.0.1 в форму загрузки возвращает ошибку: «URL contains a blacklisted address!» Фильтр реализован как denylist (чёрный список) — проверяет URL на наличие запрещённых подстрок. Это принципиально слабее белого списка (allowlist), потому что denylist нужно обойти лишь один раз.
Работает если: фильтр — denylist с регистрозависимым сравнением строк, сервер следует HTTP-редиректам, нет DNS pinning (фиксации IP после первого DNS-резолва). Не работает если: используется allowlist (белый список), фильтр проверяет IP-адрес после DNS-резолва, HTTP-клиент не следует 3xx-ответам.
Смена регистра URL — простейший обход denylist SSRF
HTTP-протокол и DNS нечувствительны к регистру: Admin.Forge.Htb резолвится в тот же IP, что и admin.forge.htb. А фильтр Forge ищет подстроку admin.forge.htb — строка Admin.Forge.Htb не совпадает с шаблоном при регистрозависимом сравнении. Баг уровня «ну серьёзно?» — но именно так оно и бывает в реальных приложениях.
Подставляем http://Admin.Forge.Htb в форму загрузки по URL — фильтр пропускает. Сервер возвращает ссылку на «загруженное изображение» вида http://forge.htb/uploads/<random>. Браузер покажет ошибку отображения (загрузчик отдаёт Content-Type как image/jpg, не проверяя реальный тип ответа), но curl http://forge.htb/uploads/<random> выводит HTML admin-панели. В ответе — две ссылки: /announcements и /upload. Внутренний интерфейс, недоступный снаружи.
Редирект через свой сервер — обход без прямого URL
Если фильтр приводит URL к нижнему регистру перед сравнением — case-bypass не пройдёт. Тогда поднимаем свой HTTP-сервер, который отвечает 302 с Location: http://admin.forge.htb. Подставляем свой IP в форму загрузки — фильтр видит внешний IP (его нет в чёрном списке) и пропускает. Сервер Forge обращается к нам, получает 302, следует редиректу — и выполняет запрос к admin.forge.htb от имени localhost.
Зачем знать оба метода: на реальном пентесте один из обходов может не сработать. Case-bypass провалится при нормализации регистра. Редирект не поможет, если HTTP-клиент (python-requests с allow_redirects=False) не следует 3xx. Иметь в арсенале несколько вариантов — не роскошь, а необходимость.
DNS rebinding — продвинутый обход фильтра localhost
DNS rebinding эксплуатирует разрыв во времени между проверкой URL и фактическим HTTP-запросом. Этот паттерн называется TOCTOU (Time-of-Check-to-Time-of-Use) — проверка и использование происходят в разные моменты, и между ними можно «подменить» реальность. В MITRE ATT&CK специализированного T-кода для DNS rebinding как метода обхода SSRF-фильтра нет. Ближайшие техники — T1071.004 (DNS) и T1568 (Dynamic Resolution) — описывают DNS-манипуляции для C2-каналов, а не для SSRF-bypass. DNS rebinding в контексте SSRF остаётся подтехникой эксплуатации в рамках T1190.
Механика по шагам:
- Атакующий контролирует домен (например,
evil.attacker.com) и его DNS-сервер. - Первый DNS-запрос целевого сервера возвращает публичный IP (скажем,
93.184.216.34). Фильтр проверяет — адрес не локальный, пропускает. - TTL DNS-записи (Time To Live — как долго кэшировать ответ) устанавливается минимальным: 0–2 секунды.
- Когда сервер отправляет фактический HTTP-запрос, кэш DNS уже истёк. Повторный запрос к DNS возвращает
127.0.0.1. - Сервер обращается к
127.0.0.1, думая, что этоevil.attacker.com.
На Forge DNS rebinding избыточен — case-bypass решает задачу проще. Но в реальных пентестах, когда фильтр нормализует регистр, сервер не следует редиректам, а DNS pinning не реализован — DNS rebinding остаётся рабочим вектором.
Для быстрого тестирования есть сервис rbndr.us от Taviso (также lock.cmpxchg8b.com/rebinder.html): генерирует домен, чередующий DNS-ответы между двумя IP. Указываете свой публичный IP и 127.0.0.1 — сервис создаёт поддомен. Минус: IP чередуется недетерминированно, атака может потребовать множества попыток. Для стабильного результата лучше поднять собственный DNS через dnschef на Kali и контролировать TTL вручную.
Реальный пример — CVE-2026-0560: эндпоинт /api/files/export-content в parisneo/lollms вызывал _download_image_to_temp() в backend/routers/files.py, которая не валидировала пользовательские URL перед отправкой запроса — классическая CWE-918. Через неё можно было обращаться к внутренним сервисам и облачным метаданным. Уязвимость устранена (NVD указывает <2.2.0, OSV.dev — fixed 2.1.1). Предполагаемый подход к фиксу — DNS pinning: хостнейм резолвится один раз, полученный IP проверяется на принадлежность к приватным диапазонам, повторного DNS-запроса не происходит — rebinding невозможен.
Цепочка эксплуатации SSRF: от admin-панели до SSH-ключа
Фильтр обойдён, admin-панель доступна через SSRF. Дальше — четыре шага, каждый использует загрузчик файлов Forge как прокси к внутренним сервисам.
Делай раз: читаем /announcements. Подставляем http://Admin.Forge.Htb/announcements в форму загрузки на forge.htb/upload. Забираем результат: curl http://forge.htb/uploads/<filename>. Ожидаемый результат — HTML-страница с двумя критичными фактами: учётные данные для внутреннего FTP-сервера (логин user, пароль — открытым текстом) и информация о том, что admin-загрузчик (/upload) принимает параметр ?u=<url>, включая FTP-схему. По ATT&CK это T1552.001 — Credentials In Files (тактика Credential Access): пароли хранятся на внутренней странице без шифрования.
Делай два: читаем файлы через FTP. Конструируем URL с двойным SSRF — внешний загрузчик Forge обращается к admin-загрузчику, а тот — к FTP от имени localhost:
http://Admin.Forge.Htb/upload?u=ftp://user:<пароль_из_announcements>@Localhost/user.txt
Подставляем этот URL в форму forge.htb/upload. Забираем через curl — получаем содержимое user.txt с пользовательским флагом. Обрати внимание: Localhost написан с заглавной буквы, чтобы обойти denylist и на этом уровне цепочки.
Делай три: извлекаем SSH-ключ. Меняем путь в FTP-URL на /.ssh/id_rsa. Принцип тот же: подставляем в форму, забираем через curl, сохраняем в файл id_rsa.txt. По ATT&CK — T1552.004 — Private Keys (тактика Credential Access): извлечение приватных SSH-ключей через доступ к файловой системе. Можно также запросить /.ssh/ без имени файла — FTP выдаст листинг директории (T1083 — File and Directory Discovery), что подтвердит наличие ключа перед его вытаскиванием.
Делай четыре: подключаемся по SSH. Перед использованием убедись, что файл содержит чистый PEM-ключ (должен начинаться с -----BEGIN OPENSSH PRIVATE KEY-----): curl может захватить лишние HTML-обёртки от upload-прокси. Удали всё за пределами PEM-блока, например через sed -n '/-----BEGIN/,/-----END/p' id_rsa.txt > id_rsa_clean. Затем chmod 600 id_rsa_clean (SSH откажет в подключении, если ключевой файл доступен другим пользователям) и ssh -i id_rsa_clean user@forge.htb. Ожидаемый результат: shell от имени user.
Повышение привилегий через Python PDB
Имея shell от user, проверяем sudo-привилегии: sudo -l. Результат показывает, что пользователь может запустить /usr/bin/python3 /opt/remote-manage.py от root без пароля.
Скрипт remote-manage.py открывает TCP-сокет на случайном порту для удалённого управления. Ключевой момент: при необработанном исключении скрипт роняется в PDB (Python Debugger — встроенный отладчик Python). PDB даёт интерактивную Python-консоль с полным доступом к интерпретатору. Скрипт запущен от root — значит и PDB работает с root-привилегиями. Один import os; os.system("/bin/sh") — и у тебя рутовый шелл.
Работает если: sudo позволяет запуск Python-скрипта без пароля, скрипт вызывает PDB при исключении, на sudo-правиле нет флага NOEXEC. Не работает если: sudo настроен с NOEXEC (запрещает вызов подпроцессов из разрешённой команды), скрипт обрабатывает все исключения без PDB, используется restricted Python shell.
Эксплуатация требует двух SSH-сессий к машине:
- В первом терминале:
sudo /usr/bin/python3 /opt/remote-manage.py. Скрипт выводит номер порта. - Во втором терминале:
nc localhost <port>. Вводим некорректные данные, чтобы вызвать исключение. - В первом терминале появляется PDB-промпт
(Pdb). Вводим:
import os
os.system("/bin/sh")
Ожидаемый результат: root-shell. whoami выводит root. Забираем /root/root.txt.
Когда обход фильтра SSRF не работает
Все три метода обхода эксплуатируют фундаментальные слабости denylist-подхода. Вот что делает защиту действительно рабочей — и где описанные техники бессильны.
DNS pinning — сервер резолвит хостнейм один раз и фиксирует полученный IP для всех последующих запросов. Между валидацией и fetch нет повторного DNS-запроса — DNS rebinding нейтрализуется. Аналогичный подход предположительно использован для исправления CVE-2026-0560 — SSRF в parisneo/lollms (CVSS 7.5 HIGH, CWE-918, исправлено в версии 2.1.1 по OSV.dev / <2.2.0 по NVD).
Проверка IP после DNS-резолва — фильтр не сравнивает строки URL, а резолвит DNS и проверяет полученный IP на принадлежность к внутренним диапазонам: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (link-local, включая облачные метаданные AWS/Azure/GCP), 100.64.0.0/10 (Carrier-grade NAT), IPv6-loopback ::1. При таком подходе ни case-bypass, ни DNS-трюки через nip.io или localhost.me не помогут.
Allowlist вместо denylist — разрешается обращение только к заранее определённым хостам. Обойти можно через open redirect на разрешённом домене или URL parser confusion — но это на порядок сложнее, чем обход denylist. URL parser confusion работает, когда разные компоненты системы (валидатор и HTTP-клиент) интерпретируют один URL по-разному: https://allowed.com@evil.com может быть воспринят валидатором как allowed.com, а HTTP-клиентом — как evil.com.
Отключение редиректов — параметр allow_redirects=False в python-requests. Без следования 3xx-ответам атака через редирект невозможна.
По рекомендации OWASP (A10:2021), эффективная защита от эксплуатации SSRF уязвимости — комбинация мер: allowlist плюс DNS pinning плюс отключение редиректов плюс валидация IP после резолва. Одного denylist недостаточно — Forge это показывает наглядно.
Forge — машина, которую стоит проходить не ради флага, а ради паттерна. SSRF через загрузку файлов по URL — не CTF-экзотика. На пентестах веб-приложений этот вектор встречается в импорте аватаров, webhook-настройках, PDF-генераторах, интеграциях с внешними API. По данным Mandiant M-Trends 2025, эксплуатация уязвимостей (включая, но не ограничиваясь SSRF) — наиболее распространённый вектор начального доступа: 38% инцидентов в 2024 году начинались с эксплойтов. OWASP не случайно выделил SSRF в Top 10 отдельной строкой.
Неудобная правда: большинство разработчиков «закрывают» SSRF одним if "127.0.0.1" in url: block() и считают задачу решённой. Потом приходит пентестер и обходит через hex-представление 0x7f000001, через 127.1, через [::1], через localhost.me (домен, резолвящийся в 127.0.0.1), через decimal IP 2130706433 или через DNS rebinding. Denylist — фундаментально проигрышная стратегия: атакующему нужно найти один обход из десятков вариантов, а защитнику — предусмотреть их все. DNS pinning с валидацией IP после резолва меняет эту динамику, но я до сих пор встречаю его в продакшене реже, чем denylist с тремя if-ами. Если хочется не просто повторить одну машину, а системно разобраться в HTTP, DNS и сетевых протоколах, на которых строятся такие цепочки — IB Basics на codeby.school закрывает эту базу за пару месяцев, без пересказа Википедии.
Эту тему и смежные навыки разбирают на практике в курсе «Python для пентестера» Codeby Academy.