У частині 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 сокетів і тримає):
| |
Важливий момент: create_connection на клієнті майже завжди «успішний» - TCP-рукостискання робить ядро, і навіть ті зʼєднання, які dnsdist потім не обслуговуватиме, спершу встановлюються. Тому скільки реально тримає сервер - дивимось на сервері, а не з боку клієнта.
Далі те саме, що в записі, тільки вже можна копіювати.
Спершу - без TCP-лімітів#
З ns2 відкриваємо 60 зʼєднань до ns1 і тримаємо:
| |
Поки зʼєднання тримаються, дивимось на ns1, скільки одночасних TCP він тримає з цього IP. showTCPStats() - широка таблиця (dnsdist підіймає кілька accept-потоків, тож на :53 кілька фронтендів), тож сумуємо «Max concurrent» по всіх:
| |
Один IP тримає 60 одночасних зʼєднань. Помножте на кількох атакувальників - і TCP-потоки сервера забиті, чесним клієнтам місця немає.
TCP-ліміти#
Додаємо в /etc/dnsdist/dnsdist.conf три рядки:
| |
Що робить кожен:
setMaxTCPConnectionsPerClient(5)- головний запобіжник. Один IP не отримає більше 5 одночасних TCP-зʼєднань; 6-те відкидається одразу. Connection flood з одного джерела впирається в стелю.setMaxTCPQueriesPerConnection(50)- одним зʼєднанням не можна слати нескінченно запитів (інакше ліміт на кількість конектів обходять, ганяючи все в одному довгому). Після 50 запитів зʼєднання закривається.setMaxTCPConnectionDuration(5)- зʼєднання живе щонайбільше 5 секунд. Саме це рубає slowloris: тримати конект годинами не вийде.
dnsdist і так закриває зʼєднання, яке нічого не шле, по внутрішньому таймауту читання - тож «класичний» slowloris частково мітигований з коробки. Але явні ліміти роблять поведінку передбачуваною й захищають від варіацій. Значення (5/50/5) - для демо занижені; під свій трафік підбирайте з запасом (великий резолвер за CGNAT може легітимно тримати більше).
| |
Та сама лавина - з лімітами#
| |
| |
Клієнт і далі пробує відкрити 60, але сервер тепер тримає з цього IP лише кілька (стеля - 5, яку ми задали; у виводі буває 5-6 через миттєвий слот на прийманні) замість 60 - решту dnsdist відкидає на вході. А легітимний клієнт по TCP працює як завжди:
| |
Коли цього мало: 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 тепер витримує не лише падіння нод, а й цілеспрямований флуд - з обох боків.



