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

Перевірив проксі - працює.
Припустив, що DNS йде не через проксі. 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.
Для більшості запитів нам нема діла до якого саме резолверу звертатись. Під час серфінгу в інтернеті чи виконуючи більш прикладні задачі типу скрепінгу, зазвичай, я не задаю собі питання, хто саме повернув мені 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
Обговорення
Завантаження коментарів…