← Усі статті

DevDays

Витік DNS, або будуємо велосипед

16 серпня 2026 р. · 6 хв читання

Під час тестування одного з інструментів я помітив дещо, що спершу пропустив повз увагу: перед відправкою запиту він сам резолвив DNS цільового хоста — напряму, в обхід проксі. Здавалось би, дрібниця, але саме через неї реальна адреса джерела потрапляла назовні.

Розповім конкретніше, як я до цього прийшов.
Задача була, просканувати видалений хост через whatweb. Без проксі запити до хосту блокувались. Задача типова і питань не викликала. Запустив із параметром --proxy і програма впала.

Whatweb витік

Перевірив проксі - працює.
Припустив, що DNS йде не через проксі. Tcpdump підтвердив мої думки.

вивід Tcpdump

Запити не загортались у проксі, а надсилались напряму у системний резолвер.
Першою думкою стало використати proxychains, але там картина точнісінько така ж.
Мені потребувалась проста але надійна логіка із вибором резолвера, а також важливо, щоб запити загортались у проксі. Звісно, можна було замінити резолвер на 8.8.8.8 але це не дало б бажаного проксіювання, хоч і тула б запрацювала.

В голові одразу з'явився принцип того, що мені потрібно. А саме, я зрозумів, що контролювати запити з середини може не вдаватись з різних причин, будь яка тула, якщо буде мати таке бажання - буде слати запити небезпечним шляхом (випадково у результаті збою або навіть навмисно), а значить потрібно обробляти запити зовні, брати вже сформовані запити від самої машини і загортати їх у проксі. Ідея не нова колись я про неї вже чув. А про запуск Kali через Whonix у якості шлюза читав ще років з 10 назад.

Отже пішов шукати готові проекти. Whonix у якості шлюза відкинув одразу, мені потрібні сотні прогрітих з'єднаннь, а не один лагаючий тор, не кажучи про його сумнівну безпеку. По тій же причині був виключений і Kalitorify.

Clash Meta / mihomo /Sing-box - вже більше схоже на те, що мені потрібно. Першою думкою було використовувати одного з них. Є вмонтований балансувальник, можуть підвантажувати списки з URL. Але вмонтованої системи ранжування мені здалось недостатньо, а керувати логікою не змінюючи ядро я не знайшов.

Отже мудруємо свою систему.

Схема маршрутизації в моїй голові виникла приблизно така:

Робоча VM ────► gateway

├─ TCP → redsocks → HAProxy → дозволений upstream → Internet
└─ DNS
├─ стандартний запит → DNS-over-HTTPS → resolver
└─ запит до конкретного резолвера → UDPtoTCP-dns → resolver

Окрім маршрутизації я б хотів автоматичний збір проксі з відкритих пулів, щоб не доводилось додавати і відсіювати їх вручну. Також, якщо я знайду десь додатковий пул то має бути простий механізм для його підключення у проект. Для цього має бути простий програмний інтерфейс для додавання логіки без перезапуску програми.

У якості операційної системи я вирішив використовувати Debian 12. Це рішення продиктоване необхідністю зручно лікувати баги перших версій. Debian 12 для цієї системи - цілком робочий і зручний варіант. Коли я буду вважати, що система достатньо стабільно працює, буду дивитись в сторону Flatcar/Bottlerocket. У перших версіях ціль зробити робочий прототип і мати зручний доступ до хостової системи для легкого аналізу логів.

Debian 12 в нашому випадку буде служити шлюзом для наших систем, яким потрібен анонімний доступ в інтернет. Навідміну від багатьох інших інструментів мій буде також обробляти й DNS-запити і проксувати їх. А отже ця система буде шлюзом і для DNS.

Саму схему проксування я поки що пропущу, детальніше розповім про неї пізніше.
В цій статті я детально розповім саме про маршрутизацію DNS.

Схема маршруту DNS

Для більшості запитів нам нема діла до якого саме резолверу звертатись. Під час серфінгу в інтернеті чи виконуючи більш прикладні задачі типу скрепінгу, зазвичай, я не задаю собі питання, хто саме повернув мені ip-адресу ресурсу. А отже можемо весь DNS-трафік відправляти на довірені резолвери. Для цього буду направляти такі пакету кастомному воркеру doh_dns.py, для шифрування і перенаправлення запитів на публічні резолвери.

Код для ознайомлення:

Python-код

  import http.client
  import os
  import socket
  import ssl

  SO_MARK = 36
  DNS_SOCKET_MARK = 0x53


  def doh_request(message: bytes, timeout: float) -> bytes:
      endpoint_ip = os.getenv("DNS_DOH_IP", "1.1.1.1")
      server_name = os.getenv(
          "DNS_DOH_SERVER_NAME",
          "cloudflare-dns.com",
      )

      # Маркуємо TCP socket для перенаправлення через redsocks.
      raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
      raw.setsockopt(
          socket.SOL_SOCKET,
          SO_MARK,
          DNS_SOCKET_MARK,
      )
      raw.settimeout(timeout)
      raw.connect((endpoint_ip, 443))

      tls = ssl.create_default_context().wrap_socket(
          raw,
          server_hostname=server_name,
      )

      try:
          request = (
              f"POST /dns-query HTTP/1.1\r\n"
              f"Host: {server_name}\r\n"
              "Content-Type: application/dns-message\r\n"
              "Accept: application/dns-message\r\n"
              f"Content-Length: {len(message)}\r\n"
              "Connection: close\r\n\r\n"
          ).encode() + message

          tls.sendall(request)

          response = http.client.HTTPResponse(tls)
          response.begin()
          body = response.read()

          if response.status != 200 or not body:
              raise OSError(
                  f"DoH HTTP status {response.status}"
              )

          return body
      finally:
          tls.close()

Але що, як потрібно опитати конкретний резолвер? Іноді буває така необхідність.

Іноді нам потрібно, щоб запит йшов до конкретного резолвера, система має розуміти такі запити і направляти їх окремим шляхом саме до потрібного резолвера (коралова гілка). Такі запити у https ми не загортаємо, адже не кожен резолвер підтримує шифрування. За цей напрямок віповідає інший воркер udp2tcp_dns.py який слухає порт 5353.

Повний код udp2tcp_dns.py приводити в статті повністю не буду, якщо буде бажання повивчати, запрошую до репозиторію.
Метод обміну пакетом з резолвером через TCP:

Python-код
async def tcp_exchange(payload: bytes, destination: tuple[str, int], timeout: float) -> bytes:
      if len(payload) > 65535:
          raise ValueError("DNS payload exceeds TCP framing limit")

      outbound = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
      outbound.setsockopt(socket.SOL_SOCKET, SO_MARK, DNS_SOCKET_MARK)
      outbound.setblocking(False)
      writer = None

      try:
          loop = asyncio.get_running_loop()
          await asyncio.wait_for(loop.sock_connect(outbound, destination), timeout)
          reader, writer = await asyncio.open_connection(sock=outbound)

          writer.write(struct.pack("!H", len(payload)) + payload)
          await asyncio.wait_for(writer.drain(), timeout)

          length = struct.unpack(
              "!H", await asyncio.wait_for(reader.readexactly(2), timeout)
          )[0]
          return await asyncio.wait_for(reader.readexactly(length), timeout)
      finally:
          if writer is not None:
              writer.close()
              await writer.wait_closed()
          else:
              outbound.close()

Для реалізації логіки було використано окремі ланцюжки iptables.
Виконується наступна логіка:

  • перехоплюємо пакети, що прийшли на порт 53
  • сортуємо по двом напрямкам порт 5053 і 5353
  • повертаємо правильному маршруту.

Правила iptables:

Bash-код
# Ланцюжки для двох DNS-сценаріїв
  iptables -t nat -N TG_DNS_DOH
  iptables -t mangle -N TG_DNS_TPROXY

  # Перехоплюємо лише DNS-трафік тестової підмережі
  iptables -t nat -A PREROUTING \
    -s "$CLIENT_CIDR" -p udp --dport 53 -j TG_DNS_DOH

  iptables -t mangle -A PREROUTING \
    -s "$CLIENT_CIDR" -p udp --dport 53 -j TG_DNS_TPROXY

  # Стандартні resolver-адреси: UDP DNS → локальний DoH relay
  for resolver in 1.1.1.1 1.0.0.1; do
    iptables -t nat -A TG_DNS_DOH \
      -p udp -d "$resolver" -j REDIRECT --to-ports 5053

    # Ці адреси не повинні потрапити до TPROXY-гілки
    iptables -t mangle -A TG_DNS_TPROXY \
      -d "$resolver" -j RETURN
  done

  # Усі інші UDP DNS-запити: TPROXY → UDP-to-TCP relay
  iptables -t mangle -A TG_DNS_TPROXY -p udp \
    -j TPROXY --on-ip 127.0.0.1 --on-port 5353 \
    --tproxy-mark 0x1/0x1

  # Доставка TPROXY-пакетів до локального socket
  ip rule add fwmark 0x1/0xff lookup 100 priority 100
  ip route replace local 0.0.0.0/0 dev lo table 100

Перевіряю, чи працюють запити через дефолтний резолвер:

Перевіряю, чи працюють запити на конкретні резолвери:

Мережеве перехоплення:

Показати код
  192.168.90.10.35001 > 8.8.8.8.53
  192.168.88.104.42200 > 146.255.185.194.15025: Flags [S]
  146.255.185.194.15025 > 192.168.88.104.42200: Flags [S.]
  192.168.88.104.42202 > 146.255.185.194.15025: Flags [S]
  146.255.185.194.15025 > 192.168.88.104.42202: Flags [S.]

Лог relay:

Показати код
  [udp2tcp-dns] tcp response 72 bytes from ('8.8.8.8', 53)
  [udp2tcp-dns] udp reply sent to ('192.168.90.10', 35001)

Реалізацію самого проксіювання опишу окремо в наступній статті. Тут я навмисно пропустив цей крок, щоб йти послідовно і не заплутувати лишньою інформацією.

Сам проект уже в готовому вигляді є на github https://github.com/Va1b0rt/TransparentGateway

Обговорення

Коментарі

Завантаження коментарів…

Щоб коментувати, увійдіть як учасник DevDays.