↓ До тексту
  1. Статті/

dnsdist: захист від флуду TCP-зʼєднань

·5 хвилин
cat /etc/passwd
Автор
cat /etc/passwd
Технічний блог про Linux, DevOps та Open Source технології.
Зміст
Відмовостійкий DNS - частина серії статей.
Частина 5: ця стаття (ти тут)

У частині 4 ми захистилися від флуду запитів - обмежили qps на IP і додали динамічні блоки. Але є інший клас атаки, з яким rate-limit по запитах не впорається: флуд зʼєднань.

DNS відповідає не лише по UDP, а й по TCP (великі відповіді, зона-трансфер) - і саме по TCP можливі дві типові атаки:

  • connection flood - лавина нових TCP-зʼєднань, щоб забити accept-черги й потоки;
  • slowloris - відкрити багато зʼєднань і тримати їх напіввідкритими, повільно (або взагалі нічого не) віддаючи, щоб зайняти всі TCP-слоти й не пустити чесних клієнтів.

Запитів при цьому може бути обмаль - тож ліміт на qps мовчить. Потрібні окремі TCP-важелі dnsdist.

Що потрібно зробити?
#

Налаштувати в dnsdist ліміти на TCP-зʼєднання: скільки одночасних дозволено з одного IP, скільки запитів може йти одним зʼєднанням і скільки воно живе. А тоді - перевірити наживо: з сусідньої машини відкрити лавину TCP-зʼєднань до сервера й побачити, як ліміт ріже їх, а легітимний TCP-клієнт працює далі. Двопанельний запис: ліворуч ns1 (наш DNS), праворуч ns2 (відкриває зʼєднання).

З чим працюю?
#

Той самий стенд: ns1 - «захисник» (dnsdist :53 + pdns), ns2 відкриватиме до нього TCP-зʼєднання. Простий «тримач зʼєднань» на Python (відкриває N сокетів і тримає):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# holdconns.py - на ns2
import socket, sys, time
host, port, n, dur = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), float(sys.argv[4])
socks = []
for i in range(n):
    try:
        socks.append(socket.create_connection((host, port), timeout=2))
    except Exception:
        pass
print(f"пробуємо відкрити {n} TCP-зʼєднань до {host} (відкрито {len(socks)}), тримаємо {dur}с")
time.sleep(dur)
for s in socks:
    s.close()

Важливий момент: create_connection на клієнті майже завжди «успішний» - TCP-рукостискання робить ядро, і навіть ті зʼєднання, які dnsdist потім не обслуговуватиме, спершу встановлюються. Тому скільки реально тримає сервер - дивимось на сервері, а не з боку клієнта.

Далі те саме, що в записі, тільки вже можна копіювати.

Спершу - без TCP-лімітів
#

З ns2 відкриваємо 60 зʼєднань до ns1 і тримаємо:

1
2
3
# на ns2
python3 holdconns.py 10.10.60.103 53 60 6
# пробуємо відкрити 60 TCP-зʼєднань до 10.10.60.103 (відкрито 60), тримаємо 6.0с

Поки зʼєднання тримаються, дивимось на ns1, скільки одночасних TCP він тримає з цього IP. showTCPStats() - широка таблиця (dnsdist підіймає кілька accept-потоків, тож на :53 кілька фронтендів), тож сумуємо «Max concurrent» по всіх:

1
2
3
# на ns1
dnsdist -c -e 'showTCPStats()' | awk '/0.0.0.0:53/{s+=$4}END{print "одночасних TCP з одного IP: "s}'
# одночасних TCP з одного IP: 60

Один IP тримає 60 одночасних зʼєднань. Помножте на кількох атакувальників - і TCP-потоки сервера забиті, чесним клієнтам місця немає.

TCP-ліміти
#

Додаємо в /etc/dnsdist/dnsdist.conf три рядки:

1
2
3
4
-- ліміти TCP-зʼєднань
setMaxTCPConnectionsPerClient(5)   -- не більше 5 одночасних TCP-конектів з одного IP
setMaxTCPQueriesPerConnection(50)  -- одне зʼєднання - не більше 50 запитів
setMaxTCPConnectionDuration(5)     -- рубати зʼєднання, що висить довше 5 секунд

Що робить кожен:

  • setMaxTCPConnectionsPerClient(5) - головний запобіжник. Один IP не отримає більше 5 одночасних TCP-зʼєднань; 6-те відкидається одразу. Connection flood з одного джерела впирається в стелю.
  • setMaxTCPQueriesPerConnection(50) - одним зʼєднанням не можна слати нескінченно запитів (інакше ліміт на кількість конектів обходять, ганяючи все в одному довгому). Після 50 запитів зʼєднання закривається.
  • setMaxTCPConnectionDuration(5) - зʼєднання живе щонайбільше 5 секунд. Саме це рубає slowloris: тримати конект годинами не вийде.

dnsdist і так закриває зʼєднання, яке нічого не шле, по внутрішньому таймауту читання - тож «класичний» slowloris частково мітигований з коробки. Але явні ліміти роблять поведінку передбачуваною й захищають від варіацій. Значення (5/50/5) - для демо занижені; під свій трафік підбирайте з запасом (великий резолвер за CGNAT може легітимно тримати більше).

1
systemctl restart dnsdist

Та сама лавина - з лімітами
#

1
2
# на ns2 - та сама команда (знову пробує 60)
python3 holdconns.py 10.10.60.103 53 60 6
1
2
3
# на ns1
dnsdist -c -e 'showTCPStats()' | awk '/0.0.0.0:53/{s+=$4}END{print "одночасних TCP з одного IP: "s}'
# одночасних TCP з одного IP: 6

Клієнт і далі пробує відкрити 60, але сервер тепер тримає з цього IP лише кілька (стеля - 5, яку ми задали; у виводі буває 5-6 через миттєвий слот на прийманні) замість 60 - решту dnsdist відкидає на вході. А легітимний клієнт по TCP працює як завжди:

1
2
dig +tcp +short @127.0.0.1 www.example.com
# 203.0.113.20

Коли цього мало: eBPF
#

Ліміти вище добре тримають «звичайний» флуд з кількох джерел. Але якщо зловмисник сипле десятками тисяч нових зʼєднань за секунду з багатьох IP, навіть відкинути їх у застосунку - вже навантаження. Для таких обсягів dnsdist уміє скидати пакети на рівні ядра через eBPF/XDP (newBPFFilter, setDefaultBPFFilter) - IP у чорному списку не доходить навіть до accept. Це важча тема й окреме залізо/ядро - але знати, що стеля є куди вище, корисно.

Граблі
#

  • Два фронтенди на :53. dnsdist підіймає кілька TCP accept-потоків, тож showTCPStats() показує кілька фронтендів. Ліміт setMaxTCPConnectionsPerClient рахує суму на клієнта по всіх фронтендах, не на кожен окремо.
  • showTCPStats() широкий. Таблиця має десятки колонок (переважно TLS). У записі я тягну лише «Max concurrent» через awk /0.0.0.0:53/ - так читабельніше.
  • Idle-зʼєднання dnsdist закриває сам по таймауту читання - тому «порожній» slowloris (конект без жодного байта) живе недовго навіть без setMaxTCPConnectionDuration. Але явний ліміт тривалості закриває й ті, що шлють по байту, аби лише не відпускати.
  • Не заниж ліміти. setMaxTCPConnectionsPerClient замалий - і великий рекурсор за одним IP (CGNAT, корпоративний резолвер) впреться в стелю на ровному місці. Міряйте реальну кількість конектів на IP.

Що далі
#

Зʼєднання по TCP тепер теж під захистом: лавину конектів і slowloris ріжуть ліміти, а при потребі є eBPF. Наступний крок у надійності DNS - підписати зони DNSSEC (бекенд у нас уже з gmysql-dnssec=yes, лишилось згенерувати ключі й віддати DS реєстратору).

Підсумок
#

UDP і TCP-флуд ріжуться різними важелями: у частині 4 ми обмежували запити (qps, динамічні блоки), тут - зʼєднання (одночасні, запити на конект, тривалість). Разом вони закривають обидва шляхи перевантаження входу, а eBPF лишається запасом на екстремальні обсяги. Наш DNS тепер витримує не лише падіння нод, а й цілеспрямований флуд - з обох боків.

Відмовостійкий DNS - частина серії статей.
Частина 5: ця стаття (ти тут)

Related

dnsdist проти флуду: rate-limit і динамічні блоки

Резолвери заливають authoritative-сервер запитами, і pdns захлинається. Ставимо захист у dnsdist: ліміт на IP з форсуванням TCP проти спуфленого UDP і динамічні блоки, що тимчасово банять абʼюзера. Заливаємо сервер наживо з сусідньої машини - потік падає з 800 до ~100 запитів/с, а чесний клієнт не помічає. Частина серії про відмовостійкий authoritative DNS.

dnsdist: балансування і failover для PowerDNS

·6 хвилин
dnsdist перед кожним pdns: балансування на локальний бекенд, резервний сусід по приватній мережі, health-check і автоматичне виведення хворої ноди. Вимикаємо pdns наживо - запити далі резолвляться через сусіда. Плюс кеш пакетів. Двопанельний запис. Третя частина серії про відмовостійкий authoritative DNS.

PowerDNS: реплікація MariaDB між двома DNS-серверами

·6 хвилин
Другий DNS-сервер на репліці MariaDB: master-slave з GTID по окремій приватній мережі, pdns на репліці read-only. Зміна через API на майстрі миттєво на репліці. Живий двопанельний запис. Друга частина серії про відмовостійкий authoritative DNS.