Пишемо захист від DDoS-атак на XDP. Ядерна частина
Технологія eXpress Data Path (XDP) дозволяє виконати довільну обробку трафіку на інтерфейсах Linux до того, як пакети надійдуть у стек ядра. Застосування XDP – захист від DDoS-атак (CloudFlare), складні фільтри, збирання статистики (Netflix). Програми XDP виконуються віртуальною машиною eBPF, тому мають обмеження як на свій код, так і доступні функції ядра в залежності від типу фільтра.
Стаття покликана заповнити недоліки численних матеріалів XDP. По-перше, у них дається готовий код, який одразу обходить особливості XDP: підготовлений для верифікації або занадто простий, щоб викликати проблеми. При спробі потім написати свій код з нуля немає розуміння, що робити з характерними помилками. По-друге, не висвітлюються способи локально тестувати XDP без ВМ та «заліза», при тому, що у них свої «підводні камені». Текст розрахований на програмістів, знайомих із мережами та Linux, яким цікавий XDP та eBPF.
У цій частині детально розберемося, як збирається XDP-фільтр та як його тестувати, потім напишемо простий варіант відомого механізму SYN cookies на рівні обробки пакетів. Поки не формуватимемо «білий список»
перевірених клієнтів, вести лічильники та керувати фільтром – вистачить логів.
Писати будемо на C - це не модно, проте практично. Весь код доступний на GitHub за посиланням наприкінці і розбитий на комміти за етапами, описаними у статті.
Відмова від відповідальності. У ході статті буде розроблятися міні-рішення для відображення від DDoS-атак, тому що це реалістичне завдання для XDP та моя область. Однак головна мета — розібратися з технологією, це не посібник із створення готового захисту. Навчальний код не оптимізовано та опускає деякі нюанси.
Короткий огляд XDP
Викладу лише ключові моменти, щоб не дублювати документацію та існуючі статті.
Отже, в ядро завантажується код фільтра. Фільтру передаються вхідні пакети. У результаті фільтр повинен ухвалити рішення: пропустити пакет в ядро (XDP_PASS), скинути пакет (XDP_DROP) або відправити його назад (XDP_TX). Фільтр може змінити пакет, це особливо актуально для XDP_TX. Також можна аварійно перервати програму (XDP_ABORTED) і скинути пакет, але це аналог assert(0) - Для налагодження.
Віртуальна машина eBPF (extended Berkley Packet Filter) спеціально зроблена простою, щоб ядро могло перевірити, що код не зациклюється і не ушкоджує чужу пам'ять. Сукупні обмеження та перевірки:
Заборонено цикли (переходи назад).
Є стек даних, але немає функцій (всі функції C повинні вбудовуватися).
Заборонені звернення до пам'яті за межами стека та буфера пакета.
Розмір коду обмежений, але на практиці це не дуже суттєво.
Дозволено виклики лише спеціальних функцій ядра (eBPF helpers).
Розробка та встановлення фільтра виглядають так:
Вихідний код (наприклад, kernel.c) компілюється в об'єктний (kernel.o) під архітектуру віртуальної машини eBPF. На жовтень 2019 року компіляція в eBPF підтримується Clang і обіцяна в GCC 10.1.
Якщо в цьому об'єктному коді є звернення до структур ядра (наприклад, таблиць і лічильників), замість їх ID стоять нулі, тобто виконати такий код не можна. Перед завантаженням у ядро потрібно ці нулі замінити на ID конкретних об'єктів, створених через виклики ядра (слінкувати код). Можна зробити це зовнішніми утилітами, а можна написати програму, яка лінкуватиме і завантажуватиме конкретний фільтр.
Ядро верифікує програму, що завантажується. Перевіряється відсутність циклів та невихід за межі пакета та стека. Якщо верифікатор не може довести, що код коректний, програма відкидається, — треба вміти задовольняти його.
Після успішної верифікації ядро компілює об'єктний код архітектури eBPF машинний код системної архітектури (just-in-time).
Програма прикріплюється до інтерфейсу та починає обробляти пакети.
Оскільки XDP працює в ядрі, налагодження ведеться за логами трасування і, власне, пакетами, які програма фільтрує або генерує. Тим не менш, eBPF забезпечує безпеку завантаженого коду для системи, тому експериментувати з XDP можна прямо на локальному Linux.
Підготовка оточення
збірка
Clang не може безпосередньо видавати об'єктний код для архітектури eBPF, тому процес складається з двох кроків:
Скомпілювати код на C в байт-код LLVM (clang -emit-llvm).
Перетворити байт-код на об'єктний код eBPF (llc -march=bpf -filetype=obj).
При написанні фільтра знадобиться пара файлів з допоміжними функціями та макросами із тестів ядра. Важливо, щоб вони відповідали версії ядра (KVER). Качаємо їх у helpers/:
KDIR містить шлях до заголовків ядра, ARCH - архітектуру системи. Шляхи та інструменти можуть дещо відрізнятися між дистрибутивами.
Приклад відмінностей для Debian 10 (ядро 4.19.67)
# другая команда
CLANG ?= clang
LLC ?= llc-7
# другой каталог
KDIR ?= /usr/src/linux-headers-$(shell uname -r)
ARCH ?= $(subst x86_64,x86,$(shell uname -m))
# два дополнительных каталога -I
CFLAGS =
-Ihelpers
-I/usr/src/linux-headers-4.19.0-6-common/include
-I/usr/src/linux-headers-4.19.0-6-common/arch/$(ARCH)/include
# далее без изменений
CFLAGS підключають директорію з допоміжними заголовками та кілька директорій із заголовками ядра. Символ __KERNEL__ означає, що заголовки UAPI (userspace API) визначаються коду ядра, оскільки фільтр виконується в ядрі.
Захист стека можна вимкнути (-fno-stack-protector), тому що верифікатор коду eBPF все одно перевіряє невихід за межі стека. Відразу варто включити оптимізацію, тому що розмір байт-коду eBPF обмежений.
Почнемо з фільтру, який пропускає всі пакети і нічого не робить:
Команда make збирає xdp_filter.o. Де його тепер випробувати?
Тестовий стенд
Стенд повинен включати два інтерфейси: на якому буде фільтр і з якого будуть надсилатися пакети. Це повинні бути повноцінні пристрої Linux зі своїми IP, щоб перевіряти, як звичайні програми працюють із нашим фільтром.
Пристрої типу veth (virtual Ethernet) нам підходять: це пара віртуальних мережевих інтерфейсів, «з'єднаних» між собою. Створити їх можна так (у цьому розділі всі команди ip виконуються від root):
ip link add xdp-remote type veth peer name xdp-local
Тут xdp-remote и xdp-local - Імена пристроїв. на xdp-local (192.0.2.1/24) буде приєднаний фільтр, з xdp-remote (192.0.2.2/24) відправлятиметься вхідний трафік. Однак є проблема: інтерфейси знаходяться на одній машині, і Linux не надсилатиме трафік на один з них через інший. Можна вирішувати це хитрими правилами iptablesАле їм доведеться міняти пакети, що незручно при налагодженні. Краще використовувати мережні простори імен (network namespaces, далі netns).
Мережевий простір імен містить набір інтерфейсів, таблиць маршрутизації та правил NetFilter ізольовані від аналогічних об'єктів в інших netns. Кожен процес працює в якомусь просторі імен, і йому доступні лише об'єкти цього netns. За промовчанням в системі єдиний мережний простір імен для всіх об'єктів, тому можна працювати в Linux і не знати про netns.
Створимо новий простір імен xdp-test і перемістимо туди xdp-remote.
ip netns add xdp-test
ip link set dev xdp-remote netns xdp-test
Тоді процес, що виконується в xdp-test, не буде «бачити» xdp-local (він залишиться в netns за замовчуванням) і при надсиланні пакета на 192.0.2.1 передасть його через xdp-remote, тому що це єдиний інтерфейс 192.0.2.0/24, доступний цьому процесу. Це діє і у зворотний бік.
При переміщенні між netns інтерфейс опускається і втрачає адресу. Щоб налаштувати інтерфейс у netns, потрібно запустити ip ... у цьому просторі командних імен ip netns exec:
ip netns exec xdp-test
ip address add 192.0.2.2/24 dev xdp-remote
ip netns exec xdp-test
ip link set xdp-remote up
Як можна бачити, це не відрізняється від налаштування xdp-local у просторі стандартних імен:
ip address add 192.0.2.1/24 dev xdp-local
ip link set xdp-local up
Якщо запустити tcpdump -tnevi xdp-local, можна побачити, що пакети, надіслані з xdp-test, доставляються на цей інтерфейс:
ip netns exec xdp-test ping 192.0.2.1
Зручно запустити шелл у xdp-test. У репозитарії є скрипт, який автоматизує роботу зі стендом, наприклад, можна налаштувати стенд командою sudo ./stand up і видалити його sudo ./stand down.
Трасування
Фільтр прив'язується до пристрою так:
ip -force link set dev xdp-local xdp object xdp_filter.o verbose
Ключ -force потрібний, щоб прив'язати нову програму, якщо інша вже прив'язана. "No news is good news" не про цю команду, висновок у будь-якому випадку об'ємний. Вказувати verbose необов'язково, але з ним з'являється звіт про роботу верифікатора коду з лістингом асемблера:
Verifier analysis:
0: (b7) r0 = 2
1: (95) exit
Відв'язати програму від інтерфейсу:
ip link set dev xdp-local xdp off
У скрипті це команди sudo ./stand attach и sudo ./stand detach.
Прив'язавши фільтр, можна переконатися, що ping продовжує працювати, але чи працює програма? Додамо логи. Функція bpf_trace_printk() схожа на printf(), але підтримує до трьох аргументів, крім шаблону, і обмежений список специфікаторів. Макрос bpf_printk() спрощує виклик.
З цієї причини налагоджувальний висновок сильно роздмухує підсумковий код.
Надсилання пакетів XDP
Змінимо фільтр: нехай він усі вхідні пакети відправляє назад. Це некоректно з мережевої точки зору, тому що потрібно було б змінювати адреси в заголовках, але зараз важливою є робота в принципі.
запускаємо tcpdump на xdp-remote. Він повинен показати ідентичні вихідні та вхідні ICMP Echo Request та перестати показувати ICMP Echo Reply. Але не вказує. Виявляється, для роботи XDP_TX у програмі на xdp-localнеобхіднодля парного інтерфейсу xdp-remote теж було призначено програму, хоча порожня, і його було піднято.
Як я про це дізнався?
Простежити шлях пакету у ядрі дозволяє механізм perf events, до речі, який використовує ту ж віртуальну машину, тобто для розбирання з eBPF застосовується eBPF.
Ти маєш зробити добро зі зла, тому що його більше нема з чого зробити.
Якщо замість цього показуються лише ARP, потрібно прибрати фільтри (це робить sudo ./stand detach), пустити pingпотім встановити фільтри і спробувати знову. Проблема в тому, що фільтр XDP_TX діє і на ARP, і якщо стек
простору імен xdp-test встигла «забути» MAC-адресу 192.0.2.1, вона не зможе дозволити цю IP.
Постановка завдання
Перейдемо до заявленої задачі: написати на XDP механізм SYN cookies.
Досі популярною DDoS-атакою залишається SYN flood, суть якої наступного. При установці з'єднання (TCP handshake) сервер отримує SYN, виділяє ресурси під майбутнє з'єднання, відповідає SYNACK-пакетом і чекає на ACK. Атакуючий просто відправляє SYN-пакети з підроблених адрес у кількості тисяч за секунду з кожного хоста з багатотисячного ботнету. Сервер змушений виділяти ресурси відразу після прибуття пакета, а звільняє по великому таймууту, в результаті вичерпується пам'ять або ліміти, нові з'єднання не приймаються, недоступний сервіс.
Якщо не виділяти по SYN-пакету ресурси, а лише відповідати SYNACK-пакетом, як тоді серверу зрозуміти, що ACK-пакет, який прийшов пізніше, відноситься до SYN-пакету, який не зберігали? Адже атакуючий може генерувати і фальшиві ACK. Суть SYN cookie в тому, щоб кодувати в seqnum параметри з'єднання як хеш від адрес, портів і мінливої солі. Якщо ACK встиг прийти до зміни солі, можна ще раз порахувати хеш і порівняти з acknum. Підробити acknum атакуючий не може, оскільки сіль містить секрет, а перебрати не встигне через обмежений канал.
SYN cookie давно реалізований в ядрі Linux і навіть може автоматично вмикатися, якщо SYN приходять дуже швидко і масово.
Лікнеп по TCP handshake
TCP забезпечує передачу даних як потоку байтів, наприклад поверх TCP передаються HTTP-запити. Потік передається шматочками в пакетах. Усі пакети TCP мають логічні прапори та 32-бітові номери послідовностей:
Комбінація прапорів визначає роль конкретного пакета. Прапор SYN означає, що це перший пакет відправника з'єднання. Прапор ACK означає, що відправник отримав усі дані з'єднання до байта acknum. Пакет може мати кілька прапорів і називається їх комбінації, наприклад, SYNACK-пакет.
Sequence number (seqnum) визначає зміщення в потоці даних першого байти, який передається в цьому пакеті. Наприклад, якщо у першому пакеті з X байтами даних цей номер був N, у наступному пакеті з новими даними він буде N+X. На початку з'єднання кожна сторона вибирає цей номер довільним чином.
Acknowledgement number (acknum) - таке ж зміщення, як seqnum, але визначає не номер байта, що передається, а номер першого байта від одержувача, якого відправник не бачив.
На початку з'єднання сторони повинні погодити seqnum и acknum. Клієнт відправляє SYN-пакет зі своїм seqnum = X. Сервер відповідає SYNACK-пакетом, куди записує свій seqnum = Y та виставляє acknum = X + 1. Клієнт на SYNACK відповідає ACK-пакетом, де seqnum = X + 1, acknum = Y + 1. Після цього починається власне передача даних.
Якщо співрозмовник не підтверджує отримання пакета, TCP відправляє його повторно по тайму.
Чому SYN cookie не використовуються завжди?
По-перше, якщо загубиться SYNACK або ACK, доведеться чекати повторного відправлення - уповільнюється встановлення з'єднання. По-друге, у SYN-пакеті — і лише в ньому! - Передається ряд опцій, що впливають на подальшу роботу з'єднання. Не запам'ятовуючи вхідні SYN-пакети, сервер таким чином ігнорує ці опції, в наступних пакетах клієнт не надішле їх. Працювати TCP при цьому може, але як мінімум на початковому етапі якість з'єднання зменшиться.
З точки зору пакетів, XDP-програма має робити таке:
на SYN відповідати SYNACK з cookie;
на ACK відповідати RST (розривати з'єднання);
решту пакетів скидати.
Псевдокод алгоритму разом із розбором пакета:
Если это не Ethernet,
пропустить пакет.
Если это не IPv4,
пропустить пакет.
Если адрес в таблице проверенных, (*)
уменьшить счетчик оставшихся проверок,
пропустить пакет.
Если это не TCP,
сбросить пакет. (**)
Если это SYN,
ответить SYN-ACK с cookie.
Если это ACK,
если в acknum лежит не cookie,
сбросить пакет.
Занести в таблицу адрес с N оставшихся проверок. (*)
Ответить RST. (**)
В остальных случаях сбросить пакет.
Однією (*) відмічені пункти, в яких потрібно керувати станом системи - на першому етапі можна обійтися без них, просто реалізувавши TCP handshake з генерацією SYN cookie як seqnum.
На місці (**), поки у нас немає таблиці, пропускатимемо пакет.
Реалізація TCP handshake
Розбір пакета та верифікація коду
Нам знадобляться структури мережевих заголовків: Ethernet (uapi/linux/if_ether.h), IPv4 (uapi/linux/ip.h) та TCP (uapi/linux/tcp.h). Останній у мене так і не вдалося підключити через помилки, пов'язані з atomic64_t, Довелося скопіювати потрібні визначення в код.
Всі функції, які C виділяються для зручності читання, повинні бути вбудовані за місцем виклику, так як верифікатор eBPF в ядрі забороняє переходи назад, тобто, фактично, цикли і виклики функцій.
Макрос LOG() відключає друк у релізному складанні.
Програма є конвеєром з функцій. Кожна приймає пакет, у якому виділено заголовок відповідного рівня, наприклад, process_ether() очікує, що заповнено ether. За результатами аналізу полів функція може передати пакет до рівня вище. Результат роботи функції – дія XDP. Поки обробники SYN та ACK пропускають усі пакети.
Ключовий рядок invalid access to packet, off=13 size=1, R7(id=0,off=0,r=0): є шляхи виконання, коли тринадцятий байт від початку буфера знаходиться поза пакетом. По лістингу важко зрозуміти, про який рядок йдеться, зате є номер інструкції (12) і дизассемблер, що показує рядки вихідного коду:
за якою зрозуміло, що проблема в ether. Завжди так.
Відповідь на SYN
Мета на цьому етапі – формувати коректний SYNACK-пакет з фіксованим seqnum, який у майбутньому заміниться SYN cookie. Всі зміни відбуваються в process_tcp_syn() та околицях.
Перевірка пакету
Як не дивно, ось найпримітніший рядок, точніше, коментар до нього:
При написанні першої версії коду використовувалося ядро 5.1, для верифікатора якого була різниця між data_end и (const void*)ctx->data_end. Під час написання статті ядро 5.3.1 не мало такої проблеми. Можливо, компілятор звертався до локальної змінної інакше, ніж поля. Мораль - на великій вкладеності спрощення коду може допомогти.
Далі рутинні перевірки довжин на славу верифікатора; про MAX_CSUM_BYTES нижче.
Змінюємо місцями порти TCP, адресу IP та MAC-адреси. Стандартна бібліотека недоступна з XDP-програми, тому memcpy() - макрос, що приховує інтринсік Clang.
Контрольні суми IPv4 і TCP вимагають складання всіх 16-бітних слів у заголовках, а розмір заголовків записаний у яких, тобто на момент компіляції невідомий. Це проблема тому, що верифікатор не пропустить звичайний цикл до змінного кордону. Проте розмір заголовків обмежений: до 64 байтів кожен. Можна зробити цикл із фіксованою кількістю ітерацій, який може закінчитися достроково.
Зауважу, що є RFC 1624 про те, як перераховувати контрольну суму частково, якщо змінено лише фіксовані слова пакетів. Однак спосіб не універсальний, а реалізацію було б складніше підтримувати.
Функція розрахунку контрольної суми:
#define MAX_CSUM_WORDS 32
#define MAX_CSUM_BYTES (MAX_CSUM_WORDS * 2)
INTERNAL u32
sum16(const void* data, u32 size, const void* data_end) {
u32 s = 0;
#pragma unroll
for (u32 i = 0; i < MAX_CSUM_WORDS; i++) {
if (2*i >= size) {
return s; /* normal exit */
}
if (data + 2*i + 1 + 1 > data_end) {
return 0; /* should be unreachable */
}
s += ((const u16*)data)[i];
}
return s;
}
Незважаючи на те що size перевірено зухвалим кодом, друга умова виходу необхідно, щоб верифікатор міг довести завершення циклу.
Для 32-бітних слів реалізована простіша версія:
INTERNAL u32
sum16_32(u32 v) {
return (v >> 16) + (v & 0xffff);
}
Власне перерахунок контрольних сум та відправлення пакета назад:
Функція carry() робить з 32-бітної суми 16-бітних слів контрольну суму згідно RFC 791.
Перевірка рукостискання TCP
Фільтр коректно встановлює з'єднання з netcat, Пропускаючи фінальний ACK, на який Linux відповідав RST-пакетом, так як мережевий стек не отримував SYN - він був перероблений в SYNACK і відправлений назад - і з точки зору ОС прибув пакет, що не відноситься до відкритих з'єднань.
$ sudo ip netns exec xdp-test nc -nv 192.0.2.1 6666
192.0.2.1 6666: Connection reset by peer
Важливо перевіряти саме повноцінними додатками та спостерігати tcpdump на xdp-remote тому що, наприклад, hping3 не реагує на некоректні контрольні суми.
SYN cookie
З погляду XDP сама перевірка тривіальна. Алгоритм розрахунку є примітивним і, ймовірно, вразливим для витонченого зловмисника. Ядро Linux, наприклад, використовує криптографічний SipHash, але його реалізація для XDP явно виходить за рамки статті.
З'явилося для нових TODO, пов'язаних із зовнішньою взаємодією:
XDP-програма не може зберігати cookie_seed (Секретну частину солі) у глобальній змінній, потрібно сховище в ядрі, значення в якому періодично оновлюватиметься з надійного генератора.
При збігу SYN cookie в ACK-пакеті потрібно друкувати повідомлення, а запам'ятовувати IP перевіреного клієнта, щоб далі пропускати пакети від нього.
Іноді eBPF взагалі і XDP, зокрема, представляється скоріше як інструмент просунутого адміністратора, ніж як платформа для розробки. Справді, XDP — інструмент втручання у обробку пакетів ядром, а чи не альтернатива ядерному стеку, як DPDK та інші варіанти kernel bypass. З іншого боку, XDP дозволяє реалізувати досить складну логіку, яку до того ж легко оновлювати без паузи в обробці трафіку. Верифікатор не створює великих проблем, особисто я не відмовився б від такого для частин userspace-коду.
У другій частині, якщо тема цікава, доробимо таблицю перевірених клієнтів та розрив з'єднань, впровадимо лічильники та напишемо userspace-утиліту для керування фільтром.