Syncookied — OpenSource ddos protection system
Почему для разработки мы выбрали язык Rust и фреймворк NetMap, с какими сложностями мы столкнулись в процессе — будет рассказано в этой статье.
GitHub: github.com/LTD-Beget/syncookied
GitHub модуль ядра: github.com/LTD-Beget/tcpsecrets
Страница проекта: beget.com/ru/articles/syncookied
Принцип работы
Более подробную информацию о принципах работы TCP и методах защиты от DDOS атак уровня L4 можно прочесть на странице проекта. Но, в любом случае, описать схему работы и ее преимущества в рамках данной статьи необходимо.
Syncookied — является логичным продолжением развития технологии Syncookie. Технология Syncookie выносится из ядра операционной системы на отдельный сервер. Для этого мы написали модуль к ядру и демон, который общается с фаерволом, передает секретный ключ и синхронизирует метки времени, а также включает Syncookie на сервере в случае необходимости. То есть на защищаемый сервер устанавливается модуль ядра, который обманывает ядро заставляя его думть, что оно отправляло Syncookie, и создает файл /proc/tcp_secrets, и демон, который передает данные с этого файла на фаервол.
Принципиальная схема работы приведена ниже:
Пояснения:
- Роутер — маршрутизатор, обрабатывающий входящий трафик.
- Фаервол — сервер, на котором запущена система фильтрации.
- Защищаемый сервер — сервер, трафик на который необходимо фильтровать.
В обычном состоянии роутер и защищаемый сервер общаются напрямую, в случае обнаружения атаки на роутере прописывается статическая привязка IP адреса сервера к MAC адресу фаервола, после этого весь трафик, идущий на защищаемый сервер, идет на фаервол. Фаервол при получении SYN пакета отправляет в сеть SYN-ACK пакет от имени защищаемого сервера с IP адреса сервера и, что более важно, с валидной с точки зрения защищаемого сервера syncookie (так как у него есть секретный ключ и метка времени от защищаемого сервера), в случае получения ACK пакета (с кукой), фаервол проверяет его валидность, затем или меняет в пакете MAC адрес на MAC адрес защищаемого сервера и отправляет пакет обратно в сеть, или удаляет его, если кука не валидна. Защищаемый сервер, получая ACK пакет, открывает соединения и исходящие пакеты отправляет напрямую на роутер.
На сервере, так же как и в технологии SynProxy, ведется отслеживание соединений (об алгоритме работы можно прочитать более подробно на странице проекта), что позволяет фильтровать также другие невалидные пакеты — RST, DATA, SYN+ACK, ACK и так далее.
Основное отличие от аналогичных систем:
- Асинхронность фильтрации трафика — дает возможность устанавливать систему около пограничных маршрутизаторов, вне зависимости от пути обратного пакета. То есть в местах с максимальным размером канала.
- Syncookied не разрывает соединения после отключения защиты. Так как у пакетов, в отличие от технологии SynProxy, не меняются SEQ номера последовательностей. Само отключение заключается в удалении статической привязки MAC адреса к IP адресу защищаемого сервера на роутере, после чего трафик идет напрямую на защищаемый сервер без обработки его фаерволом.
- Открытость решения, масштабируемость и возможность работы на достаточно бюджетном железе без заметных задержек.
- Система написана на Rust с использованием assembler для ресурсоемких операций.
Есть возможность указывать дополнительные фильтры в конфигурации хоста, фильры пишутся в pcap формате.
Такая система фильтрации подходит не всем, так как из ее преимуществ вытекают и недостатки:
- Это не решение для установки на защищаемый сервер и не решение для защиты одного сервера
- Предполагается, что есть доступ к защищаемым серверам, и они работают под операционной системой Linux (для установки модуля ядра)
Реализация
Изначально система писалась на С++, после создания рабочего прототипа и проверки жизнеспособности идеи приняли решение переписать все на Rust. Отчасти из-за упрощения кода, отчасти из-за желания свести к минимуму риск выстрелить себе в ногу при работе с памятью. Так как все ресурсоемкие операции реализовывались на assembler, небольшая потеря производительности не была критичной. Для работы с сетевой картой был выбран фреймворк NetMap (на хабре есть несколько статей с описанием), как один из самых простых в работе и легких в изучении (как оказалось, не без проблем).
Архитектура программы
Количество выполняемых потоков привязано к количеству прерываний, генерируемых сетевой картой для входящих и исходящих очередей, между потоками для входящего и исходящего трафика создается две очереди — для пересылаемого трафика и трафика, на который необходимо ответить. Потоки для входящего трафика забирают пакеты с сетевой карты, анализируют их заголовки и на основании правил фильтрации делают одно из трех действий:
- удаляют пакет.
- отправляют пакет в очередь для пересылки.
- отправляют пакет в очередь для ответа.
Потоки, которые обрабатывают отправку пакетов в сеть, ждут сигнала от сетевой карты о появлении места в очереди для отправки пакетов, далее опрашивают очередь для пересылки пакетов и, если она пустая, опрашивают очередь для пакетов, ответ на которые необходим, создавая SYN+ACK ответ и отправляя его в сеть.
Минимальная задержка сделана для пересылаемых валидных пакетов, пакеты, на которые необходимо отвечать, идут с пониженным приоритетом.
Была идея реализовать отдельные процессы для обработки очереди пакетов требующих ответа, но, как показала практика, на современных процессорах и адекватных сетевых картах это не требуется, так как при максимальной теоретической нагрузке ядра не загружаются на 100%. Да и дополнительные очереди приведут к замедлению обработки.
Проблемы, с которыми мы столкнулись в netmap
Библиотека оказалась не такой идеальной, как мы о ней думали. Изначально в прототипе реализовывалась система фильтрации для локального сервера. Оказалось в NetMap для связи с сетевой подсистемой ядра используется только одна входящая и одна исходящая очередь, что создавало определенные сложности в плане производительности.
Для входящей очереди был реализован флаг NS_FORWARD для автоматической пересылки пакетов из NIC в HOST RING, для исходящей этого реализовано не было. Для TX очереди добавляется это достаточно просто:
diff --git a/sys/dev/netmap/netmap.c b/sys/dev/netmap/netmap.c
index c1a0733..2bf6a26 100644
--- a/sys/dev/netmap/netmap.c
+++ b/sys/dev/netmap/netmap.c
@@ -548,6 +548,9 @@ SYSEND;
NMG_LOCK_T netmap_global_lock;
+
+static inline void nm_sync_finalize(struct netmap_kring *kring);
+
/*
* mark the ring as stopped, and run through the locks
* to make sure other users get to see it.
@@ -1158,6 +1161,14 @@ netmap_sw_to_nic(struct netmap_adapter *na)
rdst->head = rdst->cur = nm_next(dst_head, dst_lim);
}
/* if (sent) XXX txsync ? */
+ if (sent) {
+ if (nm_txsync_prologue(kdst, rdst) >= kdst->nkr_num_slots) {
+ netmap_ring_reinit(kdst);
+ } else if (kdst->nm_sync(kdst, NAF_FORCE_RECLAIM) == 0) {
+ nm_sync_finalize(kdst);
+ }
+ printk(KERN_ERR "Synced %d packets to NIC ring %d", sent, i);
+ }
В дальнейшем от работы с HOST RING мы отказались.
Для тестирования мы использовали две карты от Intel — X520 и X710. Карта на базе X520 завелась без проблем, но в них под прерывание отведено всего 4 бита — в итоге максимум 16 прерываний, несмотря на то, что драйвера могут показывать и больше. В X710 под прерывание отведено уже 8 бит, есть виртуализация и много всего интересного. X710 пришлось прошивать, так как Intel не удосужилась добавить туда поддержку сторонних SPF+ через опцию модуля ядра, как это сделано в X520 картах:
insmod ./ixgbe/ixgbe.ko allow_unsupported_sfp=1
Карту прошивали по инструкции, представленной ниже:
- Скачать github.com/terpstra/xl710-unlocker.
- Подставить значения pci в mytool.c
- Скомпилить
- Сдампить ./mytool 0 0×8000
- Найти, где такое повторяется 4 раза:
00006870 + 00 => 000b 00006870 + 01 => 0022 = external SFP+ 00006870 + 02 => 0083 = int: SFI, 1000BASE-KX, SGMII (???) 00006870 + 03 => 1871 = ext: 0x70 = 10GBASE-{SFP+,LR,SR} 0x1801=crap? 00006870 + 04 => 0000 00006870 + 05 => 0000 00006870 + 06 => 3303 = SFP+ copper (passive, active), 10GBase-{SR,LR}, SFP 00006870 + 07 => 000b 00006870 + 08 => 2b0c *** This is the important register *** (0xb=bits 3+1+0 = enable qualification (3), pause TX+RX capable (1+0), 0xc = 3+2 = 10GbE + 1GbE) 00006870 + 09 => 0a00 00006870 + 0a => 0a1e = default LESM values 00006870 + 0b => 0003
- Впихнуть в mypoke.c оффсет и значение
- Скомпилить, запустить (оно долго висит, это видимо ок)
- Запустить mytool и посмотреть, что все ок изменилось
- Скачать downloadcenter.intel.com/download/24769/NVM-Update-Utility-for-Intel-Ethernet-Converged-Network-Adapter-XL710-and-X710-Series
- Запустить nvmupdate64e -u оттуда (тоже долго висит)
- Тут либо линк появился, либо вы убили карту =)
Более новая (чем в ядре) версия драйвера с e1000.sf.net с наложенным патчем от netmap:
github.com/polachok/i40e-netmap/tree/master
Также оставлю здесь набор магических костылей Наши тесты показали, что при следующих настройках достигается оптимальная производительность:
ядру передать iommu=off
netmap.ko no_timestamp=1
ixgbe.ko InterruptThrottleRate=9560,9560 RSS=12,12 DCA=2,2 allow_unsupported_sfp=1,1 VMDQ=0,0 AtrSampleRate=0,0 FdirPballoc=0,0 MDD=0,0
Проблемы, которые мы побороли в Rust
Rust, как язык программирования, избавил нас от большого количества сложностей и значительно ускорил разработку (если найдутся желающие переписать на С++ — это не составит труда), но добавил ряд специфичных проблем с производительностью, с которыми мы успешно боролись на протяжении месяца. За время разработки мы послали 15 пул реквестов, из которых 12 у нас приняли.
В первой реализации мы добились производительности 5М пакетов на 16 ядрах, примерно в это же время сотрудник Google Eric Dumazet написал в рассылку ядра о том, что все починил и ему удалось заставить ванильное ядро обрабатывать 6М пакетов. Как говорится:
Netmap generic driver
Изначально мы использовали generic driver, но он был не лучшим решением в плане производительности. При использовании generic driver пакет все равно попадает в сетевой стек ядра, что достаточно грустно, так как картина ksoftirqd с красным цветом в htop продолжается:
Решение — использовать нативные драйвера. Это специальные драйвера с добавленными хуками для netmap.
Более новая (чем в ядре) версия драйвера с e1000.sf.net с наложенным патчем от netmap:
github.com/polachok/i40e-netmap/tree/master
Использование Host Ring
В нативных драйверах мы столкнулись с новыми проблемами — они не работали. Пришлось разбираться в исходниках, читать документацию, и оказалось, что драйвера работают, не работает Host Ring. У Host Ring есть две проблемы — она не работает с нативными драйверами и у нее всего одна очередь, то есть если даже у сетевой будет 16 очередей, Host Ring будет иметь одну очередь. Это создает дополнительные проблемы синхронизации, блокировок. Мы нашли самое правильное решение в данной ситуации — не использовать Host Ring =), а отправлять пакеты обратно в сеть. В качестве альтернативы можно было использовать tap устройства, они поддерживают multiqueue, но эту возможность мы пока не реализовали.
Locks
После отказа от Host Ring и отказа от установки ПО на каждый сервер (как мы хотели изначально) выяснилось, что нам надо иметь поддержку нескольких защищаемых хостов в одном демоне, быструю перезагрузку конфига без потери состояний и прочие приятные мелочи. Попробовали сделать просто — взять глобальную структуру завернуть ее в mutex — производительность упала до 3М пакетов. После выяснения оказалось, что std: sync: Mutex из стандартной библиотеки Rust — это просто обертки над pthread мютексами и они работают достаточно медленно, так как на каждую блокировку приходится ходить в ядро, переключать контекст. Нашли в интернете пост, где люди из webkit сделали более производительные блокировки, в это же время, на основе этого поста была сделана библиотека parking_lot. В parking_lot блокировки организованы несколько по другому — они адаптивные, сначала они пытаются поспиниться (Spinlock) несколько раз и только потом идут в ядро, они меньше в размере, потому что список потоков, которые на нем заблокированы, хранится отдельно. Стало немного лучше, но не сильно.
Решили использовать Thread Local Storage. У каждого потока может быть собственное хранилище, в которое мы можем ходить без блокировок. Мы создали глобальную конфигурацию которая раз в 10 секунд копируется ее в Thread Local Storage. Получается практически полное отсутствие блокировок, они конечно есть, но разносятся по времени и фактически не заметны.
Channels
Выяснилось еще одна проблема: в Rust есть std: sync: mpsc (каналы) — они примерно такие же, как каналы в Go. Мы их используем для того, чтобы передавать пакетики из потоков, которые принимают, в потоки, которые отправляют. Фактически, канал — это массив за mutex. Слово mutex в контексте нашего рассказа — грустное слово. Мы взяли каналы и перенесли их на parking_lot:
github.com/polachok/mpsc.
Стало немного лучше. mpsc — это multiple producer single consumer, в нашем случае multiple producer не нужен, так как из одного RX пакет идет в один TX.
Нашли библиотеку с нужным нам функционалом bounded-spsc-queue — стало гораздо лучше, блокировки пропали — использовались только атомарные операции, но выяснилось, что происходит копирование — пришлось немного допилить библиотеку.
Построение пакета каждый раз
Следующая проблема, с которой мы столкнулись: в случае ответа пакет каждый раз собирается с нуля. Это отображалось красненьким в perf, и пришлось это оптимизировать единоразовой сборкой шаблона пакета, после его копирования заменяем в нем необходимые поля. Выигрыш производительности составил порядка 30%.
Из более или менее интересных pull реквестов можно отметить:
libpnet
github.com/libpnet/libpnet/pull/178 — поддержка протокола TCP
github.com/libpnet/libpnet/pull/181 — изменение пакета без аллокаций
github.com/libpnet/libpnet/pull/183 — чтение пакета без аллокаций
github.com/libpnet/libpnet/pull/187 — поддержка ARP
rust-lang
github.com/rust-lang/rust/pull/33891 — ускорение сравнения ip-адресов
concurrent-hash-map
github.com/AlisdairO/concurrent-hash-map/pull/4 — добавляет поддержку кастомных алгоритмов хэширования
bpfjit для Rust
github.com/polachok/bpfjit — биндинги JIT-компилятора pcap-фильтров для Rust
github.com/gobwas/influent.rs/pull/8
github.com/terminalcloud/rust-scheduler/pull/4
github.com/rust-lang/rust/pull/33891
github.com/libpnet/libpnet/pull/187
github.com/libpnet/libpnet/pull/183
github.com/libpnet/libpnet/pull/182
github.com/libpnet/libpnet/pull/181
github.com/libpnet/libpnet/pull/178
github.com/libpnet/netmap_sys/pull/10
github.com/libpnet/netmap_sys/pull/4
github.com/libpnet/netmap_sys/pull/3
github.com/AlisdairO/concurrent-hash-map/pull/4
github.com/ebfull/pcap/pull/56
github.com/polachok/xl710-unlocker/pull/1
Производительность
В отсутствие трафика syncookied для уменьшения задержек постоянно опрашивает сетевую карту, что создает небольшую нагрузку. Паразитная нагрузка уменьшается с увеличением количества входящих пакетов:
Нагрузка, создаваемая фильтрацией трафика — 12.755pps (теоретический предел при размере пакета 74 байта + 4 байта заголовок ethernet)
При фильтрации UDP или применении правил по портам/протоколам нагрузка будет неотличима от нагрузки в отсутствие трафика.
Фактически 12 ядер процессора Intel Xeon E5–2680v3 могут обрабатывать 10 гигабит трафика syn/ack/data флуда. Один физический сервер способен обрабатывать более 40 гигабит трафика.
TODO
На текущий момент мы почти закончили внедрение SynCookied на всей инфраструктуре нашего хостинга, отбили несколько не очень сильных атак и по возможности улучшаем продукт. В планах добавить защиту Out of Seq (с этим, я надеюсь, нам помогут коллеги), почти реализовали полноценный SynProxy, добавляем красивые метрики в Influx, думаем добавить режим Auto в реализацию защиты Syncooked (аналогично режиму net.ipv4.tcp_syncookies=1, кука посылается, когда приходит слишком много Syn, пакетов).
Заключение
Мы очень надеемся, что наша работа принесет пользу интернет сообществу.
Если бы компании по защите от DDOS выложили свои наработки в OpenSource, и каждый провайдер/администратор/клиент мог их использовать, проблема эксплуатации несовершенства сети Internet решилась бы гораздо быстрее.
Мы тестировали наш систему на синтетических тестах и слабых DDOS атаках (Как на зло ни одной нормальной атаки уже 4 месяца так и не прилетало). Для наших задач система получилась достаточно удобной. Хочется также заметить, что проект на Rust был реализован фактически одним человеком, очень талантливым программистом Поляковым Александром за короткий срок, после реализации прототипа на С++ и проверки идеи на работоспособность до появления рабочей версии прошло 5 недель.
Исходный код: github.com/LTD-Beget/syncookied
Исходный код модуля ядра: github.com/LTD-Beget/tcpsecrets
Страница проекта: beget.com/ru/articles/syncookied
Автор идеи и реализация прототипа: Маникин Алексей redfenix
Реализация варианта на Rust: Поляков Александр polachok
Создание инфраструктуры и тестирование: Лосев Максим mlosev