HTB Editorial прохождение: от SSRF в форме загрузки до кредов из внутреннего API

На машине Editorial с HackTheBox весь foothold уместился в одно поле формы — «Cover URL» на странице загрузки книги. Поле принимало произвольный URL, сервер делал запрос от своего имени, а на localhost:5000 висел незащищённый API с кредами в открытом виде. От обнаружения SSRF до SSH-шелла — пятнадцать минут чистой работы. Ниже — пошаговый разбор, где я объясняю не только что делать, но и почему это работает.
Место в kill chain: одна форма — четыре этапа атаки
Прежде чем лезть в консоль, полезно увидеть полную цепочку. MITRE ATT&CK — открытая база тактик и техник, которыми пользуются атакующие. У каждой техники есть T-код (T1190, T1552.001 и т.д.) — по ним пентестеры и аналитики SOC ссылаются на конкретные шаги в атаке. Если встретите T-код в writeup’е или отчёте — ищите его на attack.mitre.org, там описание с примерами.
Цепочка на Editorial:
- Initial Access — SSRF через форму загрузки (T1190, Exploit Public-Facing Application)
- Credential Access — чтение кредов из внутреннего API (T1552.001, Credentials In Files)
- Lateral Movement — SSH с украденными кредами (T1021.004, SSH)
- Privilege Escalation — утечка из Git-репозитория + CVE-2022-24439 в GitPython до root
Контекст: внешний пентест, modern-стек (Python/Flask/nginx на Ubuntu 22.04). SSRF здесь — классический вектор из OWASP Top 10 A10:2021. Суть проста: веб-приложение делает HTTP-запрос по URL от пользователя, не проверяя, куда именно этот запрос уходит. Та же техника встречается в реальных проектах — формы загрузки аватаров, обложек, импорта по URL.
Требования к окружению
- ОС: Kali Linux 2023+ или любой дистрибутив с предустановленным набором пентест-инструментов
- RAM: 2 ГБ минимум (машина работает на стороне HTB, локально тяжёлых процессов нет)
- Инструменты: nmap, ffuf (оба предустановлены в Kali), curl, SSH-клиент
- Сеть: активное VPN-подключение к HTB (файл
.ovpnиз личного кабинета) - Опционально: Burp Suite Community Edition для перехвата и модификации запросов (бесплатный, ставится из Kali-репозитория)
Разведка: что открыто и куда смотреть
Начинаем с nmap — сетевого сканера, который показывает открытые порты и сервисы на целевой машине. Запускаем полное сканирование: nmap -p- --min-rate 10000 10.10.11.20, затем детальное nmap -p 22,80 -sCV 10.10.11.20. Флаг -sCV — комбинация двух вещей: -sC запускает стандартные NSE-скрипты обнаружения (например, проверяет баннеры, SSL-сертификаты), а -sV определяет версии сервисов.
Результат: два открытых порта — 22/tcp (OpenSSH 8.9p1, Ubuntu) и 80/tcp (nginx 1.18.0). Веб-сервер отвечает редиректом на http://editorial.htb, значит, нужно добавить запись в /etc/hosts:
echo '10.10.11.20 editorial.htb' | sudo tee -a /etc/hosts
По версиям OpenSSH и nginx — это Ubuntu 22.04 Jammy. Перебор поддоменов через ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u http://editorial.htb -H "Host: FUZZ.editorial.htb" -ac ничего не даёт. Перебор директорий (feroxbuster или gobuster) находит три пути: /, /about, /upload. Ничего скрытого.
Что на сайте
Сайт — издательство книг. Страница /about содержит email submissions@editorial.htb. Форма подписки на newsletter не работает (GET без параметров). Поиск тоже не отправляет данных. Единственная интерактивная точка — страница /upload («Publish with us»), где есть форма для загрузки книги с двумя интересными полями: файл обложки и URL обложки (Cover URL).
При нажатии «Preview» форма отправляет POST на /upload-cover с двумя параметрами: bookurl (URL из поля) и bookfile (загруженный файл). Поле bookurl — наша точка входа.
SSRF в форме загрузки обложки
SSRF (Server-Side Request Forgery) — подделка запроса на стороне сервера. Если объяснять на пальцах: вы говорите серверу «скачай картинку вот по этому адресу», а вместо картинки подсовываете адрес внутреннего ресурса. Сервер послушно делает запрос — но уже от своего имени, из внутренней сети. Атакующий указывает URL вида http://127.0.0.1:ПОРТ/путь, и сервер обращается к ресурсам, которые снаружи просто недоступны.
Как обнаружить
Заполняем поле Cover URL ссылкой на свою машину (например, http://10.10.14.6/test), предварительно запустив python3 -m http.server 80. Жмём «Preview». В логах Python-сервера видим входящее соединение от 10.10.11.20 — сервер Editorial сделал запрос к нам. Заголовок User-Agent: python-requests/2.25.1 подсказывает, что бэкенд написан на Python и использует библиотеку requests.
В HTTP-ответе приходит путь к загруженному файлу: /static/uploads/<UUID>. Скачиваем через curl http://editorial.htb/static/uploads/<UUID> — получаем содержимое того, что сервер скачал по нашему URL. Это не просто SSRF — сервер сохраняет ответ и отдаёт его нам. Файл сохраняется с UUID-именем без расширения, поэтому загрузить и выполнить PHP/Python-шелл не получится.
Разница в поведении — ключ к фаззингу
При обращении к http://127.0.0.1 (порт 80) сервер зависает на ~20 секунд и возвращает дефолтную заглушку. При обращении к закрытому порту (например, http://127.0.0.1:33333) ответ приходит мгновенно. Открытый порт с HTTP-сервисом — долгий ответ, закрытый — быстрый. Но бэкенд на Python requests поддерживает только HTTP/HTTPS, поэтому сканировать не-HTTP сервисы (SSH на 22) бесполезно — requests выбросит ошибку.
Ещё одна подсказка: ответ при успешном запросе имеет Content-Length: 51 (путь к загруженному файлу), при неуспешном — Content-Length: 61 (путь к дефолтной картинке-заглушке). Эту разницу можно использовать для фильтрации в ffuf.
Фаззинг внутренних портов через SSRF
Задача: найти внутренние HTTP-сервисы, слушающие на localhost. Для этого используем ffuf — быстрый веб-фаззер, который подставляет значения из словаря (или генератора) в указанное место запроса.
Сохраняем POST-запрос к /upload-cover из Burp Suite в файл ssrf.request, заменяя номер порта на слово FUZZ:
POST /upload-cover HTTP/1.1
Host: editorial.htb
Content-Type: multipart/form-data; boundary=----bound
------bound
Content-Disposition: form-data; name="bookurl"
http://127.0.0.1:FUZZ
------bound
Content-Disposition: form-data; name="bookfile"; filename=""
Content-Type: application/octet-stream
------bound--
Запускаем: ffuf -u http://editorial.htb/upload-cover -request ssrf.request -w <(seq 1 65535) -fs 61 -ac. Флаг -fs 61 отфильтровывает ответы с размером 61 байт (заглушка), -ac включает дополнительную автофильтрацию. Процесс подставляет в FUZZ числа от 1 до 65535 — полный диапазон TCP-портов.
Результат: порт 5000 возвращает ответ, отличный от заглушки. Все остальные — либо закрыты, либо не отвечают по HTTP.
Извлечение credentials из внутреннего API
Обращаемся к найденному сервису: в поле Cover URL вводим http://127.0.0.1:5000, жмём Preview, забираем UUID из ответа и скачиваем содержимое: curl http://editorial.htb/static/uploads/<UUID>. Сервер возвращает JSON с описанием API-эндпоинтов:
{
"messages": [
{"promotions": {"endpoint": "/api/latest/metadata/messages/promos"}},
{"coupons": {"endpoint": "/api/latest/metadata/messages/coupons"}},
{"new_authors": {"endpoint": "/api/latest/metadata/messages/authors"}},
{"platform_use": {"endpoint": "/api/latest/metadata/messages/how_to_use_platform"}}
],
"version": [
{"changelog": {"endpoint": "/api/latest/metadata/changelog"}},
{"latest": {"endpoint": "/api/latest/metadata"}}
]
}
Внутренний API на Flask/Gunicorn, привязанный к 127.0.0.1:5000 — снаружи недоступен, но через SSRF мы читаем его свободно. Дальше перебираем каждый эндпоинт: в Cover URL вводим http://127.0.0.1:5000/api/latest/metadata/messages/authors, скачиваем результат.
Эндпоинт /api/latest/metadata/messages/authors отдаёт приветственное сообщение для нового автора — и прямо в теле JSON лежат креды: имя пользователя dev и пароль dev080217_devAPI!@. Эндпоинт /api/latest/metadata/changelog содержит ещё два email-адреса: soporte@tiempoarriba.oc и info@tiempoarriba.oc.
Почему это сработало: разработчик засунул учётные данные прямо в ответ внутреннего API — вероятно, как часть шаблона welcome-сообщения. API был привязан только к localhost и считался «безопасным», потому что снаружи не виден. SSRF ломает это допущение на раз.
Foothold: SSH как пользователь dev
Имея креды, проверяем их через SSH: ssh dev@editorial.htb с паролем dev080217_devAPI!@. Подключение успешно — мы внутри как пользователь dev (uid=1001). Файл user.txt лежит в домашней директории. Проверяем sudo -l — у dev нет sudo-привилегий.
Foothold завершён. Полная цепочка: SSRF (T1190) → чтение внутреннего API (T1552.001) → SSH с украденными кредами (T1021.004, T1078.003). Три шага, ноль эксплойтов в классическом смысле — только злоупотребление штатной функциональностью.
Путь к root: Git-секреты и CVE-2022-24439
Для полноты картины — краткий разбор эскалации. В домашней директории dev находится каталог apps с Git-репозиторием. Команда git log --oneline показывает историю коммитов, а git diff между коммитами раскрывает ещё один набор кредов — для пользователя prod. Переключаемся: ssh prod@editorial.htb.
Пользователь prod может запускать скрипт clone_prod_change.py через sudo с Python 3. Скрипт использует библиотеку GitPython, а на машине стоит уязвимая версия (ниже 3.1.30).
CVE-2022-24439 — RCE (удалённое выполнение кода) во всех версиях GitPython до 3.1.30. Причина: библиотека не санитизирует пользовательский ввод при передаче URL в команду git clone, и через URL можно внедрить произвольные аргументы (CWE-20 — недостаточная валидация входных данных).
Несколько цифр для понимания масштаба. CVSS — стандартизированная шкала оценки критичности уязвимостей от 0 до 10. У этой CVE CVSS 8.1 HIGH, вектор CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H. Если разложить: AV:N — атака по сети, AC:H — высокая сложность эксплуатации, PR:N — привилегии не нужны. EPSS (Exploit Prediction Scoring System — оценка вероятности реальной эксплуатации в ближайшие 30 дней) составляет 0.054, что ставит уязвимость в top 10% по вероятности использования. Исправлена в gitpython 3.1.30 (по данным OSV.dev).
Через подстановку вредоносного URL в параметр clone можно выполнить произвольную команду от root. Итог: cat /root/root.txt.
Место каждого этапа в цепочке
| Этап | Тактика ATT&CK | Техника | Что происходит |
|---|---|---|---|
| SSRF | Initial Access | T1190 | Доступ к внутреннему API через форму загрузки |
| Чтение кредов | Credential Access | T1552.001 | Пароль dev из JSON-ответа API |
| SSH dev | Lateral Movement | T1021.004 | Шелл от имени dev |
| Git enum | Discovery | T1083 | Креды prod в истории коммитов |
| SSH prod | Persistence | T1078.003 | Переключение на prod |
| GitPython RCE | Execution | T1059.004 | Команда от root через CVE-2022-24439 |
Ограничения SSRF и когда техника не работает
SSRF в Editorial — учебный пример, потому что здесь нет ни одной защиты. В реальных проектах ситуация другая.
[Применимо: внешний пентест, modern web-стек с функцией импорта по URL]
Когда SSRF не работает или деградирует:
- Allowlist URL. Если приложение проверяет URL по белому списку доменов (например, только
*.example.com), прямой запрос к127.0.0.1отклоняется. Обходы существуют: DNS rebinding, URL-парсинг через декорирование (http://127.0.0.1@allowed.com). Но современные фреймворки (Django 4+, Spring Boot сRestTemplateчерезSimpleClientHttpRequestFactory) фильтруют большинство таких трюков. - Network segmentation. Если внутренний API сидит в отдельном VLAN без маршрутизации с веб-сервера, SSRF не достигнет цели. В облаках (AWS, GCP) метадата-сервис на
169.254.169.254доступен почти всегда — но IMDSv2 (AWS) требует токен через PUT-запрос, чтоpython-requestsс SSRF не сделает автоматически. - WAF / outbound-фильтрация. Stateful firewall или WAF (например, ModSecurity с правилом SecRule на
127.0.0.1в теле запроса) детектирует попытку SSRF. Но если URL передаётся в заголовке или в multipart-body — не все WAF парсят это корректно. - Blind SSRF без side-channel. На Editorial разница в
Content-Lengthдала нам side-channel для определения открытых портов. Если ответ всегда одинаковый (нет сохранения ответа, нет разницы по времени), SSRF превращается в blind — и ценность падает резко.
Детекция со стороны защиты: исходящие HTTP-запросы от веб-сервера к 127.0.0.1 или RFC1918-адресам — аномалия. Её зафиксирует любой SIEM с правилом на outbound-трафик от DMZ-сегмента. В Elastic 8.x+ и Splunk ES такие правила есть в дефолтных detection packs.
Формулу на бумаге понять несложно, но SSRF по-настоящему ощущается, когда сам прогоняешь запросы и видишь, как меняется поведение сервера. Стенд для практики можно собрать локально (Flask + Gunicorn за nginx, привязка API к localhost), либо взять аналогичную задачу на CTF-платформе.
Машина Editorial показывает одну вещь: SSRF — это не про чтение /etc/passwd. Это про доступ к внутренней инфраструктуре, которая считается «безопасной», потому что не торчит наружу. На реальных пентестах я чаще вижу SSRF как трамплин к метадате облачных провайдеров или к внутренним API — чем к локальным файлам. Разработчики закрывают LFI, ставят WAF на SQL-инъекции, но забывают, что форма «загрузить картинку по URL» — это полноценный HTTP-клиент с привилегиями серверной сети.
И ещё момент, который упускают почти все writeup’ы: проблема Editorial не в SSRF как таковом. Проблема в том, что внутренний API на порту 5000 отдаёт креды без аутентификации. SSRF — только средство доставки. Если бы API требовал токен, SSRF остался бы пустышкой. Частая ошибка проектирования: «если сервис слушает только на localhost, он безопасен». Нет — потому что между «снаружи недоступен» и «не может быть достигнут» лежит целый класс атак.
Для тех, кто только начинает разбираться в пентесте и хочет не просто читать writeup’ы, а системно пройти базу — от сетевых основ до первых рабочих задач — на IB Basics это выстроено в структуру без академического тона.
Эту тему и смежные навыки разбирают на практике в курсе «Тестирование веб-приложений на проникновение (WAPT)» Codeby Academy.