← Назад в базу знаний

Почему IPv6-прокси «просвечивает» реальный адрес через Teredo/6to4: диагностика утечек на уровне сетевого стека

Прокси настроен, трафик идёт, а сайт всё равно видит твой настоящий адрес. Классика. Причём не просто видит — он видит его через туннель, о существовании которого ты забыл ещё в 2018-м. Разберём, откуда берётся утечка и как её поймать за хвост.

Как IPv6 ломает привычную модель прокси

В IPv4 всё просто: есть один маршрут по умолчанию, есть SOCKS5 или HTTP CONNECT, и если приложение уважает переменную `ALL_PROXY` — оно ходит через прокси. В IPv6 ситуация другая. У машины может быть одновременно глобальный адрес от провайдера, link-local на каждом интерфейсе, временный адрес privacy extensions и туннельный адрес через Teredo или 6to4. Приложение, которое резолвит `example.com` через `getaddrinfo()`, получает список из нескольких AAAA-записей и радостно пробует их по очереди — часто мимо прокси.

Ключевая проблема в том, что многие прокси-клиенты и библиотеки поддерживают только IPv4. SOCKS5 умеет ATYP=0x04 для IPv6, но не все реализации это знают. HTTP-прокси с CONNECT вообще не различает семейства — он просто туннелирует TCP, а вот что внутри туннеля решает клиент. Если клиент резолвит имя сам и получает AAAA, он может пойти напрямую по IPv6, минуя прокси, потому что «IPv6-маршрут же есть, зачем прокси».

Ещё хуже с Happy Eyeballs (RFC 8305). Браузер запускает гонку между IPv4 и IPv6 соединениями с задержкой 250 мс. Если IPv6-соединение устанавливается быстрее — оно выигрывает. А прокси-то настроен только для IPv4-соединений. Итог: половина запросов идёт через прокси, половина — напрямую, и в логах сервера ты видишь два разных адреса с одного клиента.

Teredo: туннель, о котором ты не просил

Teredo (RFC 4380) — механизм трансляции IPv6 через UDP поверх IPv4 NAT. Работает на порту UDP 3544. Идея простая: клиент за NAT инкапсулирует IPv6-пакеты в UDP-датаграммы и отправляет их на Teredo-сервер, который распаковывает и передаёт дальше. Адрес клиента кодируется в префиксе `2001:0000::/32`, причём в самом адресе зашиты публичный IPv4 и порт.

Вот тут и начинается веселье. Teredo-адрес вида `2001:0:xxxx:xxxx:yyyy:yyyy:zzzz:zzzz` содержит в последних 32 битах инвертированный публичный IPv4 клиента. Любой сервер, увидевший такой адрес в заголовке, может его распаковать и узнать, откуда ты на самом деле. Никакой прокси не поможет — утечка на уровне самого формата адреса.

На Windows Teredo включён по умолчанию в некоторых конфигурациях (особенно в доменных сетях с политиками). Проверить просто:

```powershell

netsh interface teredo show state

```

Если видишь `Type: client` и `State: qualified` — туннель активен. Отключить:

```powershell

netsh interface teredo set state disabled

```

На Linux Teredo обычно поднимается через `miredo`. Проверка:

```bash

ip -6 addr show | grep 2001:0

systemctl status miredo

```

Если сервис запущен — он будет перехватывать IPv6-трафик в обход любых прокси-настроек, потому что маршрутизация идёт через `teredo` интерфейс с более специфичным маршрутом, чем default.

6to4: ещё один способ слить адрес

6to4 (RFC 3056) работает иначе: берёт публичный IPv4, кодирует его в префикс `2002:VVVV:WWWW::/48`, где VVVV:WWWW — hex-представление IPv4. Трафик на этот префикс уходит через протокол 41 (IPv6-in-IPv4) напрямую на relay-серверы. Никакого UDP, никакого NAT traversal — только raw IP.

Утечка здесь ещё грубее. Если на машине есть 6to4-адрес и приложение резолвит AAAA-запись, оно может пойти по маршруту `2002::/16` мимо прокси. Адрес назначения при этом содержит твой IPv4 в открытом виде. Сервер видит источник `2002:c0a8:0101::1` — и это буквально `192.168.1.1`, только закодированный.

Проверить наличие 6to4:

```bash

ip -6 route show | grep 2002

ip tunnel show | grep sit

```

Стандартный `sit0` интерфейс на многих дистрибутивах поднимается автоматически. Убрать:

```bash

ip link set sit0 down

sysctl -w net.ipv6.conf.sit0.disable_ipv6=1

```

И прописать в `/etc/sysctl.d/99-no-tunnels.conf`:

```

net.ipv6.conf.sit0.disable_ipv6 = 1

net.ipv6.conf.teredo.disable_ipv6 = 1

net.ipv6.conf.all.disable_ipv6 = 0

```

Почему прокси-клиенты игнорируют IPv6

Разберём на примере `curl`. Он уважает `--proxy` и `ALL_PROXY`, но что делает при резолве? По умолчанию `curl` резолвит имя на стороне клиента, потом передаёт IP прокси. Для SOCKS5 с `--socks5-hostname` резолв уходит на прокси-сервер. Разница критична: в первом случае локальный резолвер может вернуть AAAA, и `curl` попытается соединиться по IPv6 напрямую, если SOCKS5-прокси не поддерживает ATYP=0x04.

Проверить, что реально уходит:

```bash

curl -6 -v --socks5-hostname 127.0.0.1:1080 https://ifconfig.co 2>&1 | grep -E "Trying|Connected"

```

Если видишь `Trying 2001:...` — соединение идёт по IPv6, и это уже подозрительно. Если SOCKS5-сервер не умеет IPv6, `curl` отвалится с ошибкой. Если умеет — трафик пойдёт через прокси, но только если прокси-сервер сам имеет IPv6-выход.

Питон-клиенты ведут себя ещё интереснее. `requests` с `proxies={'https': 'socks5h://...'}` требует `PySocks`, а тот в старых версиях (до 1.7.0) не поддерживал IPv6-адреса в SOCKS5. Проверка версии:

```python

import socks

print(socks.__version__)

```

Если меньше 1.7.0 — IPv6-цели просто не пройдут через прокси, а `urllib3` тихо откатится на прямое соединение. Это не баг, это «фича» с точки зрения библиотеки: лучше хоть как-то соединиться, чем никак. С точки зрения приватности — катастрофа.

Диагностика: как поймать утечку

Первый шаг — понять, какие IPv6-адреса вообще есть на машине и куда они маршрутизируются.

```bash

ip -6 addr show scope global

ip -6 route show default

ip -6 route get 2001:4860:4860::8888

```

Последняя команда покажет, через какой интерфейс и какой source-адрес уйдёт пакет к Google DNS. Если это не твой прокси-интерфейс и не loopback — утечка есть.

Второй шаг — проверить, что видит внешний сервер. Запускаем на удалённой машине простой эхо-сервер:

```python

import socket

s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)

s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

s.bind(('::', 8443))

s.listen(5)

while True:

conn, addr = s.accept()

print(f"peer: {addr[0]}", flush=True)

conn.close()

```

Клиент подключается через прокси, сервер логирует реальный peer-адрес. Если в логе `2001:0:...` или `2002:...` — прокси не работает для IPv6-маршрута. Если `::ffff:1.2.3.4` — утечка идёт по IPv4-mapped адресу, что тоже плохо.

Третий шаг — сравнить DNS-ответы. Локальный резолвер может возвращать AAAA, а прокси-сервер — нет. Тогда клиент попробует IPv6 напрямую, получит таймаут (если маршрута нет) и откатится на IPv4 через прокси. Но если маршрут есть — соединение установится в обход.

```bash

dig AAAA example.com +short

curl -s --socks5-hostname 127.0.0.1:1080 https://api64.ipify.org

curl -s https://api64.ipify.org

```

Второй и третий ответы должны совпадать. Если различаются — прокси не покрывает IPv6.

Реальный кейс: nginx за прокси, но с AAAA

Сервер на Ubuntu 22.04, nginx 1.24, приложение ходит в API через `proxy_pass`. В `nginx.conf` прописан `resolver 127.0.0.53` (systemd-resolved). Приложение резолвит `api.example.com` — получает и A, и AAAA. Nginx по умолчанию использует первый адрес из ответа, и если это AAAA, а IPv6-маршрут есть — соединение идёт напрямую, мимо корпоративного прокси.

Симптом: в логах API-сервера чередуются два IP — прокси и реальный. Причём реальный — это 6to4-адрес вида `2002:5db8:d822::1`, который легко расшифровывается в `93.184.216.34`. Причина: в `/etc/gai.conf` приоритет IPv6 выше IPv4 (`precedence ::ffff:0:0/96 100` отсутствует), плюс на интерфейсе висит `sit0` с автоподнятым туннелем.

Решение: отключить `sit0`, добавить в `gai.conf` строку `precedence ::ffff:0:0/96 100` (это заставит `getaddrinfo()` предпочитать IPv4), и в nginx прописать `resolver` с явным указанием `ipv6=off`:

```nginx

resolver 127.0.0.53 valid=30s ipv6=off;

set \$backend "api.example.com";

proxy_pass https://\$backend;

```

После правок — перезапуск `systemd-resolved` и `nginx -s reload`. Проверка: `ss -tnp | grep nginx` должен показывать только IPv4-соединения к прокси.

Кейс: Windows с Teredo в домене

Рабочая станция в корпоративном домене, групповые политики включают Teredo для «совместимости с legacy-приложениями». Пользователь настраивает SOCKS5-прокси в браузере, но половина запросов уходит напрямую. В логах прокси-сервера — пропуски, в логах целевого сайта — Teredo-адреса.

Причина: Teredo-интерфейс имеет маршрут `2001::/32` с метрикой ниже, чем default. Windows при резолве AAAA-записи выбирает Teredo-адрес как source, потому что он «глобальный». Браузер запускает Happy Eyeballs, IPv6-соединение через Teredo устанавливается за 40-60 мс (UDP до relay), IPv4 через прокси — за 80-120 мс. IPv6 выигрывает гонку.

Решение: `netsh interface teredo set state disabled` через GPO, либо явное отключение через реестр `HKLM\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\DisabledComponents = 0x20` (отключает только Teredo, оставляя остальной IPv6). После перезагрузки — проверка `netsh interface teredo show state` должна вернуть `State: offline`. Браузер перестаёт видеть Teredo-адрес, гонка Happy Eyeballs идёт между IPv4-прокси и обычным IPv6 (если он есть), и утечка закрывается.

Кейс: Docker-контейнер с IPv6 и хостовым прокси

Контейнер на Debian 12, `docker run --network host`, внутри приложение на Python 3.11 с `requests`. Прокси задан через `HTTPS_PROXY=http://172.17.0.1:3128`. Приложение ходит в API — и часть запросов идёт напрямую по IPv6 через `docker0`-интерфейс, потому что `requests` резолвит AAAA и `urllib3` не проверяет, покрывает ли прокси IPv6.

Причина: `urllib3` версии 1.26.x при наличии `HTTPS_PROXY` всё равно резолвит хост локально (для SNI и проверки сертификата), а потом соединяется с прокси. Но если в `NO_PROXY` случайно попадает `::/0` (бывает в корпоративных образах) — прокси игнорируется для IPv6-целей полностью. Плюс `requests` не умеет отключать IPv6-резолв без хаков.

Решение: явно ограничить семейство адресов через `urllib3.util.connection`:

```python

import socket

import urllib3.util.connection

def _allowed_gai_family():

return socket.AF_INET

urllib3.util.connection.allowed_gai_family = _allowed_gai_family

```

Плюс в `docker-compose.yml` прописать `sysctls: net.ipv6.conf.all.disable_ipv6=1` для контейнера. После этого `requests` резолвит только A-записи, и весь трафик гарантированно идёт через прокси. Проверка — `tcpdump -i docker0 -n ip6` на хосте должен показывать ноль пакетов от контейнера.

Что делает прокси-сервер, когда клиент всё же пролез

Даже если клиент отправил IPv6-запрос напрямую, прокси-сервер может его перехватить — если настроен transparent proxy на уровне ядра. Для этого нужен `TPROXY` в iptables/nftables и маршрутизация IPv6-трафика на локальный порт.

```bash

ip6tables -t mangle -N DIVERT

ip6tables -t mangle -A PREROUTING -p tcp -m socket -j DIVERT

ip6tables -t mangle -A DIVERT -j MARK --set-mark 1

ip6tables -t mangle -A DIVERT -j ACCEPT

ip6tables -t mangle -A PREROUTING -p tcp --dport 443 -j TPROXY \

--on-port 3129 --tproxy-mark 1

ip -6 rule add fwmark 1 lookup 100

ip -6 route add local default dev lo table 100

```

Это работает, но требует, чтобы клиент не использовал Happy Eyeballs с fallback на прямое соединение. Если прокси-сервер на `3129` не отвечает за 250 мс — браузер уйдёт напрямую. Так что таймауты должны быть агрессивными, а сервер — быстрым.

Именно на этом уровне удобно ставить фильтр: если в SNI или HTTP-заголовке `Host` видно, что клиент пришёл по IPv6-адресу, а не через прокси-пул — можно рвать соединение или подменять source. На практике проще не пускать IPv6-трафик вообще, кроме явно разрешённых префиксов.

Чек-лист диагностики

Соберём последовательность проверок в одном месте. Первое — `ip -6 addr show scope global`, второе — `ip -6 route get 2001:4860:4860::8888`, третье — сравнение ответов `curl` с прокси и без. Если хоть один шаг показывает расхождение — утечка есть.

Для автоматизации подойдёт скрипт, который раз в минуту проверяет внешний IP через прокси и напрямую, и алертит при расхождении:

```bash

!/bin/bash

PROXY_IP=\$(curl -s -6 --socks5-hostname 127.0.0.1:1080 https://api64.ipify.org)

DIRECT_IP=\$(curl -s -6 https://api64.ipify.org)

if [ "\$PROXY_IP" != "\$DIRECT_IP" ]; then

echo "LEAK: proxy=\$PROXY_IP direct=\$DIRECT_IP" | logger -t ipv6-leak

fi

```

Запускать через `systemd timer` каждые 60 секунд. Логи — в `journalctl -t ipv6-leak`. На практике хватает недели наблюдений, чтобы выловить все сценарии, где приложение обходит прокси.

Для тестовых стендов, где нужен предсказуемый IPv6-выход без Teredo и 6to4, использую пул адресов на lexic.ml — там маршрутизация настроена так, что туннельные префиксы `2001::/32` и `2002::/16` отфильтрованы на входе, и клиент физически не может утечь через них. Это не панацея, но убирает целый класс проблем, когда приложение резолвит AAAA и пытается идти в обход.

Итог

Утечка через Teredo и 6to4 — не баг прокси, а следствие того, что IPv6 живёт по другим правилам маршрутизации. Туннели поднимаются автоматически, адреса кодируют IPv4 в открытом виде, Happy Eyeballs обходит медленные прокси. Лечится это отключением туннелей на уровне ОС, принудительным IPv4-резолвом в клиентах и transparent-перехватом на прокси-сервере. Проверять надо не «работает ли прокси», а «видит ли целевой сервер мой настоящий адрес» — это разные вопросы, и второй задаётся сильно реже.

✔️Купить прокси