Пишемо захист від 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).

Розробка та встановлення фільтра виглядають так:

  1. Вихідний код (наприклад, kernel.c) компілюється в об'єктний (kernel.o) під архітектуру віртуальної машини eBPF. На жовтень 2019 року компіляція в eBPF підтримується Clang і обіцяна в GCC 10.1.
  2. Якщо в цьому об'єктному коді є звернення до структур ядра (наприклад, таблиць і лічильників), замість їх ID стоять нулі, тобто виконати такий код не можна. Перед завантаженням у ядро ​​потрібно ці нулі замінити на ID конкретних об'єктів, створених через виклики ядра (слінкувати код). Можна зробити це зовнішніми утилітами, а можна написати програму, яка лінкуватиме і завантажуватиме конкретний фільтр.
  3. Ядро верифікує програму, що завантажується. Перевіряється відсутність циклів та невихід за межі пакета та стека. Якщо верифікатор не може довести, що код коректний, програма відкидається, — треба вміти задовольняти його.
  4. Після успішної верифікації ядро ​​компілює об'єктний код архітектури eBPF машинний код системної архітектури (just-in-time).
  5. Програма прикріплюється до інтерфейсу та починає обробляти пакети.

Оскільки XDP працює в ядрі, налагодження ведеться за логами трасування і, власне, пакетами, які програма фільтрує або генерує. Тим не менш, eBPF забезпечує безпеку завантаженого коду для системи, тому експериментувати з XDP можна прямо на локальному Linux.

Підготовка оточення

збірка

Clang не може безпосередньо видавати об'єктний код для архітектури eBPF, тому процес складається з двох кроків:

  1. Скомпілювати код на C в байт-код LLVM (clang -emit-llvm).
  2. Перетворити байт-код на об'єктний код eBPF (llc -march=bpf -filetype=obj).

При написанні фільтра знадобиться пара файлів з допоміжними функціями та макросами із тестів ядра. Важливо, щоб вони відповідали версії ядра (KVER). Качаємо їх у helpers/:

export KVER=v5.3.7
export BASE=https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/plain/tools/testing/selftests/bpf
wget -P helpers --content-disposition "${BASE}/bpf_helpers.h?h=${KVER}" "${BASE}/bpf_endian.h?h=${KVER}"
unset KVER BASE

Makefile для Arch Linux (ядро 5.3.7):

CLANG ?= clang
LLC ?= llc

KDIR ?= /lib/modules/$(shell uname -r)/build
ARCH ?= $(subst x86_64,x86,$(shell uname -m))

CFLAGS = 
    -Ihelpers 
    
    -I$(KDIR)/include 
    -I$(KDIR)/include/uapi 
    -I$(KDIR)/include/generated/uapi 
    -I$(KDIR)/arch/$(ARCH)/include 
    -I$(KDIR)/arch/$(ARCH)/include/generated 
    -I$(KDIR)/arch/$(ARCH)/include/uapi 
    -I$(KDIR)/arch/$(ARCH)/include/generated/uapi 
    -D__KERNEL__ 
    
    -fno-stack-protector -O2 -g

xdp_%.o: xdp_%.c Makefile
    $(CLANG) -c -emit-llvm $(CFLAGS) $< -o - | 
    $(LLC) -march=bpf -filetype=obj -o $@

.PHONY: all clean

all: xdp_filter.o

clean:
    rm -f ./*.o

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 обмежений.

Почнемо з фільтру, який пропускає всі пакети і нічого не робить:

#include <uapi/linux/bpf.h>

#include <bpf_helpers.h>

SEC("prog")
int xdp_main(struct xdp_md* ctx) {
    return XDP_PASS;
}

char _license[] SEC("license") = "GPL";

Команда 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() спрощує виклик.

   SEC("prog")
   int xdp_main(struct xdp_md* ctx) {
+      bpf_printk("got packet: %pn", ctx);
       return XDP_PASS;
   }

Виведення йде в канал трасування ядра, який потрібно включити:

echo -n 1 | sudo tee /sys/kernel/debug/tracing/options/trace_printk

Перегляд потоку повідомлень:

cat /sys/kernel/debug/tracing/trace_pipe

Обидві ці команди робить виклик sudo ./stand log.

Ping тепер має викликати в ньому такі повідомлення:

<...>-110930 [004] ..s1 78803.244967: 0: got packet: 00000000ac510377

Якщо придивитися до висновку верифікатора, можна побачити дивні обчислення:

0: (bf) r3 = r1
1: (18) r1 = 0xa7025203a7465
3: (7b) *(u64 *)(r10 -8) = r1
4: (18) r1 = 0x6b63617020746f67
6: (7b) *(u64 *)(r10 -16) = r1
7: (bf) r1 = r10
8: (07) r1 += -16
9: (b7) r2 = 16
10: (85) call bpf_trace_printk#6
<...>

Справа в тому, що програми на eBPF не мають секції даних, тому єдиний спосіб закодувати форматний рядок — immediate-аргументи команд ВМ:

$ python -c "import binascii; print(bytes(reversed(binascii.unhexlify('0a7025203a74656b63617020746f67'))))"
b'got packet: %pn'

З цієї причини налагоджувальний висновок сильно роздмухує підсумковий код.

Надсилання пакетів XDP

Змінимо фільтр: нехай він усі вхідні пакети відправляє назад. Це некоректно з мережевої точки зору, тому що потрібно було б змінювати адреси в заголовках, але зараз важливою є робота в принципі.

       bpf_printk("got packet: %pn", ctx);
-      return XDP_PASS;
+      return XDP_TX;
   }

запускаємо tcpdump на xdp-remote. Він повинен показати ідентичні вихідні та вхідні ICMP Echo Request та перестати показувати ICMP Echo Reply. Але не вказує. Виявляється, для роботи XDP_TX у програмі на xdp-local необхіднодля парного інтерфейсу xdp-remote теж було призначено програму, хоча порожня, і його було піднято.

Як я про це дізнався?

Простежити шлях пакету у ядрі дозволяє механізм perf events, до речі, який використовує ту ж віртуальну машину, тобто для розбирання з eBPF застосовується eBPF.

Ти маєш зробити добро зі зла, тому що його більше нема з чого зробити.

$ sudo perf trace --call-graph dwarf -e 'xdp:*'
   0.000 ping/123455 xdp:xdp_bulk_tx:ifindex=19 action=TX sent=0 drops=1 err=-6
                                     veth_xdp_flush_bq ([veth])
                                     veth_xdp_flush_bq ([veth])
                                     veth_poll ([veth])
                                     <...>

Що таке код 6?

$ errno 6
ENXIO 6 No such device or address

Функція veth_xdp_flush_bq() отримує код помилки від veth_xdp_xmit(), де пошуком по ENXIO та знаходимо коментар.

Відновимо мінімальний фільтр (XDP_PASS) у файлі xdp_dummy.c, додамо його в Makefile, прив'яжемо до xdp-remote:

ip netns exec remote 
    ip link set dev int xdp object dummy.o

Тепер tcpdump показує те, що очікується:

62:57:8e:70:44:64 > 26:0e:25:37:8f:96, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 13762, offset 0, flags [DF], proto ICMP (1), length 84)
    192.0.2.2 > 192.0.2.1: ICMP echo request, id 46966, seq 1, length 64
62:57:8e:70:44:64 > 26:0e:25:37:8f:96, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 13762, offset 0, flags [DF], proto ICMP (1), length 84)
    192.0.2.2 > 192.0.2.1: ICMP echo request, id 46966, seq 1, length 64

Якщо замість цього показуються лише 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 в ядрі забороняє переходи назад, тобто, фактично, цикли і виклики функцій.

#define INTERNAL static __attribute__((always_inline))

Макрос LOG() відключає друк у релізному складанні.

Програма є конвеєром з функцій. Кожна приймає пакет, у якому виділено заголовок відповідного рівня, наприклад, process_ether() очікує, що заповнено ether. За результатами аналізу полів функція може передати пакет до рівня вище. Результат роботи функції – дія XDP. Поки обробники SYN та ACK пропускають усі пакети.

struct Packet {
    struct xdp_md* ctx;

    struct ethhdr* ether;
    struct iphdr* ip;
    struct tcphdr* tcp;
};

INTERNAL int process_tcp_syn(struct Packet* packet) { return XDP_PASS; }
INTERNAL int process_tcp_ack(struct Packet* packet) { return XDP_PASS; }
INTERNAL int process_tcp(struct Packet* packet) { ... }
INTERNAL int process_ip(struct Packet* packet) { ... }

INTERNAL int
process_ether(struct Packet* packet) {
    struct ethhdr* ether = packet->ether;

    LOG("Ether(proto=0x%x)", bpf_ntohs(ether->h_proto));

    if (ether->h_proto != bpf_ntohs(ETH_P_IP)) {
        return XDP_PASS;
    }

    // B
    struct iphdr* ip = (struct iphdr*)(ether + 1);
    if ((void*)(ip + 1) > (void*)packet->ctx->data_end) {
        return XDP_DROP; /* malformed packet */
    }

    packet->ip = ip;
    return process_ip(packet);
}

SEC("prog")
int xdp_main(struct xdp_md* ctx) {
    struct Packet packet;
    packet.ctx = ctx;

    // A
    struct ethhdr* ether = (struct ethhdr*)(void*)ctx->data;
    if ((void*)(ether + 1) > (void*)ctx->data_end) {
        return XDP_PASS;
    }

    packet.ether = ether;
    return process_ether(&packet);
}

Звертаю увагу на перевірки, зазначені A та B. Якщо закоментувати A, програма збереться, але при завантаженні буде помилка верифікації:

Verifier analysis:

<...>
11: (7b) *(u64 *)(r10 -48) = r1
12: (71) r3 = *(u8 *)(r7 +13)
invalid access to packet, off=13 size=1, R7(id=0,off=0,r=0)
R7 offset is outside of the packet
processed 11 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0

Error fetching program/map!

Ключовий рядок invalid access to packet, off=13 size=1, R7(id=0,off=0,r=0): є шляхи виконання, коли тринадцятий байт від початку буфера знаходиться поза пакетом. По лістингу важко зрозуміти, про який рядок йдеться, зате є номер інструкції (12) і дизассемблер, що показує рядки вихідного коду:

llvm-objdump -S xdp_filter.o | less

У цьому випадку він вказує на рядок

LOG("Ether(proto=0x%x)", bpf_ntohs(ether->h_proto));

за якою зрозуміло, що проблема в ether. Завжди так.

Відповідь на SYN

Мета на цьому етапі – формувати коректний SYNACK-пакет з фіксованим seqnum, який у майбутньому заміниться SYN cookie. Всі зміни відбуваються в process_tcp_syn() та околицях.

Перевірка пакету

Як не дивно, ось найпримітніший рядок, точніше, коментар до нього:

/* Required to verify checksum calculation */
const void* data_end = (const void*)ctx->data_end;

При написанні першої версії коду використовувалося ядро ​​5.1, для верифікатора якого була різниця між data_end и (const void*)ctx->data_end. Під час написання статті ядро ​​5.3.1 не мало такої проблеми. Можливо, компілятор звертався до локальної змінної інакше, ніж поля. Мораль - на великій вкладеності спрощення коду може допомогти.

Далі рутинні перевірки довжин на славу верифікатора; про MAX_CSUM_BYTES нижче.

const u32 ip_len = ip->ihl * 4;
if ((void*)ip + ip_len > data_end) {
    return XDP_DROP; /* malformed packet */
}
if (ip_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* implementation limitation */
}

const u32 tcp_len = tcp->doff * 4;
if ((void*)tcp + tcp_len > (void*)ctx->data_end) {
    return XDP_DROP; /* malformed packet */
}
if (tcp_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* implementation limitation */
}

Розворот пакета

заповнюємо seqnum и acknum, виставляємо ACK (SYN вже виставлено):

const u32 cookie = 42;
tcp->ack_seq = bpf_htonl(bpf_ntohl(tcp->seq) + 1);
tcp->seq = bpf_htonl(cookie);
tcp->ack = 1;

Змінюємо місцями порти TCP, адресу IP та MAC-адреси. Стандартна бібліотека недоступна з XDP-програми, тому memcpy() - макрос, що приховує інтринсік Clang.

const u16 temp_port = tcp->source;
tcp->source = tcp->dest;
tcp->dest = temp_port;

const u32 temp_ip = ip->saddr;
ip->saddr = ip->daddr;
ip->daddr = temp_ip;

struct ethhdr temp_ether = *ether;
memcpy(ether->h_dest, temp_ether.h_source, ETH_ALEN);
memcpy(ether->h_source, temp_ether.h_dest, ETH_ALEN);

Перерахунок контрольних сум

Контрольні суми 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);
}

Власне перерахунок контрольних сум та відправлення пакета назад:

ip->check = 0;
ip->check = carry(sum16(ip, ip_len, data_end));

u32 tcp_csum = 0;
tcp_csum += sum16_32(ip->saddr);
tcp_csum += sum16_32(ip->daddr);
tcp_csum += 0x0600;
tcp_csum += tcp_len << 8;
tcp->check = 0;
tcp_csum += sum16(tcp, tcp_len, data_end);
tcp->check = carry(tcp_csum);

return XDP_TX;

Функція 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 не реагує на некоректні контрольні суми.

З погляду XDP сама перевірка тривіальна. Алгоритм розрахунку є примітивним і, ймовірно, вразливим для витонченого зловмисника. Ядро Linux, наприклад, використовує криптографічний SipHash, але його реалізація для XDP явно виходить за рамки статті.

З'явилося для нових TODO, пов'язаних із зовнішньою взаємодією:

  • XDP-програма не може зберігати cookie_seed (Секретну частину солі) у глобальній змінній, потрібно сховище в ядрі, значення в якому періодично оновлюватиметься з надійного генератора.

  • При збігу SYN cookie в ACK-пакеті потрібно друкувати повідомлення, а запам'ятовувати IP перевіреного клієнта, щоб далі пропускати пакети від нього.

Перевірка легітимним клієнтом:

$ sudoip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Connection reset by peer

У логах зафіксовано проходження перевірки (flags=0x2 - це SYN, flags=0x10 - це ACK):

Ether(proto=0x800)
  IP(src=0x20e6e11a dst=0x20e6e11e proto=6)
    TCP(sport=50836 dport=6666 flags=0x2)
Ether(proto=0x800)
  IP(src=0xfe2cb11a dst=0xfe2cb11e proto=6)
    TCP(sport=50836 dport=6666 flags=0x10)
      cookie matches for client 20200c0

Поки немає списку перевірених IP, захисту від власне SYN flood не буде, але реакція на ACK flood, що запускається такою командою:

sudo ip netns exec xdp-test   hping3 --flood -A -s 1111 -p 2222 192.0.2.1

Записи у лозі:

Ether(proto=0x800)
  IP(src=0x15bd11a dst=0x15bd11e proto=6)
    TCP(sport=3236 dport=2222 flags=0x10)
      cookie mismatch

Висновок

Іноді eBPF взагалі і XDP, зокрема, представляється скоріше як інструмент просунутого адміністратора, ніж як платформа для розробки. Справді, XDP — інструмент втручання у обробку пакетів ядром, а чи не альтернатива ядерному стеку, як DPDK та інші варіанти kernel bypass. З іншого боку, XDP дозволяє реалізувати досить складну логіку, яку до того ж легко оновлювати без паузи в обробці трафіку. Верифікатор не створює великих проблем, особисто я не відмовився б від такого для частин userspace-коду.

У другій частині, якщо тема цікава, доробимо таблицю перевірених клієнтів та розрив з'єднань, впровадимо лічильники та напишемо userspace-утиліту для керування фільтром.

Посилання:

Джерело: habr.com

Додати коментар або відгук