Четыре символа мимо SSRF-фильтра: зона IPv6 и netip.Prefix.Contains в Go

11 ноября опубликовали описание уязвимости в Gotenberg, популярном сервисе, который превращает HTML и офисные документы в PDF. Это CVE-2026-126799, она затрагивает версии с 8.31.0 по 8.37.0 и исправлена в 8.38.0. В ней нет ничего экзотического: защита от SSRF честно блокировала [::1], [::] и внутренние диапазоны IPv6, но пропускала тот же адрес, если к нему дописать %25 и любое имя сетевого интерфейса. Например, http://[::%251]:9999/ проходил проверку и открывал доступ к сервисам на localhost.

Самое неприятное, что Gotenberg тут ни при чём в смысле «плохого кода». Проверка написана аккуратно, на net/netip, и выглядит так же, как в десятках других проектов на Go: вебхуках, превью ссылок, импортах по URL, прокси для картинок. Поэтому я собрал маленький сервис с такой же проверкой и разобрал проблему по шагам: проблема → воспроизведение → исправление → проверка.

Все выводы ниже взяты из реального прогона на Go 1.26.11 и 1.27.4 (это свежие патч-релизы от 10 ноября) и Python 3.13.5.

Что такое зона в IPv6-адресе

У IPv6 есть link-local адреса fe80::/10. Они одинаковые на каждом интерфейсе, поэтому системе нужно подсказать, через какой интерфейс идти. Для этого к адресу дописывают зону: fe80::1%eth0 или fe80::1%2. В URL символ % нужно экранировать, поэтому получается http://[fe80::1%25eth0]/.

Go это поддерживает: url.Parse принимает зону в квадратных скобках, netip.ParseAddr сохраняет её внутри netip.Addr, а net.Dial спокойно подключается. Проблема в том, что для адреса не из fe80::/10 зона ничего не меняет в маршрутизации, зато меняет результат некоторых проверок.

Где ломается проверка

Типичная функция «можно ли ходить на этот адрес»:

var nonPublicIPv6 = []netip.Prefix{netip.MustParsePrefix("fec0::/10"), // site-local (устаревший)netip.MustParsePrefix("64:ff9b::/96"), // NAT64netip.MustParsePrefix("64:ff9b:1::/48"), // локальный NAT64netip.MustParsePrefix("2002::/16"), // 6to4netip.MustParsePrefix("2001::/32"), // Teredonetip.MustParsePrefix("::/96"), // IPv4-compatible}func isPublicVuln(addr netip.Addr) bool {addr = addr.Unmap()switch {case addr.IsLoopback(), addr.IsPrivate(), addr.IsLinkLocalUnicast(),addr.IsLinkLocalMulticast(), addr.IsMulticast(), addr.IsUnspecified(),addr.IsInterfaceLocalMulticast():return false}if addr.Is6() {for _, p := range nonPublicIPv6 {if p.Contains(addr) {return false}}}return true}

Тут две дыры, и обе связаны с зоной:

  1. netip.Prefix.Contains возвращает false для любого адреса с зоной. Это не баг, так прямо написано в документации к методу. Но из-за этого весь список запрещённых префиксов перестаёт работать.

  2. IsUnspecified() сравнивает адрес целиком, вместе с зоной, поэтому ::%1 для неё уже не «неуказанный адрес».

При этом IsLoopback, IsPrivate и IsLinkLocalUnicast смотрят на байты адреса и зону игнорируют. Получается, что половина проверок в одном switch устойчива к зоне, а половина нет.

Проверил это маленькой программой, которая прогоняет адреса через все методы:

site := netip.MustParsePrefix("fec0::/10")ula := netip.MustParsePrefix("fc00::/7")nat64 := netip.MustParsePrefix("64:ff9b::/96")for _, s := range []string{"fec0:1234::3", "fec0:1234::3%1", "::", "::%1","::1", "::1%1", "fd00::5", "fd00::5%eth0", "64:ff9b::7f00:1", "64:ff9b::7f00:1%x", "fe80::1%lo"} {a := netip.MustParseAddr(s)fmt.Printf("%-20s fec0::/10=%-5v fc00::/7=%-5v nat64=%-5v IsUnspecified=%-5v IsLoopback=%-5v IsPrivate=%-5v IsLinkLocal=%-5vn",s, site.Contains(a), ula.Contains(a), nat64.Contains(a),a.IsUnspecified(), a.IsLoopback(), a.IsPrivate(), a.IsLinkLocalUnicast())}
fec0:1234::3 fec0::/10=true fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=falsefec0:1234::3%1 fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=false:: fec0::/10=false fc00::/7=false nat64=false IsUnspecified=true IsLoopback=false IsPrivate=false IsLinkLocal=false::%1 fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=false::1 fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=true IsPrivate=false IsLinkLocal=false::1%1 fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=true IsPrivate=false IsLinkLocal=falsefd00::5 fec0::/10=false fc00::/7=true nat64=false IsUnspecified=false IsLoopback=false IsPrivate=true IsLinkLocal=falsefd00::5%eth0 fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=true IsLinkLocal=false64:ff9b::7f00:1 fec0::/10=false fc00::/7=false nat64=true IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=false64:ff9b::7f00:1%x fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=falsefe80::1%lo fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=true 

Вывод на 1.26.11 и 1.27.4 совпадает байт в байт, так что обновление Go здесь ничего не меняет: поведение задокументировано и останется таким.

Видно главное: fec0:1234::3 попадает в префикс, а fec0:1234::3%1 уже нет. :: распознаётся как неуказанный адрес, а ::%1 нет. Зато ::1%1 и fd00::5%eth0 по-прежнему ловятся, потому что их проверяют IsLoopback и IsPrivate.

Воспроизведение

Собрал два сервиса.

Внутренний сервис слушает только loopback и отдаёт «секрет»:

h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {fmt.Fprintf(w, "INTERNAL-TOKEN-7f3a (local=%s remote=%s)n",r.Context().Value(http.LocalAddrContextKey), r.RemoteAddr)})go func() { log.Fatal(http.ListenAndServe("127.0.0.1:9999", h)) }()log.Fatal(http.ListenAndServe("[::1]:9999", h))

Внешний сервис делает превью ссылки. Он принимает ?url=, разбирает хост, проверяет все его адреса функцией выше и только потом делает запрос:

func checkURL(raw string, isPublic func(netip.Addr) bool, rejectZone bool) error {u, err := url.Parse(raw)if err != nil {return err}if u.Scheme != "http" && u.Scheme != "https" {return errors.New("blocked: scheme")}host := u.Hostname()if addr, err := netip.ParseAddr(host); err == nil {if rejectZone && addr.Zone() != "" {return errors.New("blocked: IPv6 zone in URL")}if !isPublic(addr) {return errBlocked}return nil}addrs, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", host)if err != nil {return err}for _, a := range addrs {if !isPublic(a) {return errBlocked}}return nil}

Параметр rejectZone понадобится для исправления, в уязвимом режиме он выключен. Дальше скрипт attack.sh прогоняет набор адресов через /preview. Последняя строка нужна как контроль, что обычные внешние ссылки работают:

== mode=vulnhttp://127.0.0.1:9999/ blocked: non-public address [HTTP 403]http://localhost:9999/ blocked: non-public address [HTTP 403]http://[::1]:9999/ blocked: non-public address [HTTP 403]http://[::]:9999/ blocked: non-public address [HTTP 403]http://[::ffff:127.0.0.1]:9999/ blocked: non-public address [HTTP 403]http://[::1%25lo]:9999/ blocked: non-public address [HTTP 403]http://[::%251]:9999/ fetched 200: INTERNAL-TOKEN-7f3a (local=[::1]:9999 remote=[::1]:51568) [HTTP 200]http://[::%25lo]:9999/ fetched 200: INTERNAL-TOKEN-7f3a (local=[::1]:9999 remote=[::1]:51584) [HTTP 200]http://[::%25zzz]:9999/ fetched 200: INTERNAL-TOKEN-7f3a (local=[::1]:9999 remote=[::1]:51594) [HTTP 200]https://example.com/ fetched 200: <meta name=view

Все «обычные» варианты локального адреса блокируются, включая ::1%lo. А :: с любой зоной проходит, и сервис отдаёт наружу внутренний токен. Обратите внимание на %25zzz: интерфейса zzz в системе нет, но подключение всё равно состоялось. Для :: Linux просто идёт на ::1, зона ни на что не влияет.

Одна оговорка, чтобы не преувеличивать. Через [::] получается достучаться только до сервисов, которые слушают IPv6 loopback: ::1 или все адреса (:9999 в Go по умолчанию слушает и IPv4, и IPv6). Сервис, привязанный строго к 127.0.0.1, так не достать, я проверил это отдельно:

http://127.0.0.1:9998/ 200http://[::%251]:9998/ 000 Failed to connect to :: port 9998 after 0 ms: Could not connect to server

Для fec0::/10, NAT64 и 6to4 зона открывает уже не localhost, а внутреннюю сеть, если такие адреса в ней маршрутизируются. Своего NAT64 у меня нет, поэтому эту часть я проверил только на уровне функции: в тесте ниже старая проверка пропускает 64:ff9b::7f00:1%1 и 2002:7f00:1::%1, то есть адреса, за которыми по смыслу стоит 127.0.0.1.

Исправление

Шаг 1. Снять зону до классификации

Самое короткое исправление в одну строку:

func isPublicFixed(addr netip.Addr) bool {return isPublicVuln(addr.WithZone(""))}

WithZone("") убирает зону, и после этого Contains и IsUnspecified работают как надо. Внутри isPublicVuln уже есть Unmap() для адресов вида ::ffff:127.0.0.1, это то же самое по смыслу: привести адрес к каноническому виду до проверки.

Шаг 2. Не принимать зону в URL вообще

Зона имеет смысл только для link-local адресов, а их сервис превью ссылок всё равно блокирует. Значит, URL с зоной можно отклонять сразу, ещё до классификации. Это закрывает целый класс обходов, а не один случай. В checkURL выше это ветка rejectZone.

Шаг 3. Проверять адрес в момент подключения

Проверка URL до запроса всегда немного врёт: между проверкой и подключением DNS может ответить по-другому, а HTTP-клиент пойдёт по редиректу на другой хост. Поэтому вторую проверку стоит повесить на сам net.Dialer, туда, где известен настоящий адрес подключения:

d := &net.Dialer{Timeout: 2 * time.Second}d.Control = func(network, address string, _ syscall.RawConn) error {ap, err := netip.ParseAddrPort(address)if err != nil {return fmt.Errorf("blocked: cannot parse dial address %q", address)}if !isPublicFixed(ap.Addr()) {return fmt.Errorf("blocked at dial: %s", address)}return nil}client := &http.Client{Timeout: 3 * time.Second,Transport: &http.Transport{DialContext: d.DialContext, Proxy: nil},}

Control вызывается для каждой попытки подключения, в том числе после редиректов и для каждого адреса из DNS. Proxy: nil тут не случайно: если клиент ходит через прокси, Control увидит адрес прокси, а не цели, и проверка потеряет смысл.

Проверка

Чтобы понять, что даёт каждый шаг, сервис умеет работать в четырёх режимах: vuln (как было), zone (только шаг 1), guard (старая проверка URL плюс шаг 3) и fixed (все три шага). run_all.sh поднимает все четыре и прогоняет тот же набор адресов.

Только шаг 1:

== mode=zonehttp://[::1%25lo]:9999/ blocked: non-public address [HTTP 403]http://[::%251]:9999/ blocked: non-public address [HTTP 403]http://[::%25lo]:9999/ blocked: non-public address [HTTP 403]http://[::%25zzz]:9999/ blocked: non-public address [HTTP 403]https://example.com/ fetched 200: <meta name=view

Только проверка в Dialer.Control, при этом проверка URL осталась уязвимой:

== mode=guardhttp://[::%251]:9999/ fetch error: Get "http://[::%251]:9999/": dial tcp [::%1]:9999: blocked at dial: [::%1]:9999 [HTTP 502]http://[::%25lo]:9999/ fetch error: Get "http://[::%25lo]:9999/": dial tcp [::%lo]:9999: blocked at dial: [::%lo]:9999 [HTTP 502]http://[::%25zzz]:9999/ fetch error: Get "http://[::%25zzz]:9999/": dial tcp [::%zzz]:9999: blocked at dial: [::%zzz]:9999 [HTTP 502]https://example.com/ fetched 200: <meta name=view

Вторая линия обороны сработала сама по себе: первая проверка пропустила запрос, но подключение не состоялось. Видно и то, что в Control адрес приходит вместе с зоной ([::%1]:9999). Значит, если в этой функции забыть WithZone(""), получится та же дыра, только на другом уровне.

Всё вместе:

== mode=fixedhttp://127.0.0.1:9999/ blocked: non-public address [HTTP 403]http://localhost:9999/ blocked: non-public address [HTTP 403]http://[::1]:9999/ blocked: non-public address [HTTP 403]http://[::]:9999/ blocked: non-public address [HTTP 403]http://[::ffff:127.0.0.1]:9999/ blocked: non-public address [HTTP 403]http://[::1%25lo]:9999/ blocked: IPv6 zone in URL [HTTP 403]http://[::%251]:9999/ blocked: IPv6 zone in URL [HTTP 403]http://[::%25lo]:9999/ blocked: IPv6 zone in URL [HTTP 403]http://[::%25zzz]:9999/ blocked: IPv6 zone in URL [HTTP 403]https://example.com/ fetched 200: <meta name=view

Внутренний токен не утёк ни разу, а внешняя ссылка работает во всех режимах.

Чтобы это не вернулось при следующем рефакторинге, нужен тест. Он требует, чтобы новая проверка блокировала все адреса из списка, и заодно пишет в лог, что пропускала старая:

func TestZoneDoesNotBypass(t *testing.T) {blocked := []string{"::", "::%1", "::%lo", "::%zzz","::1", "::1%lo","fec0:1234::3", "fec0:1234::3%eth0","fd00::5", "fd00::5%eth0","64:ff9b::7f00:1", "64:ff9b::7f00:1%1","2002:7f00:1::", "2002:7f00:1::%1","fe80::1%lo","::ffff:127.0.0.1", "127.0.0.1", "10.0.0.1", "169.254.169.254",}for _, s := range blocked {a := netip.MustParseAddr(s)if isPublicFixed(a) {t.Errorf("isPublicFixed(%s) = true, want false", s)}if isPublicVuln(a) {t.Logf("старая проверка пропускает %s", s)}}for _, s := range []string{"2a00:1450:4001:82a::200e", "93.184.215.14"} {if !isPublicFixed(netip.MustParseAddr(s)) {t.Errorf("isPublicFixed(%s) = false, want true", s)}}}
=== RUN TestZoneDoesNotBypass filter_test.go:25: старая проверка пропускает ::%1 filter_test.go:25: старая проверка пропускает ::%lo filter_test.go:25: старая проверка пропускает ::%zzz filter_test.go:25: старая проверка пропускает fec0:1234::3%eth0 filter_test.go:25: старая проверка пропускает 64:ff9b::7f00:1%1 filter_test.go:25: старая проверка пропускает 2002:7f00:1::%1--- PASS: TestZoneDoesNotBypass (0.00s)PASS

Шесть адресов из списка старая проверка пропускала, новая блокирует все. fd00::5%eth0 старая проверка ловила и раньше, потому что его закрывает IsPrivate.

А что в Python

Для сравнения прогнал те же URL через urllib.parse и ipaddress:

nets = [ipaddress.ip_network(n) for n in ("fec0::/10", "64:ff9b::/96", "2002::/16")]for raw in ("http://[::%251]:9999/", "http://[::%25lo]:9999/", "http://[fec0:1234::3%25eth0]:8080/"): host = urlsplit(raw).hostname a = ipaddress.ip_address(host) print(f"{raw:36} host={host!r:22} scope_id={a.scope_id!r:8} " f"unspecified={a.is_unspecified!s:5} in_deny_nets={any(a in n for n in nets)!s:5} is_global={a.is_global}")
http://[::%251]:9999/ host='::%251' scope_id='251' unspecified=True in_deny_nets=False is_global=Falsehttp://[::%25lo]:9999/ host='::%25lo' scope_id='25lo' unspecified=True in_deny_nets=False is_global=Falsehttp://[fec0:1234::3%25eth0]:8080/ host='fec0:1234::3%25eth0' scope_id='25eth0' unspecified=False in_deny_nets=True is_global=True

Здесь именно этой дыры нет: ipaddress проверяет вхождение в сеть и is_unspecified без учёта зоны. Но есть две другие странности. Во-первых, urlsplit не раскодирует %25, и зона получается 25eth0 вместо eth0. Во-вторых, is_global для fec0:: возвращает True, так что фильтр «пускаем только is_global» этот диапазон пропустит. Так что и в Python зону лучше отклонять явно, а не надеяться на библиотеку.

Как найти это у себя

Быстрый поиск по Go-коду: файлы, где адреса проверяют через netip и Contains или IsUnspecified, но зону нигде не трогают. Это эвристика, она ничего не доказывает, но показывает, куда смотреть:

#!/usr/bin/env bashdir=${1:-.}rg -l --type go -e '.Contains(' -e 'IsUnspecified()' "$dir" | while read -r f; do rg -q 'netip' "$f" || continue if ! rg -q -e 'WithZone("")' -e '.Zone()' "$f"; then echo "ПРОВЕРИТЬ: $f" rg -n -e '.Contains(' -e 'IsUnspecified()' "$f" | sed 's/^/ /' fidone

На копии старого фильтра:

ПРОВЕРИТЬ: sample-old/filter.go 22:addr.IsLinkLocalMulticast(), addr.IsMulticast(), addr.IsUnspecified(), 28:if p.Contains(addr) {

На исправленном коде находок нет.

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

  1. Если вы используете Gotenberg с включённой защитой от приватных адресов, обновитесь как минимум до 8.38.0.

  2. В своём коде перед любыми проверками приводите адрес к одному виду: addr.Unmap().WithZone("").

  3. Отклоняйте URL с зоной в хосте, если вам не нужны link-local адреса. Скорее всего, не нужны.

  4. Повесьте проверку на net.Dialer.Control и не забудьте снять зону там тоже. Следите, чтобы клиент не ходил через прокси из переменных окружения.

  5. Добавьте в тесты адреса с зоной: ::%1, fec0::1%eth0, 64:ff9b::7f00:1%1. Для SSRF-фильтра стоит держать отдельный набор «странных» записей адреса и прогонять его на каждом изменении.

А у вас SSRF-фильтр как устроен: свой список диапазонов, готовая библиотека или отдельный прокси для исходящих запросов? И проверяли ли вы его на IPv6 вообще, или сервис живёт там, где IPv6 выключен, и считается, что это не про вас?

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев