Інформаційна безпека

Як відкидати 10 мільйонів пакетів в секунду

У компанії нашу команду з протистояння DDoS-атакам називають «отбрасыватели пакетів» (the packet droppers — прим. пер). Поки всі інші команди роблять кльові штуки з проходження через нашу мережу трафіком, ми розважаємося пошуком нових способом позбутися від нього.


Фотографія: Brian Evans, CC BY-SA 2.0

Вміння швидко відкидати пакети дуже важливо в протистоянні DDoS-атакам.

Кожен спосіб має свої плюси і мінуси. Ми розглянемо все, що ми випробували.

Примітка перекладача: виведення деяких представлених команд були видалені зайві пробіли для збереження доступності.

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

Наші тести виглядатимуть наступним чином:

  • Ми створюємо навантаження з величезної кількості маленьких UDP-пакетів, досягаючи значення 14 мільйонів пакетів в секунду;
  • Весь цей трафік прямує на одне ядро процесора вибраного сервера;
  • Ми заміряємо кількість оброблених ядром пакетів на одному ядрі процесорі.

Штучний трафік генерується таким чином, щоб створити максимальне навантаження: використовуються випадкові IP-адресу і порт відправника. Ось приблизно так це виглядає в tcpdump:

$ tcpdump -ni vlan100 -з 10 -t udp and dst port 1234
IP 198.18.40.55.32059 > 198.18.0.12.1234: UDP, length 16
IP 198.18.51.16.30852 > 198.18.0.12.1234: UDP, length 16
IP 198.18.35.51.61823 > 198.18.0.12.1234: UDP, length 16
IP 198.18.44.42.30344 > 198.18.0.12.1234: UDP, length 16
IP 198.18.106.227.38592 > 198.18.0.12.1234: UDP, length 16
IP 198.18.48.67.19533 > 198.18.0.12.1234: UDP, length 16
IP 198.18.49.38.40566 > 198.18.0.12.1234: UDP, length 16
IP 198.18.50.73.22989 > 198.18.0.12.1234: UDP, length 16
IP 198.18.43.204.37895 > 198.18.0.12.1234: UDP, length 16
IP 198.18.104.128.1543 > 198.18.0.12.1234: UDP, length 16

На вибраному сервері всі пакети будуть ставати в одну RX-черга і, отже, оброблятися одним ядром. Ми досягаємо цього за допомогою апаратного управління потоком:

ethtool -N ext0 flow-type udp4 dst-ip 198.18.0.12 dst-port 1234 action 2

Тестування продуктивності — складний процес. Коли ми готували тести, ми помітили, що наявність активних raw-сокетів негативно впливає на продуктивність, тому перед запуском тестів необхідно впевнитися, що ні один tcpdump не запущений. Є простий спосіб перевірити наявність поганих процесів:

$ ss -A raw,packet_raw -l -p|cat
Netid State Recv-Q Send-Q Local Address:Port
p_raw UNCONN 525157 0 *:vlan100 users:(("tcpdump",pid=23683,fd=3))

Ну і нарешті ми відключаємо Intel Turbo Boost на нашому сервері:

echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo

Незважаючи на те, що Turbo Boost — прекрасна штука і збільшує пропускну здатність принаймні на 20%, він значно псує стандартне відхилення в наших тестах. З включеним turbo відхилення досягають ±1.5%, в той час як без нього всього 0.25%.

Крок 1. Відкидання пакетів в додатку

Почнемо з ідеї доставляти всі пакети в додаток і ігнорувати їх там. Для чесності експерименту переконаємося, що iptables ніяк не впливають на продуктивність:

iptables -I PREROUTING -t mangle -d 198.18.0.12 -p udp --dport 1234 -j ACCEPT
iptables -I PREROUTING -t raw -d 198.18.0.12 -p udp --dport 1234 -j ACCEPT
iptables -I INPUT -t filter -d 198.18.0.12 -p udp --dport 1234 -j ACCEPT

Додаток — простий цикл, в якому прийшли дані тут же викидаються:

s = socket.socket(AF_INET, SOCK_DGRAM)
s.bind(("0.0.0.0", 1234))
while True:
s.recvmmsg([...])

Ми вже підготували код, запускаємо:

$ ./dropping-packets/recvmmsg-loop
packets=171261 bytes=1940176

Таке рішення дозволяє ядру забирати всього 175 тисяч пакетів з черги апаратного забезпечення, як і було виміряно утилітами ethtool і нашої mmwatch:

$ mmwatch 'ethtool -S ext0|grep rx_2'
 rx2_packets: 174.0 k/s

Технічно, на сервер приходить 14 мільйонів пакетів в секунду, проте, одне ядро процесора не справляється з таким обсягом. mpstat підтверджує це:

$ watch 'mpstat -u -I SUM-P ALL 1 1|egrep -v Aver'
01:32:05 PM CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle
01:32:06 PM 0 0.00 0.00 0.00 2.94 0.00 3.92 0.00 0.00 0.00 93.14
01:32:06 PM 1 2.17 0.00 27.17 0.00 0.00 0.00 0.00 0.00 0.00 70.65
01:32:06 PM 2 0.00 0.00 0.00 0.00 0.00 100.00 0.00 0.00 0.00 0.00
01:32:06 PM 3 0.95 0.00 1.90 0.95 0.00 3.81 0.00 0.00 0.00 92.38

Як ми можемо бачити, додаток не є вузьким місцем: CPU#1 використовується на 27.17% + 2.17%, в той час як обробка переривань займає 100% на CPU#2.

Використання recvmessagge(2) грає важливу роль. Після виявлення уразливості Spectre системні виклики стали ще дорожчими через використовуються в ядрі KPTI і retpoline

$ tail -n +1 /sys/devices/system/cpu/vulnerabilities/*
==> /sys/devices/system/cpu/vulnerabilities/meltdown <==
Mitigation: PTI

==> /sys/devices/system/cpu/vulnerabilities/spectre_v1 <==
Mitigation: __user pointer sanitization

==> /sys/devices/system/cpu/vulnerabilities/spectre_v2 <==
Mitigation: Full generic retpoline, IBPB, IBRS_FW

 

Крок 2. Вбивство conntrack

Ми спеціально зробили таку навантаження з різними IP і порт відправника, щоб якомога сильніше навантажити conntrack. Кількість записів в conntrack під час тесту прагне до максимально можливого і ми можемо в цьому переконатися:

$ conntrack -C
2095202

$ sysctl net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_max = 2097152

Більш того, в dmesg так само можна побачити крики conntrack:

[4029612.456673] nf_conntrack: nf_conntrack: table full, dropping packet
[4029612.465787] nf_conntrack: nf_conntrack: table full, dropping packet
[4029617.175957] net_ratelimit: 5731 callbacks suppressed

Так відкиньмо його:

iptables -t raw -I PREROUTING -d 198.18.0.12 -p udp -m udp --dport 1234 -j NOTRACK

І перезапустим тести:

$ ./dropping-packets/recvmmsg-loop
packets=331008 bytes=5296128

Це дозволило нам дійти до позначки в 333 тисячі пакетів в секунду. Ура!
P. S. З використанням SO_BUSY_POLL ми можемо досягти цілих 470 тисяч в секунду, проте, це тема для окремого посту.

Крок 3. Пакетний фільтр Берклі

Йдемо далі. Навіщо нам доставляти пакети в додаток? Хоча це не є поширеним рішенням, ми можемо прив’язати класичний пакетний фільтр Берклі до сокета викликом setsockopt(SO_ATTACH_FILTER) та налаштувати фільтр відкидати пакети ще в ядрі.
Підготуємо код, запускаємо:

$ ./bpf-drop
packets=0 bytes=0

З використанням пакетного фільтра (класичний і розширений фільтри Берклі дають приблизно схожу продуктивність) ми добираємося до приблизно 512 тисяч пакетів в секунду. Більш того, відкидання пакета під час переривання звільняє процесор від необхідності будити додаток.

Крок 4. iptables DROP після маршрутизації

Тепер ми можемо відкидати пакети, додавши в iptables в ланцюжок INPUT таке правило:

iptables -I INPUT -d 198.18.0.12 -p udp --dport 1234 -j DROP

Нагадаю, що ми вже відключили conntrack правилом -j NOTRACK. Ці два правила дають нам 608 тисяч пакетів в секунду.

Подивимося на числа в iptables:

$ mmwatch 'iptables -L -v -n -x | head'
Chain INPUT (policy DROP 0 packets, 0 bytes)
 pkts bytes target prot opt out in source destination
605.9 k/s 26.7 m/s DROP udp -- * * 0.0.0.0/0 198.18.0.12 udp dpt:1234

Ну що ж, непогано, але ми можемо краще.

Крок 5. iptabes DROP в PREROUTING

Більш швидка техніка — відкидати пакети ще до маршрутизації з допомогою такого правила:

iptables -I PREROUTING -t raw -d 198.18.0.12 -p udp --dport 1234 -j DROP

Це дозволяє нам відкидати солідні 1.688 мільйона пакетів в секунду.

Насправді, це трохи дивний стрибок в продуктивності. Я так і не зрозумів причин, можливо наша маршрутизація складна, а може просто баг в конфігурації сервера.

У будь-якому випадку, «сирі» iptables працюють значно швидше.

Крок 6. nftables DROP

Зараз утиліта iptables вже трохи стара. Їй на зміну прийшла nftables. Ознайомтеся з цим відео-поясненням, чому nftables — топ. Nftables обіцяється бути швидше, ніж посивіла iptables по безлічі причин, серед яких слух, що retpoline’и сильно уповільнюють iptables.

Але наша стаття все ж не про порівняння iptables і nftables, так що давайте просто спробуємо найшвидше, що я зміг зробити:

nft add table netdev filter
nft -- add chain netdev filter input { type filter hook ingress device vlan100 priority -500 ; policy accept ; }
nft add rule netdev filter input ip daddr 198.18.0.0/24 udp dport 1234 counter drop
nft add rule netdev filter input ip6 daddr fd00::/64 udp dport 1234 counter drop

Лічильники можна побачити так:

$ mmwatch 'nft --handle list chain netdev filter input'
table netdev filter {
 chain input {
 type filter hook ingress device vlan100 priority -500; policy accept;
 ip daddr 198.18.0.0/24 udp dport 1234 counter packets 1.6 m/s bytes 69.6 m/s drop # handle 2
 ip6 daddr fd00::/64 udp dport 1234 counter packets 0 bytes 0 drop # handle 3
}
}

Вхідний хук nftables показав значення близько 1.53 мільйони пакетів. Це трохи менше, ніж PREROUTING ланцюжок в iptables. Але в цьому є і загадка: теоретично, хук nftables йде раніше, ніж PREROUTING iptables і, отже, повинен оброблятися швидше.

У нашому тесті nftables трохи повільніше ніж iptables, але все одно nftables крутіше. 😛

Крок 7. tc DROP

Дещо несподівано, що tc (traffic control) хук відбувається раніше, ніж iptables PREROUTING. tc дозволяє нам відбирати пакети за простими критеріями і, звісно ж, відкидати їх. Синтаксис трохи незвичайний, тому для налаштування пропонуємо використовувати цей скрипт. А нам потрібно досить складне правило, яке виглядає так:

tc qdisc add dev vlan100 ingress
tc filter add dev vlan100 parent ffff: пріоритет 4 protocol ip u32 match ip protocol 17 0xff match ip dport 1234 0xffff match ip dst 198.18.0.0/24 flowid 1:1 action drop
tc filter add dev vlan100 parent ffff: protocol ipv6 u32 match ip6 dport 1234 0xffff match ip6 dst fd00::/64 flowid 1:1 action drop

І ми можемо перевірити його в дії:

$ mmwatch 'tc -s filter show dev vlan100 ingress'
filter parent ffff: protocol ip pref 4 u32 
filter parent ffff: protocol ip pref 4 u32 fh 800: ht divisor 1 
filter parent ffff: protocol ip pref 4 u32 fh 800::800 order 2048 key ht 800 bkt 0 flowid 1:1 (rule hit 1.8 m/s success 1.8 m/s)
 match 00110000/00ff0000 at 8 (success 1.8 m/s ) 
 match 000004d2/0000ffff at 20 (success 1.8 m/s ) 
 match c612000c/ffffffff at 16 (success 1.8 m/s ) 
 action order 1: gact action drop
 random type none pass val 0
 index 1 ref 1 bind 1 installed 1.0/s sec
 Action statistics:
 Sent 79.7 m/s bytes 1.8 m/s pkt (dropped 1.8 m/s, overlimits 0 requeues 0) 

Хук tc дозволив нам відкидати до 1.8 мільйонів пакетів в секунду на одному ядрі. Це чудово!
Але ми можемо ще швидше…

Крок 8. XDP_DROP

І нарешті, наша найсильніша зброя: XDP — eXpress Data Path. C допомогою XDP ми можемо запускати код розширеного пакетного фільтра Берклі (extended Berkley Packet Filterm eBPF) прямо в контексті мережевого драйвера і, що найважливіше, ще до виділення пам’яті під skbuff, що обіцяє нам приріст в швидкості.

Зазвичай XDP-проект складається з двох частин:

  • завантажуваний код eBPF
  • завантажувач, який поміщає код в потрібний мережний інтерфейс

Написання свого завантажувача — складне заняття, тому просто скористаємося новою фішкою iproute2 і завантажимо код простою командою:

ip link set dev ext0 xdp obj xdp-drop-ebpf.o

Та-дам!

Вихідний код завантажується eBPF-програми доступний тут. Програма дивиться на такі характеристики IP-пакетів, як UDP-протокол, підмережа відправника і порт призначення:

if (h_proto == htons(ETH_P_IP)) {
 if (iph->protocol == IPPROTO_UDP
 && (htonl(iph->daddr) & 0xFFFFFF00) == 0xC6120000 // 198.18.0.0/24
 && udph->dest == htons(1234)) {
 return XDP_DROP;
}
}

XDP-програма повинна бути зібрана за допомогою сучасного clang, який вміє генерувати BPF-байткод. Після цього ми можемо завантажити і перевірити працездатність BFP-програми:

$ ip link show dev ext0
4: ext0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp qdisc fq state UP mode DEFAULT group default qlen 1000
 link/ether 24:8a:07:8a:59:8e brd ff:ff:ff:ff:ff:ff
 prog/xdp id 5 tag aedc195cc0471f51 jited

А після подивитися статистику в ethtool:

$ mmwatch 'ethtool -S ext0|egrep "rx"|egrep -v ": 0"|egrep -v "cache|csum"'
 rx_out_of_buffer: 4.4 m/s
 rx_xdp_drop: 10.1 m/s
 rx2_xdp_drop: 10.1 m/s

Ю-ху! З допомогою XDP ми можемо відкидати до 10 мільйонів пакетів за секунду!


Фотографія: Andrew Filer, CC BY-SA 2.0

Висновки

Ми повторили експеримент для IPv4 і для IPv6 і підготували цю діаграму:


У загальному випадку можна стверджувати, що наша налаштування для IPv6 трохи повільніше. Але так як пакети IPv6 дещо більше, то і різниця у швидкодії очікувана.

У Linux є безліч способів фільтрувати пакети, кожен зі своїми швидкодією і складністю налаштування.

Для захисту від DDoS цілком розумно віддавати пакети в додаток і обробляти їх там. Добре налаштоване програма може показувати хороші результати.

Для DDoS-атак з випадковим або подмененным IP може бути корисно відключати conntrack, щоб отримати невеликий приріст у швидкості, однак обережно: існують атаки, проти яких conntrack дуже корисний.

В інших випадках є сенс додати firewall linux’а як один із способів пом’якшення DDoS-атаки. В деяких випадках краще користуватися таблицею “-t raw PREROUTING”, так як вона значно швидше, ніж таблиця filter.

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


Якщо ви хочете повторити експеримент, то ось вам README, в якому ми всі задокументровали.

Ми в CloudFlare використовуємо… майже всі з цих технік. Деякі трюки в просторі користувача інтегровані в наші додатки. Техніка з iptables зустрічається в нашому Gatebot. Ну і нарешті ми замінюємо наш власне рішення в ядрі на XDP.

Велике спасибі Jesper Dangaard Brouer за допомогу в роботі.

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close