Почему IPv6-прокси «просвечивает» реальный адрес через Teredo/6to4: диагностика утечек на уровне сетевого стека
Содержание
- Как IPv6 ломает привычную модель прокси
- Teredo: туннель, о котором ты не просил
- 6to4: ещё один способ слить адрес
- Почему прокси-клиенты игнорируют IPv6
- Диагностика: как поймать утечку
- Реальный кейс: nginx за прокси, но с AAAA
- Кейс: Windows с Teredo в домене
- Кейс: Docker-контейнер с IPv6 и хостовым прокси
- Что делает прокси-сервер, когда клиент всё же пролез
- Чек-лист диагностики
- !/bin/bash
- Итог
Прокси настроен, трафик идёт, а сайт всё равно видит твой настоящий адрес. Классика. Причём не просто видит — он видит его через туннель, о существовании которого ты забыл ещё в 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-перехватом на прокси-сервере. Проверять надо не «работает ли прокси», а «видит ли целевой сервер мой настоящий адрес» — это разные вопросы, и второй задаётся сильно реже.