3. SSRF
Введение: Когда сервер становится прокси атакующего
В архитектуре распределенных систем существует классическая инженерная задача: серверу необходимо загрузить ресурс из внешнего мира по запросу пользователя. Типичные примеры — скачивание аватара по переданной ссылке, отправка Webhook-уведомления на сторонний адрес или валидация XML-схемы.
Если эта функциональность спроектирована наивно, сервис открывает прямую дорогу для уязвимости SSRF (Server-Side Request Forgery — подделка запроса на стороне сервера).
Суть проблемы кроется в концепции неявного доверия: сервер бэкенда физически находится внутри защищенного сетевого периметра компании (VPC, локальная сеть, приватный кластер Kubernetes). Внешний клиент не имеет прямого доступа к закрытым сервисам, однако он может заставить ваш Go-сервер сделать запрос от своего имени. Для внутренней сети этот исходящий сетевой пакет выглядит полностью легитимным.
flowchart TD
subgraph External["Внешний недоверенный периметр"]
A["Злоумышленник / Клиент"]
end
subgraph Host["Хост приложения (Go Runtime)"]
B["Go HTTP Handler"]
C["Парсинг URL<br/>и DNS-резолвинг"]
D["net.Dialer & syscall connect"]
E["Пакет net/http & crypto/tls"]
G["Ответ попадает в память процесса"]
end
subgraph Internal["Защищенная приватная инфраструктура"]
F1["Облачные метаданные<br/>(169.254.169.254, 100.100.100.200)"]
F2["Внутренние сервисы & K8s DNS<br/>(http://billing.internal)"]
F3["Локальные Unix-сокеты & localhost<br/>(127.0.0.1:6379 Redis)"]
end
A -->|"1. Вредоносный URL"| B
B --> C
C --> D
D --> E
E -->|"2. Отправка HTTP-запроса"| F1
E -.->|"Сканирование портов"| F2
E -.->|"Обращение к сервисам"| F3
F1 --> G
F2 -.-> G
F3 -.-> G
style External fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
style Host fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc
style Internal fill:#0f172a,stroke:#f43f5e,stroke-width:1px,color:#f8fafc
style A fill:#1e293b,stroke:#f43f5e,stroke-width:2px,color:#f8fafc
style D fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
style F1 fill:#1e293b,stroke:#f43f5e,stroke-width:2px,color:#f8fafc
Для разработчика на Go SSRF опасен не только доступом к внутренним API. Успешная эксплуатация позволяет:
- Прочитать метаданные облачных провайдеров (
169.254.169.254в AWS EC2/GCP,100.100.100.200в Yandex Cloud), мгновенно скомпрометировав временные IAM-токены и ключи доступа к инфраструктуре. - Сканировать внутренние порты и сервисы, составляя карту топологии сети предприятия.
- Вызвать отказ в обслуживании (DoS) через бесконечные редиректы, циклы или удержание открытых сетевых сокетов с таймаутами ядра.
- Получить доступ к локальным unix-сокетам или
localhostэндпоинтам (Redis, Memcached, Docker daemon), не требующим пароля в доверенной петле обратной связи.
Механика SSRF в рантайме Go: от резолвинга до syscall
В Go сетевые запросы обслуживаются высокоуровневым пакетом net/http и низкоуровневым примитивом net. Понимание того, как рантайм взаимодействует с ядром Linux, помогает осознать скрытые риски стандартных библиотечных абстракций.
1. Архитектура http.Client по умолчанию
Стандартный http.DefaultClient и вспомогательная функция http.Get исторически спроектированы для быстрого прототипирования: они не имеют таймаутов вообще и автоматически следуют за редиректами до 10 раз. В продакшене использование такого клиента для внешних ссылок — прямая дорога к аварии.
package handlers
import (
"net/http"
)
// ❌ Опасно: стандартный клиент без ограничений
func FetchURLBad(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("url")
// 🔴 Нет общего таймаута, авто-редиректы до 10 шагов, нет валидации адресата
resp, err := http.Get(target)
if err != nil {
http.Error(w, "failed to fetch url", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// ... обработка ответа
}
2. Под капотом: сетевой стек, планировщик и аллокации
Когда сервис вызывает http.Client.Get(url), рантайм Go выполняет сложный конвейер операций:
- Парсинг и резолвинг DNS:
net.DefaultResolver.LookupIPAddrвызывает либо системныйgetaddrinfo(через механизмcgo), либо использует встроенный pure-Go DNS-резолвер, читающий/etc/resolv.conf. Каждая операция поиска порождает аллокации в куче: срезы[]byteдля буферов сокетов, структурыIP,Addrи списки адресов. - Установление соединения (
syscall connect):
Методnet.Dialer.DialContextвыполняет системный вызовconnect. В ядре Linux при неблокирующем сокете этот вызов возвращаетEINPROGRESS, и рантайм Go передает файловый дескриптор в сетевой поллер (netpollна базеepoll). Горутина переходит в состояние ожиданияnetpoll, освобождая логический процессорPдля исполнения других задач. - TLS Handshake:
Если запрошен протоколhttps://, сетевой стек инициирует TLS-рукопожатие в пространстве пользователя (crypto/tls). Рантайм аллоцирует буферы под парсинг цепочек X.509 сертификатов, ключи Диффи-Хеллмана и сессионные шифротексты. - Чтение ответа:
Функцияhttp.ReadResponseбуферизует заголовки и тело. Если разработчик забывает ограничить размер читаемого ответа черезio.LimitReader, многогигабайтный ответ от вредоносного ресурса аллоцирует мегабайты памяти в куче, провоцирует взрывные циклыMinor GCи вытесняет процессорные кэш-линии L1/L2/L3.
Идиоматичная защита: транспорт, редиректы и валидация
Эффективная защита от SSRF в Go не может быть реализована простой строковой проверкой URL перед вызовом. Защита обязана быть эшелонированной: строгая валидация протокола, низкоуровневая фильтрация IP-адресов в DialContext после DNS-резолвинга, контроль редиректов и жесткие таймауты на каждом этапе.
package ssrf
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"time"
)
// NewSecureHTTPClient создаёт клиент, гарантированно защищённый от SSRF и зависаний
func NewSecureHTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second, // Таймаут на установление TCP-соединения
KeepAlive: 30 * time.Second, // Поддержание Keep-Alive
// DUALSTACK разрешает параллельный опрос IPv6 и IPv4
}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// 1. Извлекаем хост и порт из адреса назначения
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("split host port: %w", err)
}
// 2. Принудительно резолвим адрес перед вызовом системного сокета
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("dns lookup: %w", err)
}
// 3. 🔒 Проверяем каждый полученный IP-адрес на принадлежность к запрещенным диапазонам
for _, ip := range ips {
if isBlockedIP(ip.IP) {
return nil, fmt.Errorf("blocked IP range: %s", ip.IP.String())
}
}
// 4. Подключаемся напрямую к первому проверенному IP для защиты от DNS Rebinding
targetAddr := net.JoinHostPort(ips[0].IP.String(), port)
return dialer.DialContext(ctx, network, targetAddr)
},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Second, // 🔒 Жёсткий суммарный таймаут всего запроса
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("stopped after 5 redirects")
}
// 🔒 Повторная проверка URL перед каждым переходом по редиректу
if err := validateURL(req.URL.String()); err != nil {
return fmt.Errorf("redirect blocked: %w", err)
}
return nil
},
}
}
// isBlockedIP проверяет, находится ли адрес в приватных, локальных или метаданных диапазонах
func isBlockedIP(ip net.IP) bool {
// Петля обратной связи (127.0.0.0/8, ::1)
if ip.IsLoopback() {
return true
}
// Приватные подсети RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
if ip.IsPrivate() {
return true
}
// Канальные локальные адреса и облачные метаданные (169.254.0.0/16, fe80::/10)
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
// Нормализация IPv4-mapped IPv6 (например, ::ffff:127.0.0.1)
if ip4 := ip.To4(); ip4 != nil {
return isBlockedIP(ip4)
}
return false
}
// validateURL проводит базовую проверку допустимости сетевой схемы
func validateURL(rawURL string) error {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return fmt.Errorf("only HTTP/HTTPS allowed")
}
return nil
}
Подводные камни и векторы обхода
Реализация защиты от SSRF полна скрытых граблей, о которые регулярно спотыкаются разработчики:
- DNS Rebinding (Повторное связывание DNS):
Атакующий регистрирует собственный доменattacker.comс TTL = 0 секунд. При первой проверке в приложенииLookupIPAddrвозвращает публичный IP (например,1.2.3.4), и валидация успешно проходит. Но пока клиент инициирует TCP-соединение, DNS-сервер атакующего возвращает127.0.0.1или IP облачных метаданных.
Решение: Как показано в коде выше, после DNS-резолвинга вызывайтеnet.JoinHostPortс конкретным провереннымIP, а не с исходным доменным именем! Для полной изоляции в production используютhttp.Transport.TLSClientConfigс указанием ServerName для корректного TLS SNI, либо выносят исходящий сетевой трафик в отдельный egress-прокси (sidecar Envoy) с изолированным DNS-кэшем. - Экзотические форматы адресов:
Хакеры используют нестандартные формы записи IP-адресов, чтобы обмануть регулярные выражения:- Шестнадцатеричный или восьмеричный IP:
http://0177.0.0.1(восьмеричный 127.0.0.1). - Десятичный integer:
http://2130706433/(эквивалент 127.0.0.1). - IPv4-mapped IPv6:
http://[0:0:0:0:0:ffff:127.0.0.1]илиhttp://[::ffff:7f00:1]. - Сервисы разрешения wildcard-доменов:
http://127.0.0.1.nip.io. - Внедрение спецсимволов и нулевых байтов:
http://127.0.0.1%00.example.com.
Стандартные парсерыnet/urlиnetследуют строгим RFC, поэтому единственным надежным фильтром является преобразование строки черезnet.ParseIPи проверка свойств объектаnet.IP.
- Шестнадцатеричный или восьмеричный IP:
- Опасные локальные схемы и протоколы:
Попытки передачи URL видаfile:///etc/passwd,gopher://,dict://. Стандартныйhttp.Clientблокирует не-HTTP схемы, однако при использовании низкоуровневых оберток, кастомногоRoundTripperили системных утилит черезexec.Command(например,curlилиffmpeg) необходимо строго запрещать любые схемы, кромеhttpиhttps. - Повторное использование соединений (
Keep-Alive):
Пул соединенийhttp.Transportкэширует TCP-сокеты. Если злоумышленник заставляет сервис обращаться к контролируемому хосту, сервер может удерживать соединение открытым. В сценариях работы с ненадежными источниками опцияDisableKeepAlives: trueустраняет риск загрязнения пула ценой дополнительных затрат на установку TCP/TLS-сессий.
Итог
- Опасность стандартного клиента:
http.Clientв Go по умолчанию не имеет таймаутов и следует редиректам, что делает его уязвимым к SSRF и DoS без явной конфигурации. - Сетевой стек и системные вызовы: Сетевой стек рантайма (
net.Dialer,netpoll,epoll) асинхронно обрабатывает соединения, но отсутствие таймаутов приводит к исчерпанию файловых дескрипторов и утечке горутин. - Валидация на уровне транспорта: Валидация IP-адресов должна происходить в
DialContextпосле DNS-резолвинга, с обязательной проверкойIsLoopback,IsPrivate,IsLinkLocalUnicastи учётом IPv4-mapped IPv6 форматов. - Контроль редиректов: Редиректы требуют отдельной проверки (
CheckRedirect), так как атакующий может изменить целевой адрес после первоначальной валидации. - Эшелонированная архитектура: Защита от SSRF — это комбинация строгого
http.Transport, временных ограничений, пула соединений и многоуровневой валидации, встроенной в сетевой примитив, а не в бизнес-логику.
Как предотвратить выполнение вредоносных системных команд операционной системы, когда бэкенду необходимо взаимодействовать с утилитами хоста?
В следующей лекции мы разберем: 4. Command injection.