Перейти к содержимому

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

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

На машине 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:

  1. Initial Access — SSRF через форму загрузки (T1190, Exploit Public-Facing Application)
  2. Credential Access — чтение кредов из внутреннего API (T1552.001, Credentials In Files)
  3. Lateral Movement — SSH с украденными кредами (T1021.004, SSH)
  4. 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.