Перейти к основному содержанию
Блог

Веб

SSRF: когда сервер отправляет запросы от вашего имени

Загрузка картинки по URL, вебхуки и превью ссылок могут направить сервер во внутреннюю сеть. Как работает SSRF, 169.254.169.254, DNS rebinding, проверка и исправление.

· 10 мин. чтения

Фото: Raysonho @ Open Grid Scheduler / Grid Engine, CC0 · Wikimedia Commons

Server-Side Request Forgery (SSRF) — уязвимость, при которой атакующий заставляет ваш сервер отправить запрос на выбранный им адрес. Снаружи функция выглядит безобидно: «Загрузить картинку по URL», «Укажите адрес вебхука», «Предпросмотр ссылки». Но если сервер открывает переданный URL без проверки, атакующий может направить его не на внешний сайт, а на http://127.0.0.1:6379, внутреннюю админ-панель или сервис облачных метаданных.

Серьёзность проблемы в том, что сервер обычно находится в привилегированном месте сети: он видит внутренние сервисы, стоит за firewall, часто среди систем, настроенных по принципу «запросу изнутри можно доверять». SSRF передаёт это доверие атакующему.

Где появляется SSRF

Везде, где пользователь вводит URL или где URL косвенно доходит до сервера:

  • Импорт картинки или файла по URL — аватар, фото товара, документ.
  • Вебхуки — пользователь указывает свой адрес, сервер отправляет на него события.
  • Превью ссылок — когда в чат или пост вставляют ссылку, сервер получает заголовок и картинку.
  • Генерация PDF из HTML — генератор загружает на стороне сервера источники <img>, <link>, <iframe> из HTML.
  • Интеграции — «укажите адрес вашего API», импорт RSS, синхронизация iCal.
  • Обработка XML — если включены внешние сущности (XXE), запрос отправляет и парсер.
  • Прокси или «redirect»-эндпоинты — /fetch?url=..., /image-proxy?src=....

Чего может добиться атакующий

  1. Доступ к внутренним сервисам. Redis, Elasticsearch, Memcached на 127.0.0.1, панель управления или API, открытый только во внутреннюю сеть. Во многих внутренних сервисах аутентификации нет вовсе.
  2. Облачные метаданные. У облачных провайдеров по адресу http://169.254.169.254/ работает сервис метаданных экземпляра. Оттуда можно получить сведения об экземпляре, а при некоторых настройках — временные ключи доступа.
  3. Сканирование внутренней сети. По времени ответа и типам ошибок можно определить, какие внутренние адреса и порты открыты.
  4. Другие протоколы. Если HTTP-библиотека поддерживает схемы file://, gopher:// или dict://, становится возможным читать локальные файлы или отправлять сырые команды внутренним сервисам.
  5. «Маска» для внешних атак. Запросы уходят с IP-адреса вашего сервера.

Самый опасный случай — когда атакующий видит ответ (полный SSRF). Но даже если ответ не виден (blind SSRF), запросы, меняющие состояние внутренних сервисов, всё равно отправляются.

Почему это важно для экспертизы

SSRF выделен в OWASP Top 10 (2021) в отдельную категорию — A10 «Server-Side Request Forgery» — и классифицируется как CWE-918. OWASP ASVS также требует allowlist адресов для запросов, отправляемых сервером. На экспертизе каждое поле, принимающее URL, обычно проверяют внутренними адресами, адресом метаданных и альтернативными записями IP. Такая находка часто получает высокий уровень риска, потому что обходит внешний периметр.

Как проверить самостоятельно

1. Найдите места, принимающие URL

Поищите в кодовой базе вызовы, отправляющие внешние запросы:

# PHP / Laravel
grep -rnE "Http::(get|post|send)|file_get_contents\(|curl_init|fopen\(\s*\\\$|new Client\(" app/

# Node
grep -rnE "axios\.(get|post)|fetch\(|got\(|http\.request|https\.get" src/

Для каждого проследите, откуда приходит URL. Если от пользователя, из пользовательской настройки в базе или из ответа третьей стороны — это кандидат на SSRF.

2. Проверьте с контролируемым приёмником

Откройте адрес на своём сервере или в сервисе, фиксирующем тестовые запросы, и передайте его приложению. Если запрос пришёл — сервер действительно обращается наружу, и можно проверять внутренние адреса.

3. Проверьте внутренние адреса (только в своей тестовой среде)

http://127.0.0.1/
http://localhost:6379/
http://169.254.169.254/
http://[::1]/
http://0.0.0.0/
http://2130706433/          (десятичная запись 127.0.0.1)
http://127.1/
file:///etc/passwd

На уязвимость указывают разница во времени ответа, тексты ошибок (разница между «connection refused» и «timeout») или содержимое ответа.

4. Проверьте редиректы

Сделайте на своём сервере страницу, которая через 302 перенаправляет на http://127.0.0.1/. Если приложение проверяет только исходный URL, а затем автоматически следует редиректу, защита обходится.

Почему «простая проверка» не работает

Многие разработчики первым делом пишут так:

if (str_contains($url, 'localhost') || str_contains($url, '127.0.0.1')) {
    abort(422);
}

Это почти бесполезно. IP-адрес можно записать по-разному (127.1, 2130706433, 0x7f000001, [::ffff:127.0.0.1]), любой домен может резолвиться в 127.0.0.1, а редирект меняет адрес уже после проверки.

DNS rebinding

Даже резолв домена и проверка IP могут оказаться недостаточными. Атакующий управляет DNS-записью своего домена с очень коротким TTL: на первый запрос (вашу проверку) возвращает публичный IP, на второй (настоящее подключение) — 127.0.0.1. Адрес меняется между проверкой и подключением.

Решение — резолвить один раз, проверить IP и подключиться именно к этому IP (IP pinning).

Правильный вопрос против SSRF — не «что написано в URL?», а «к какому IP на самом деле подключается сервер?».

Как исправить

Общие принципы

  1. Allowlist, если возможно. Если приложению нужно обращаться только к нескольким известным сервисам, разрешите только их домены.
  2. Только http и https. Остальные схемы отклоняйте.
  3. Проверяйте все IP после резолва. Блокируйте частные (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), link-local (169.254/16, fe80::/10), 0.0.0.0/8, multicast и зарезервированные диапазоны.
  4. Закрепляйте IP и подключайтесь к проверенному IP.
  5. Отключите редиректы или проверяйте каждый заново.
  6. Ограничения: короткий таймаут, лимит размера ответа, ожидаемый Content-Type.
  7. Не возвращайте пользователю сырой ответ и текст ошибки.

Laravel

use Illuminate\Support\Facades\Http;

function safeFetch(string $url)
{
    $parts = parse_url($url);
    if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) {
        throw new InvalidArgumentException('Invalid URL');
    }

    $host = $parts['host'];
    $ips = gethostbynamel($host) ?: [];
    if (!$ips) {
        throw new InvalidArgumentException('Unresolvable host');
    }

    foreach ($ips as $ip) {
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            throw new InvalidArgumentException('Blocked address');
        }
    }

    $port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);

    return Http::timeout(5)
        ->withOptions([
            'allow_redirects' => false,
            'curl' => [
                CURLOPT_RESOLVE   => ["{$host}:{$port}:{$ips[0]}"],
                CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
            ],
        ])
        ->get($url);
}

CURLOPT_RESOLVE заставляет cURL подключаться к проверенному IP и закрывает DNS rebinding. gethostbynamel возвращает только IPv4; если нужен IPv6, проверяйте и записи AAAA через dns_get_record. В PHP 8.2+ есть также флаг FILTER_FLAG_GLOBAL_RANGE, пропускающий только глобальные адреса.

Node

import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
import axios from 'axios';
import http from 'node:http';
import https from 'node:https';

async function resolveSafe(hostname) {
  const records = await dns.lookup(hostname, { all: true });
  for (const { address } of records) {
    if (ipaddr.process(address).range() !== 'unicast') {
      throw new Error('Blocked address');
    }
  }
  return records[0];
}

export async function safeFetch(rawUrl) {
  const url = new URL(rawUrl);
  if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid scheme');

  const pinned = await resolveSafe(url.hostname);
  const lookup = (_h, opts, cb) =>
    opts?.all
      ? cb(null, [{ address: pinned.address, family: pinned.family }])
      : cb(null, pinned.address, pinned.family);
  const agentOpts = { lookup };

  return axios.get(url.href, {
    timeout: 5000,
    maxRedirects: 0,
    maxContentLength: 5 * 1024 * 1024,
    httpAgent: new http.Agent(agentOpts),
    httpsAgent: new https.Agent(agentOpts),
  });
}

В ipaddr.js метод range() возвращает unicast для публичных адресов интернета; private, loopback, linkLocal и прочие блокируются. Собственная функция lookup закрепляет подключение за проверенным IP.

На уровне сети и инфраструктуры

Защита в коде может ошибаться, поэтому нужен второй уровень:

  • Ограничьте исходящий трафик (egress). Вынесите процесс, загружающий внешние URL, в отдельный worker или контейнер и запретите ему через firewall выход во внутреннюю сеть.
  • Аутентификация и во внутренних сервисах. Пароль в Redis, токен во внутренних API. «Открыто только изнутри» — не защита.
  • Облачные метаданные. Если вы в облаке, включите версию сервиса метаданных, требующую токен (например, IMDSv2 в AWS), и дайте экземпляру роль с минимальными правами.
  • Отключите ненужные сервисы и убедитесь, что они слушают 127.0.0.1, а не 0.0.0.0. Подробнее: открытые порты и hardening сервера.

Отдельно для вебхуков

  • Проверяйте адрес вебхука и при сохранении, и при каждой отправке — DNS-запись может измениться позже.
  • Не показывайте пользователю тело ответа, фиксируйте только код статуса.
  • Подписывайте отправляемые запросы, чтобы получатель мог проверить их подлинность.

Короткий чек-лист

  • Составлен список всех функций, принимающих URL: импорт, вебхуки, превью, PDF, интеграции.
  • Разрешены только схемы http/https.
  • Все IP после резолва проверяются на частные, loopback, link-local и зарезервированные диапазоны.
  • Подключение закреплено за проверенным IP (DNS rebinding закрыт).
  • Редиректы отключены или каждый проверяется заново.
  • Ограничены таймаут, размер ответа и Content-Type.
  • Сырой ответ и детали ошибок не возвращаются пользователю.
  • Процесс, отправляющий внешние запросы, не может выйти во внутреннюю сеть.
  • Внутренние сервисы требуют аутентификации, сервис метаданных защищён.

Вывод

SSRF прячется внутри самых полезных функций продукта — импорта, вебхуков, превью. Его нельзя закрыть фильтрацией строк по чёрному списку: нужно резолвить IP, проверять его, закреплять и контролировать редиректы, а на уровне сети — ограничивать исходящий трафик. В начале проекта это несложно, но если находку сделает экспертиза, архитектуру придётся менять в спешке.

Хотите проверить функции, работающие с URL, до экспертизы? Оставьте заявку на сканирование — Claude AI агент найдёт в коде места, отправляющие внешние запросы, а наш специалист проверит их и поможет закрыть дыры.