SSRF: от удобной интеграции до дырки в облако
С SSRF обычно знакомятся не по учебнику, а по какому-нибудь на вид безобидному куску API. Нужно, скажем, чтобы сервис сам подтянул картинку по image_url, сходил за документом по file_url, проверил webhook, собрал превью ссылки, отрендерил PDF из HTML или пробросил callback во внешнюю систему. Снаружи это выглядит как обычная интеграционная рутина. Внутри - как право пользователя выбирать, куда сервер сам сделает запрос. В этот момент вся история резко перестаёт быть про "обработать URL" и становится историей про то, кому именно разрешили рулить исходящим трафиком приложения.Именно здесь SSRF начинает работать в полную силу. Сервер смотрит на сеть не так, как внешний клиент: ему видны внутренние сегменты, loopback, служебные endpoint'ы и приватные сервисы. У него есть доступ во внутренние сегменты, к loopback, к служебным endpoint'ам, к соседним сервисам по приватным адресам, а в облаке - ещё и к metadata service, до которого снаружи обычно не дотянуться вообще. Если пользователь может подсунуть серверу цель запроса, дальше всё упирается в одно: понимает ли код, куда он реально пошёл после резолвинга, редиректов и нормализации.
Из-за этого SSRF плохо укладывается в старое представление о "веб-уязвимости на одном экране". В реальной системе параметр \url` быстро превращается в исходящий запрос из доверенного контура. Причём от имени не абстрактного клиента, а вполне доверенного сервиса, который живёт в нужной подсети, ходит через разрешённые egress-правила и для части инфраструктуры выглядит своим. Если сервис ещё и умеет сам ходить по редиректам, тянуть вложенные ресурсы, рендерить HTML, SVG или PDF, то поверхность атаки разрастается тихо и без фанфар. Именно поэтому SSRF так часто всплывает не на игрушечных стендах, а в нормальных production-интеграциях, где никто изначально не собирался строить прокси во внутреннюю сеть.
Почему в облаке всё становится намного неприятнее
На обычном сервере SSRF может закончиться чтением внутреннего healthcheck, страницы админки на localhost или какого-нибудь забытого сервиса на соседнем порту. В облаке ставка выше. Рядом с workload почти всегда живёт metadata service. У AWS это Instance Metadata Service на 169.254.169.254. У Google Cloud метаданные доступны изнутри инстанса через metadata server, включая metadata.google.internal и link-local адрес. У Yandex Cloud у виртуальной машины тоже есть metadata service на 169.254.169.254, причём поддерживается и совместимый с GCE формат запросов через computeMetadata/v1. Всё это задумано как внутренняя служебная точка, а не как что-то, что должен щупать пользовательский ввод через ваш API.Дальше история смещается из веб-логики в IAM. AWS через metadata отдаёт временные security credentials для роли инстанса. Google Cloud позволяет из workload-контекста получать токены сервисного аккаунта для обращения к API Google Cloud. В Yandex Cloud через metadata доступны служебные данные ВМ, пользовательские данные и identity-документы, которые тоже могут стать опорой для дальнейшей цепочки. То есть SSRF в облаке - это не обязательно путь к RCE на самой машине. Иногда до исполнения кода дело даже не доходит: хватает уже того, что атакующий добрался до облачной идентичности приложения и начал разговаривать с control plane от его имени.
Отсюда и неприятный контраст. Один и тот же дефект в двух окружениях даёт разный вес. В маленьком внутреннем сервисе без прав он останется локальным инцидентом. В API, который крутится на виртуалке или в контейнере с жирной ролью, тот же SSRF может внезапно открыть доступ к бакетам, очередям, секретам, образам, логам, внутренним API и прочему хозяйству, которое приложение и так видит по своим полномочиям. Проблема уже не в самом запросе, а в его контексте: запрос идёт от имени доверенного элемента инфраструктуры.
Почему SSRF до сих пор наверху
С этим классом ошибок у команд одна и та же беда: его слишком долго пытаются лечить строковыми фильтрами. Запретили localhost, запретили 127.0.0.1, потом вспомнили про 169.254.169.254, добавили пару проверок на схему, успокоились. А потом выясняется, что между строкой URL и реальным соединением лежит не одна прямая функция, а целый зоопарк из URL-парсера, DNS-резолвинга, обработки редиректов, приведения адресов, поддержки IPv6, нормализации хоста и особенностей конкретной HTTP-библиотеки. Поэтому SSRF и живёт так долго: он прячется не в экзотике, а в рассинхроне между тем, что хотел проверить разработчик, и тем, куда фактически пошёл клиент внутри приложения.Есть ещё один момент, который лучше сразу поправить, чтобы не тащить в текст вчерашнюю картину мира. В OWASP Top 10:2021 SSRF действительно был отдельной категорией A10. Но в OWASP Top 10:2025 его не выкинули и не признали "устаревшим". Его свернули в A01 Broken Access Control, и это, если честно, даже логично. Для современной архитектуры SSRF - это нарушение границы доступа, возникающее из-за криво проверенного URL: приложение начинает ходить туда, куда пользователь напрямую ходить не должен, причём делает это с правами самого сервиса. Смысл уязвимости от этого не смягчился, просто её стали точнее укладывать в общий класс проблем с доступом и доверительными зонами.
За последние годы облачные провайдеры, конечно, подтянули защиту. AWS жёстко продвигает IMDSv2 с сессионным токеном и отдельными настройками metadata options. Google и Yandex требуют специальный заголовок Metadata-Flavor: Google при работе с метаданными в совместимом формате. Но все эти меры работают только там, где ими реально пользуются и где вокруг них не оставили широкую дыру в исходящих запросах приложения. Один небезопасный fetcher, один слишком доверчивый webhook, один рендерер, который тянет внешние ресурсы без жёсткого контроля целей - и дальше уже не так важно, насколько красиво у вас выглядел blacklist на входе.
SSRF в 2026 году история с SSRF давно вышла за пределы старых веб-лабораторок, где сервер послушно идёт на localhost, а пентестер радуется первой удаче. Нормальный production-контекст делает его куда злее: вокруг полно внешних интеграций, сервисы постоянно тянут удалённые ресурсы, а облако само кладёт рядом с workload служебные точки доступа, которые не прощают лишнего доверия к пользовательскому URL. Поэтому смотреть на SSRF как на "ещё один баг валидации" уже поздно. Это вполне рабочий способ превратить удобную серверную функцию в входную дверь во внутреннюю сеть и облачную плоскость управления.
Где SSRF обычно прячется в современных API
Самая частая ошибка в голове у разработчика и у тестировщика одинаковая: SSRF ждут там, где в параметре уже торчит полный URL и всё выглядит подозрительно с первого взгляда. В реальном API он чаще сидит в обычной прикладной логике - сервис что-то подтягивает, проверяет, рендерит, импортирует, обогащает или отправляет наружу от имени пользователя. OWASP API Security Top 10 прямо перечисляет типовые зоны риска: webhooks, загрузка файлов по URL, кастомные SSO-потоки и URL previews. Там же хорошо сформулирована причина, почему это особенно неприятно в 2026 году: современные платформы, облака, Kubernetes и контейнерная среда держат рядом с приложением HTTP-каналы управления и служебные endpoint'ы, до которых внешнему клиенту обычно не добраться.Параметры, которые "просто ходят по сети"
Самый прямой вариант - любой параметр, после которого бэкенд сам открывает соединение. Это может называться как угодно: fetch_url, image_url, file_url, avatar_url, callback_url, webhook_url, resume_url, import_url. Смысл один и тот же: пользователь не передаёт контент, он передаёт направление, а дальше приложение уже само решает, чем туда идти, какие заголовки добавить, сколько раз следовать за редиректом и что считать валидным ответом. В OWASP API7 это описано довольно честно: API fetch'ит удалённый ресурс без нормальной проверки пользовательского URL, и атакующий заставляет его идти в неожиданную цель, даже если та прикрыта firewall или VPN.На практике такие точки редко лежат в одном месте и редко подписаны табличкой "SSRF here". Один endpoint делает превью ссылки для мессенджера, второй проверяет доступность webhook при регистрации интеграции, третий тянет логотип компании по ссылке из профиля, четвёртый импортирует OpenAPI-спеку или конфиг по URL. Бывает ещё менее очевидный вариант: клиент присылает не весь URL, а только хост, путь или домен для callback, а сервер уже сам собирает итоговый адрес из шаблона. Снаружи это выглядит безобидно: полный URL не приходит, значит и риск будто бы меньше. На практике это та же SSRF-связка, только в менее очевидной форме. На деле это просто другой формат той же уязвимой связки. PortSwigger отдельно отмечает такие partial URL-потоки как типичный скрытый слой SSRF-поверхности.
Отдельно неприятны webhook'и. Их любят за асинхронность и за удобство интеграций, а с точки зрения SSRF это почти готовый канал для blind-сценария. Ответ внутреннего сервиса пользователь может и не увидеть вовсе, но сам факт серверного запроса уже даёт поверхность для проверки гипотезы, а дальше всё упирается в побочные эффекты, логи, тайминги, DNS-хиты и поведение системы доставки. Для тестировщика это означает простую вещь: если в продукте есть "проверить URL", "отправить тестовое уведомление", "потянуть внешний ресурс" или "сделать превью", это не косметика интерфейса, а сетевой примитив, который надо смотреть как полноценный SSRF entry point.
Загрузка файла, который на самом деле не файл, а инструкция парсеру
Вторая большая зона - форматы, которые команда по инерции считает "картинкой", "документом" или "вложением", хотя сервер видит перед собой вовсе не пассивный blob, а структуру, которую надо разобрать. Самый показательный пример - SVG. В бытовом смысле его часто воспринимают как картинку. По факту это XML-документ, а элементы и атрибуты внутри него умеют ссылаться на внешние ресурсы. MDN прямо пишет, что href в SVG задаёт ссылку на ресурс в виде URL, а старый xlink:href, хотя и объявлен устаревшим в SVG 2, всё ещё встречается и для ряда элементов вроде <image>, <feImage> и <script> по-прежнему описан как ссылка на внешний источник или код.И вот здесь начинается та самая развилка, на которой многие ломаются. Если приложение принимает SVG не как тупой файл на хранение, а как вход для дальнейшей обработки - миниатюры, конвертации, санитизации, извлечения метаданных, рендера в PNG, сборки PDF, - безопасность определяется уже не расширением файла, а поведением конкретного XML/SVG-парсера и конвейера вокруг него. PortSwigger в своей учебной лаборатории показывает ровно такой сценарий: загрузка аватара в SVG, который затем обрабатывается Apache Batik, и из безобидной загрузки изображения внезапно вырастает поверхность для XXE.
У XXE здесь своя роль, и она не декоративная. OWASP в cheat sheet по XXE отдельно указывает, что внешние сущности могут приводить не только к чтению файлов, но и к SSRF и порт-сканированию с машины, где работает парсер. Там же дано самое здравое базовое правило: если XML вам действительно нужен, самый надёжный путь - полностью отключать DTD и внешние сущности, а не надеяться на частичную фильтрацию.
По той же логике надо смотреть и на другие загрузки "спецформатов". XML-документ, который уходит в импорт конфигурации. Office/PDF-конвертация через промежуточный XML/HTML слой. Любая библиотека, которая ради удобства сама подтягивает то, на что сослался пользовательский документ. Проблема здесь обычно не в одном конкретном расширении, а в неверном предположении команды, будто загрузка файла заканчивается записью на диск. Нет, часто она только там и начинается.
PDF generation: SSRF через рендерер, а не через "сетевой" endpoint
Очень рабочий вектор - генерация PDF из HTML. Тут уязвимость часто пропускают, потому что на уровне продукта функция выглядит как офисная: сделать счёт, выгрузить отчёт, собрать коммерческое предложение, сохранить карточку заказа в PDF. Но если сервис собирает PDF не из безопасного шаблона с заранее известным контентом, а рендерит HTML, в который попадает пользовательский ввод или пользовательская разметка, у вас в контуре появляется полноценный браузерный движок или близкий к нему рендерер. А браузерные движки не "рисуют строку", они загружают страницу и её подресурсы. <link> тянет внешние стили, изображения и другие связанные ресурсы - MDN это описывает прямо.С wkhtmltopdf это особенно наглядно. Его документация описывает вход как page object с содержимым веб-страницы, а список опций показывает, что инструмент умеет грузить изображения, исполнять JavaScript, ждать его завершения, передавать cookie, добавлять кастомные HTTP-заголовки и при желании даже распространять эти заголовки на каждый запрос за подресурсами. Отдельно есть контроль ошибок загрузки страниц и media, а также флаги --disable-local-file-access и --enable-local-file-access, которые сами по себе хорошо показывают, что рендерер не ограничен "рисованием текста", а взаимодействует с внешними и локальными ресурсами как полноценный загрузчик.
С Puppeteer история та же, только стек современнее. Официальная документация прямо говорит, что Puppeteer управляет Chrome в headless-режиме, а Chrome Headless с версии 112 использует общий код с обычным Chrome без функциональных ограничений. В API Puppeteer есть и page.goto(url), и page.setContent(html), а также механизм setExtraHTTPHeaders, который отправляет дополнительные заголовки с каждым запросом, инициированным страницей. Это важная деталь для AppSec-контекста: если backend рендерит пользовательский HTML в headless Chrome с сервисными cookie, токеном или внутренними заголовками, SSRF там живёт не в отдельном "fetch endpoint", а в самой модели рендера. Страница поднимается на сервере, дальше движок сам идёт за стилями, картинками, шрифтами и прочими URL, которые оказались в DOM.
Отсюда и типичный сценарий промаха. Команда честно говорит: у нас нет никакого fetch_url, мы вообще не даём пользователю указать внешний адрес. А потом выясняется, что пользователь контролирует HTML-блок в шаблоне письма, кастомный раздел отчёта, содержимое rich-text редактора, логотип по ссылке, CSS-тему или фрагмент Markdown, который в итоге превращается в HTML и уезжает в PDF-рендерер. Прямого сетевого параметра на поверхности может и не быть, но сетевое поведение внутри уже есть, и оно куда интереснее обычного requests.get(). С этого места PDF generation уже надо смотреть как SSRF-поверхность, а не как вспомогательную офисную функцию.
Обход фильтров: почему "мы же запретили localhost" почти никогда не работает
С SSRF защита часто ломается в одном и том же месте. Разработчик смотрит на URL как на строку, а приложение потом работает уже не со строкой, а с цепочкой из парсинга, нормализации, DNS-резолвинга, редиректов и фактического соединения. На входе проверили одно представление адреса, на выходе получили совсем другое. OWASP по этой причине прямо пишет две неприятные для разработчика вещи: не надо пытаться тушить SSRF denylist'ами и регулярками, и надо учитывать URL consistency, DNS rebinding и TOCTOU-гонки. PortSwigger показывает ту же картину на практических лабах: blacklist, whitelist и редиректы ломаются не магией, а тем, что валидатор и сетевой клиент по-разному понимают один и тот же ввод.IP obfuscation: адрес тот же, строка другая
Самый дешёвый и до сих пор рабочий класс обходов - альтернативная запись адреса. Как только фильтр сравнивает строку с набором запрещённых шаблонов, игра почти проиграна. Один и тот же loopback или link-local можно представить в другом виде: сокращённой IPv4-записью, целым числом, IPv6-mapped адресом, percent-encoded кусками, комбинацией этих техник. PortSwigger на лабораториях показывает это на простом примере: фильтр режет127.0.0.1, но пропускает 127.1, потому что проверка смотрела на текст, а не на канонический адрес после нормализации. OWASP отдельно предупреждает, что denylist и regex здесь не спасают именно по этой причине.Из-за этого вся логика вида "ищем в строке
localhost и 169.254.169.254" годится только для самоуспокоения. Здесь важен сам механизм: приложение должно принимать решение не по тому, как адрес был написан пользователем, а по тому, во что он превратился после полной канонизации и DNS/IP-разрешения. Пока проверка живёт на уровне сырого ввода, обход будет вопросом формы записи, а не принципиальной сложности.В 2026 году это особенно неприятно в облаке и контейнерной среде, потому что рядом с приложением есть адреса, которые для внешнего клиента недоступны, а для самого workload абсолютно валидны. Поэтому строковый blacklist ломается не только на
127.1, но и на всех случаях, где внутренний адрес записан не так, как его ожидает автор фильтра. И чем больше в коде ручных replace(), startswith() и "запретим пару плохих хостов", тем выше шанс, что кто-то проверяет один текст, а соединение уходит совсем по другому маршруту.DNS rebinding: hostname проверили один раз, а адрес уже не тот
С hostname-based фильтрами проблема тоньше. Здесь строка может выглядеть вполне прилично, домен проходит allowlist, а дальше приложение делает то, что и должно делать сетевое приложение, - резолвит имя. И вот на этом стыке появляется rebinding: в момент проверки имя ещё указывает туда, куда фильтр готов его пустить, а к моменту реального соединения уже резолвится во внутренний адрес. OWASP не случайно упоминает рядом сразу две вещи - DNS rebinding и TOCTOU race conditions. Смысл один: между "проверили" и "сходили" не должно образовываться пространства, где цель запроса меняется незаметно для логики безопасности.На практике это бьёт по системам, где проверка hostname и сетевое соединение разделены по слоям. Один компонент валидирует строку, другой позже резолвит её заново, третий ещё и кэш использует по своим правилам. В тестах такое часто выглядит как странный частичный результат: например, по домену виден DNS lookup, а дальше HTTP-запроса нет. PortSwigger отдельно отмечает, что в blind SSRF это обычная картина, когда инфраструктура разрешает DNS, но режет нежелательный HTTP наружу. Для защитника здесь вывод неприятный, но полезный: одного "мы проверили домен" мало, если вы не привязываете проверку к финальному IP и не контролируете повторный резолвинг.
Если хочется копнуть именно blind SSRF - без красивого ответа от сервера, но с рабочими таймингами, DNS-триггерами и логикой проверки гипотез через побочные признаки, - для вас есть разбор: "Blind SSRF: тайминги, DNS-триггеры и метаданные".
URL parsing inconsistencies: одна библиотека валидирует, другая уходит в сеть
Это самый неприятный класс обходов, потому что он часто сидит в "нормальном" коде без всякой экзотики. Разработчик берёт стандартный парсер, достаётscheme, hostname, path, что-то сверяет, а потом другой слой приложения уже делает реальный HTTP-запрос. На словах всё чисто. На практике сами Python docs просят здесь не расслабляться: urlsplit() и urlparse() не выполняют валидацию входа, могут успешно разобрать то, что другие компоненты сочтут невалидным, и вообще рассчитаны на практический разбор URL, а не на security-gate. Там же отдельным текстом сказано, что в security-чувствительном коде надо проверять результат дополнительно и кодить defensively.У этой проблемы есть ещё один неприятный хвост: единое определение "что такое URL" вообще не является универсальным. Python docs прямо пишут, что WHATWG и RFC 3986 смотрят на URL не идентично, а сами библиотечные API исторически старше части этих стандартов и не могут считаться строгой реализацией какой-то одной модели. Для SSRF это означает простую вещь: как только защита построена на предположении, что один парсер уже окончательно "понял" адрес, вы начинаете доверять слишком хрупкой абстракции. Особенно весело становится там, где в URL разрешены embedded credentials, нестандартные разделители, абсолютные и scheme-relative формы, fragment tricks и повторное декодирование. PortSwigger на лабе с whitelist-фильтром как раз показывает, как подобные расхождения начинают работать против разработчика.
Отдельно сюда же бьёт привычка использовать
urljoin() как будто это безопасная сборка URL из "нашей базы" и "пользовательского хвоста". Python docs предупреждают прямо: если второй аргумент контролируется извне и может быть абсолютным URL, urljoin() соберёт в результате именно внешний абсолютный адрес. То есть даже без экзотических payload'ов приложение может незаметно превратиться из "достраиваем путь" в "разрешаем подменить весь target". Для SSRF это уже готовая мина, особенно в коде, где разработчик уверен, что работает только с относительными путями.Здесь и рождается тот самый рассинхрон, который потом тяжело ловить глазами на code review. Валидация уверена, что проверила hostname. Сетевой клиент уверен, что честно выполнил корректный URL. Между ними лежат декодирование, нормализация, userinfo, fragment handling, относительные формы и особенности конкретной библиотеки. В результате никто по отдельности не сделал "очевидную глупость", а SSRF всё равно собрался.
Redirect chains: фильтр видел одно, клиент в итоге пошёл в другое
С редиректами история совсем прозаичная. Допустим, приложение действительно ограничило первый хост, разрешило только свой домен или один внешний партнёрский endpoint. На этом месте команда расслабляется. Потом выясняется, что HTTP-клиент по умолчанию послушно следует заLocation, и уже второй запрос уходит туда, где никакой allowlist не применялся. OWASP поэтому и рекомендует для SSRF-контекста отключать HTTP redirections, а не считать, что первичная проверка URL автоматически распространяется на всю цепочку.PortSwigger показывает этот сценарий на отдельной лабе: прямой доступ к внутреннему хосту запрещён, но внутри того же приложения находится open redirect, через который серверный клиент уже добирается до внутреннего admin endpoint. С практической точки зрения это один из самых неприятных обходов, потому что снаружи он выглядит почти легитимно: первый URL формально лежит на "разрешённом" хосте, а вся грязь начинается уже после первого 30x. Если клиент автоматически follows redirect, фильтр проверял просто не ту точку маршрута.
Именно здесь хорошо видно, почему SSRF-защита не живёт в одной функции
validate_url(). Недостаточно проверить схему, хост и порт у исходной строки. Нужно понимать финальную цель запроса после редиректов, а лучше - вообще не позволять клиенту самостоятельно менять destination без повторной policy-проверки. Иначе любой внешний open redirect, партнёрский redirector или просто "удобный" hop в интеграционной цепочке превращается в транспорт до внутреннего ресурса.По-хорошему, все эти обходы надо воспринимать как один и тот же архитектурный дефект под разными углами. Валидатор принимает решение по сырой строке, промежуточному имени или первой точке маршрута. А сетевой стек потом честно исполняет уже другую сущность - канонический IP, другой parsed host, финальный redirect target или повторно разрешённое имя. В этот зазор и проваливается SSRF. Пока приложение проверяет не ту сущность, никакой blacklist не спасёт.
Cloud metadata: где SSRF перестаёт быть веб-багом и начинает ломать IAM
До этого места SSRF ещё можно было держать в голове как неприятную, но всё-таки локальную историю: приложение пошло не туда, куда ему подсунули URL. В облаке этот фокус становится заметно злее. Рядом с workload почти всегда живёт metadata service - локальная точка, из которой машина или сервис получают сведения о себе, а иногда и облачную идентичность. У AWS это IMDS на169.254.169.254, с опциональным IPv6 endpoint fd00:ec2::254 на Nitro-инстансах в IPv6-поддерживаемых подсетях. У Google Cloud metadata доступно через metadata.google.internal, а запросы требуют заголовок Metadata-Flavor: Google. У Yandex Cloud metadata внутри ВМ тоже доступно через 169.254.169.254, в GCE-совместимом формате и с тем же обязательным заголовком.С этого места SSRF бьёт уже не по "внутренней вебке", а по облачной модели доверия. Если workload может через metadata получить краткоживущие учётные данные или identity-документ, дальше ущерб начинает определяться не кодом уязвимого API, а правами attached role или service account. AWS прямо документирует получение временных security credentials роли инстанса через metadata. Google Compute Engine документирует сценарий, где приложение на ВМ запрашивает OAuth2 access token у metadata server и дальше использует его для вызова Cloud API. Yandex Cloud отдельно пишет, что metadata можно использовать для аутентификации внутри облака, а identity document формируется из metadata самой ВМ.
| Платформа | Где живёт metadata | Что делает атаку болезненной | Что уже мешает прямому SSRF | Где команды чаще всего промахиваются |
|---|---|---|---|---|
| AWS EC2 | 169.254.169.254, опционально fd00:ec2::254 | Credentials роли инстанса и смежные identity-данные | IMDSv2, token required, PUT без X-Forwarded-For, hop limit | Ставят IMDSv2 и считают тему закрытой, не проверяя реальную ширину роли и поведение контейнеров |
| Google Cloud | metadata.google.internal, HTTP endpoint, HTTPS endpoint в Preview | Access token сервисного аккаунта workload | Metadata-Flavor: Google | Смотрят только на заголовок и забывают, что сам серверный код может его добавить |
| Yandex Cloud | 169.254.169.254 в GCE-совместимом формате | IAM token сервисного аккаунта, identity document | Metadata-Flavor: Google, metadata options gce-http-endpoint и gce-http-token | Оставляют token endpoint включённым там, где он приложению вообще не нужен |
AWS: IMDSv2 сильно поднял планку, но не отменил impact
С AWS тут всё стало заметно лучше после IMDSv2. Документация EC2 прямо описывает двухшаговый поток: сначала нужен токен, потом уже с ним читаются metadata-пути. AWS отдельно указывает, чтоPUT-запросы с X-Forwarded-For отвергаются, а ответ на PUT по умолчанию имеет hop limit 1. Это сильно ломает часть старых SSRF-цепочек, особенно там, где запрос идёт через дополнительный сетевой hop или через неподходящий прокси-слой.Но здесь легко попасть в ложное чувство безопасности. IMDSv2 не "чинит SSRF" как класс. Он делает доступ к metadata менее прямолинейным, а дальше всё снова упирается в то, может ли код внутри инстанса корректно работать с metadata и насколько широкая роль к нему прикручена. AWS отдельно пишет, что временные credentials роли инстанса выдаются через metadata, а использовать их можно в пределах прав этой роли. Если роль жирная, то и последствия у одной SSRF-точки получаются вполне жирные.
Нормальная защитная настройка для EC2 выглядит так:
Bash:
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-endpoint enabled \
--http-put-response-hop-limit 1
HttpTokens=required включает режим IMDSv2 only, HttpEndpoint=enabled оставляет сам IMDS доступным, а hop limit можно задать отдельно. При этом сама документация отдельно предупреждает, что в контейнерных сценариях hop limit 1 может создать проблемы совместимости, а значение можно поднимать до 2+ при необходимости. Это полезная деталь для статьи: каждая уступка совместимости здесь влияет и на поверхность доступа к metadata.Если приложению metadata не нужен вообще, самый жёсткий вариант - не спорить с SSRF на уровне приложения, а просто выключить HTTP endpoint целиком:
Bash:
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-endpoint disabled
Google Cloud: metadata как штатный канал для workload identity
У Google Cloud логика более "платформенная". Compute Engine прямо описывает модель, где приложение на ВМ использует metadata server для получения access token сервисного аккаунта, а потом этим токеном ходит в Google Cloud API. То есть metadata здесь не побочная служба "для пары системных полей", а вполне штатный механизм аутентификации workload'а. Для SSRF это плохая новость по очень простой причине: если серверный код умеет сам формировать запрос с нужным заголовком, он уже находится рядом с источником рабочей облачной идентичности.Здесь у Google есть неплохой встроенный предохранитель. Metadata server требует
Metadata-Flavor: Google, а без этого заголовка запрос отклоняется. Плюс в документации уже есть HTTPS endpoint в Preview, что само по себе хорошо показывает направление мысли провайдера: metadata перестали воспринимать как безусловно доверенный локальный HTTP-сервис, который можно держать без дополнительных барьеров. Но для AppSec это не повод расслабляться. Если уязвимый backend сам контролирует заголовки и сетевую логику, наличие обязательного header не превращает SSRF в теоретическую уязвимость. Это просто ещё один слой защиты, который надо учитывать честно, без магического мышления.В код-ревью тут полезно держать в голове тревожный паттерн. Если в backend-компоненте внезапно появляется логика такого класса, это уже повод задавать неприятные вопросы:
Python:
METADATA_HEADERS = {"Metadata-Flavor": "Google"}
def get_workload_access_token():
# сервис получает краткоживущий токен у metadata server
# и дальше использует его для вызова Cloud API
...
Yandex Cloud: знакомая картинка, но с полезными metadata options
В Yandex Cloud модель для атакующего выглядит очень знакомо. Metadata живёт на169.254.169.254, внутри ВМ доступно без внешней аутентификации, а для GCE-совместимого формата нужен Metadata-Flavor: Google. Документация отдельно пишет, что metadata service внутри ВМ доступен всем аутентифицированным пользователям ВМ без ограничений, и ограничить доступ отдельного пользователя к metadata нельзя. Это важный практический момент: если у тебя есть уязвимый сервис внутри машины, платформа не даст тонко развести "этому процессу можно, а этому нет" на уровне самого metadata service.У Yandex здесь есть полезное отличие в управляемости. В metadata options можно отдельно включать и выключать
gce-http-endpoint, а ещё отдельно управлять gce-http-token, который отвечает за доступ к IAM token сервисного аккаунта через GCE-flavored metadata. Это хорошая, очень инженерная деталь: если workload не должен получать IAM token через metadata, можно не философствовать про "минимизировать риск", а взять и отрезать именно этот путь.Практически это выглядит так:
Bash:
yc compute instance update <VM_ID> \
--metadata-options gce-http-endpoint=enabled,gce-http-token=disabled
PROPERTY=VALUE[,PROPERTY=VALUE...], а в списке свойств отдельно документированы gce-http-endpoint и gce-http-token. Для систем, которым metadata нужен только как источник части служебных полей, а не как канал выдачи IAM token, это одна из самых полезных настроек во всей теме.Если сервису metadata не нужен вообще, можно идти ещё жёстче и выключать endpoint:
Bash:
yc compute instance update <VM_ID> \
--metadata-options gce-http-endpoint=disabled
Если после metadata и IAM хочется посмотреть, как SSRF в облаке превращается уже не просто в доступ к служебному endpoint'у, а в полноценную цепочку с движением дальше по инфраструктуре, сюда органично встанет материал: "Web: SSRF to RCE через Redis - цепочка атак на облачную инфраструктуру".
Защита: SSRF режется не blacklist'ом, а контролем над тем, куда сервис вообще имеет право ходить
Почти все провалы в защите от SSRF начинаются одинаково: кто-то пытается лечить сетевую проблему строковой проверкой. Запретили localhost, 127.0.0.1, 169.254.169.254, добавили пару регулярных выражений, успокоились. OWASP в SSRF Prevention Cheat Sheet как раз от этого и отталкивается: рабочая защита строится не на denylist'ах, а на жёстком контроле допустимых целей, отключении ненужных редиректов и сетевой изоляции на уровне инфраструктуры. Иными словами, надо не угадывать все плохие формы записи адреса, а заранее решать, куда приложению вообще можно ходить.Allowlist: проверять нужно не "красивый URL", а финальную цель
Если сервис должен ходить только в известные внешние системы - CDN, объектное хранилище, API партнёра, внутренний egress-прокси, - тут нет никакой причины оставлять ему произвольный outbound по пользовательскому URL. Нормальная модель здесь жёсткая: фиксированные схемы, фиксированные хосты, фиксированные порты, по возможности фиксированные path prefix'ы, а решение принимается уже после нормализации URL. В OWASP cheat sheet это и есть базовая линия защиты для case 1: когда приложение должно общаться только с доверенными приложениями, allowlist надо строить на реально разрешённых целях, а не на попытке перечислить всё запрещённое.Ниже - не "волшебная функция от SSRF", а пример того, как должен думать backend. Он принимает только HTTPS, проверяет хост по allowlist, резолвит его сам, сверяет итоговые адреса с разрешёнными сетями и не следует за редиректами автоматически.
Python:
from urllib.parse import urlsplit
from ipaddress import ip_address, ip_network
import socket
import requests
ALLOWED_TARGETS = {
"cdn.example.com": {
"ports": {443},
"cidrs": [
ip_network("203.0.113.0/24"),
ip_network("2001:db8:100::/48"),
],
"path_prefixes": ("/images/", "/avatars/"),
},
"api.partner.example": {
"ports": {443},
"cidrs": [
ip_network("198.51.100.0/24"),
],
"path_prefixes": ("/v1/webhooks/",),
},
}
def resolve_all_ips(hostname: str) -> set[str]:
results = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
return {item[4][0] for item in results}
def fetch_allowed_url(raw_url: str) -> requests.Response:
parsed = urlsplit(raw_url)
if parsed.scheme != "https":
raise ValueError("Only https is allowed")
host = parsed.hostname
if not host or host not in ALLOWED_TARGETS:
raise ValueError("Host is not allowlisted")
cfg = ALLOWED_TARGETS[host]
port = parsed.port or 443
if port not in cfg["ports"]:
raise ValueError("Port is not allowed")
if not parsed.path.startswith(cfg["path_prefixes"]):
raise ValueError("Path is not allowed")
resolved_ips = resolve_all_ips(host)
for raw_ip in resolved_ips:
ip = ip_address(raw_ip)
if not any(ip in cidr for cidr in cfg["cidrs"]):
raise ValueError(f"Resolved IP {ip} is outside allowed ranges")
return requests.get(
raw_url,
timeout=5,
allow_redirects=False,
)
Network isolation: приложению не нужен прямой доступ ко всему интернету и ко всей внутренней сети
Даже хороший allowlist в коде - не повод оставлять сервису безграничный egress. OWASP прямо рекомендует страховать SSRF на сетевом уровне, а Kubernetes NetworkPolicy позволяет ограничивать ingress и egress на L3/L4, если CNI вообще умеет это применять. Здесь важна не красота схемы, а дисциплина: сервисы, которые тянут внешние URL, не должны видеть metadata, приватные сегменты и произвольные внутренние HTTP-сервисы просто "потому что так получилось в сети".Практически это часто означает очень скучную, но рабочую архитектуру: сервис ходит не "куда хочет", а только в egress-прокси или в узкий набор адресов, которые ему действительно нужны. В Kubernetes это можно зафиксировать политикой. Пример ниже разрешает рендереру выход только к egress-прокси и к DNS, а не в любую точку сети.
YAML:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: report-renderer-egress
spec:
podSelector:
matchLabels:
app: report-renderer
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: networking
podSelector:
matchLabels:
app: egress-proxy
ports:
- protocol: TCP
port: 8080
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Metadata нужно либо жёстко ограничивать, либо выключать там, где оно не нужно
Отдельный слой защиты - отрезать самый ценный локальный ресурс рядом с workload. В AWS это означает IMDSv2 only, а где возможно - полное отключение metadata endpoint для тех инстансов, которым он не нужен. В Google Cloud metadata по-прежнему доступно через metadata.google.internal и 169.254.169.254, а обязательный Metadata-Flavor: Google - это полезный барьер, но не абсолютная защита, если сам серверный код умеет добавлять заголовки. В Yandex Cloud можно отдельно управлять gce-http-endpoint и gce-http-token, причём gce-http-token по документации отвечает именно за доступ к IAM credentials через GCE-flavored metadata.На стороне AWS базовая гигиена выглядит так:
Bash:
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-endpoint enabled \
--http-put-response-hop-limit 1
Bash:
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-endpoint disabled
В Yandex Cloud та же логика выглядит так:
Bash:
yc compute instance update <VM_ID> \
--metadata-options gce-http-endpoint=enabled,gce-http-token=disabled
Code review и Semgrep: искать надо не только прямые сетевые вызовы
В живом коде SSRF редко выглядит как одна голая строка requests.get(user_url). Обычно пользовательский URL проходит через helper, serializer, service class, wrapper над HTTP-клиентом или рендер-конвейер. Поэтому простой grep по requests.get( полезен только на старте. Дальше уже нужен dataflow. В документации Semgrep это и называется taint analysis: движок отслеживает путь "грязных" данных до sink'ов и хорошо подходит для классов уязвимостей, где значение приходит из входа и позже попадает в опасный вызов.Для SSRF это можно оформить отдельным правилом. Ниже - не silver bullet, а рабочая заготовка для Python, которая ловит типичную картину: URL приходит из HTTP-входа и доезжает до сетевого sink'а, если по дороге не прошёл через явный sanitizer.
YAML:
rules:
- id: python-ssrf-prone-fetch
languages:
- python
severity: WARNING
message: User-controlled URL reaches an outbound HTTP sink
mode: taint
pattern-sources:
- pattern-either:
- pattern: request.args.get(...)
- pattern: request.form.get(...)
- pattern: request.json.get(...)
- pattern: flask.request.args.get(...)
- pattern: flask.request.json.get(...)
pattern-sinks:
- pattern-either:
- pattern: requests.get($URL, ...)
- pattern: requests.post($URL, ...)
- pattern: requests.request(..., $URL, ...)
- pattern: httpx.get($URL, ...)
- pattern: httpx.post($URL, ...)
- pattern: urllib.request.urlopen($URL, ...)
pattern-sanitizers:
- pattern: validate_outbound_url(...)
Где заканчивается баг и начинается архитектурная проблема
SSRF уже давно вышел из категории уязвимостей, которые удобно держать в голове как частный дефект валидации URL. Слишком много вокруг сервисов, которые сами ходят по сети: webhook'и, импорт по ссылке, HTML-to-PDF, превью, обработка SVG/XML, интеграционные воркеры, headless-рендереры. Как только приложение получает право самому выбирать направление исходящего запроса по данным извне, вся дальнейшая история упирается уже в архитектуру. В то, как устроен egress, какие адреса видит workload, как обрабатываются редиректы, кто резолвит DNS, насколько аккуратно команда обращается с metadata и насколько широкие права висят на attached identity.Поэтому зрелость защиты от SSRF хорошо видна без долгих разговоров. Если в системе до сих пор живут denylist'ы на \localhost`, ручные проверки через
startswith(), автоматический переход по редиректам и полный сетевой доступ у сервисов с внешними URL, проблему просто отложили до следующего удобного входа. Если же у сервиса жёстко ограничены допустимые цели, metadata прикрыто или выключено, внешние fetcher'ы сидят в узком сетевом коридоре, а code review и статический анализ ловят новые сетевые sink'и ещё до продакшена, SSRF перестаёт быть дешёвым билетом во внутренний контур. И это уже другой уровень разговора - не про отдельный payload, а про дисциплину всей платформы.У этой темы есть неприятная, но полезная особенность: SSRF очень быстро вскрывает самообман в инфраструктуре. Он показывает, где команда верила строковой проверке вместо сетевой политики, где доверяла библиотеке вместо явной канонизации, где держала рядом с приложением слишком ценные внутренние сервисы и где выдала workload'у больше прав, чем ему действительно нужно. Наверное, именно поэтому SSRF так хорошо переживает смену стеков, фреймворков и облачных платформ. Меняются детали, меняются барьеры, а вопрос остаётся тем же - насколько вы вообще контролируете право приложения ходить по сети от своего имени.