27 October 2019

Пишем защиту от DDoS-атак на XDP. Ядерная часть

Network technologiesCDevelopment for Linux
Tutorial

Технология eXpress Data Path (XDP) позволяет выполнить произвольную обработку трафика на интерфейсах Linux до того, как пакеты поступят в сетевой стек ядра. Применение XDP — защита от DDoS-атак (CloudFlare), сложные фильтры, сбор статистики (Netflix). Программы XDP исполняются виртуальной машиной eBPF, поэтому имеют ограничения как на свой код, так и на доступные функции ядра в зависимости от типа фильтра.


Статья призвана восполнить недостатки многочисленных материалов по XDP. Во-первых, в них дается готовый код, который сразу обходит особенности XDP: подготовлен для верификации или слишком прост, чтобы вызвать проблемы. При попытке потом написать свой код с нуля нет понимания, что делать с характерными ошибками. Во-вторых, не освещаются способы локально тестировать XDP без ВМ и «железа», при том, что у них свои «подводные камни». Текст рассчитан на программистов, знакомых с сетями и Linux, которым интересен XDP и eBPF.


В этой части детально разберемся, как собирается XDP-фильтр и как его тестировать, затем напишем простой вариант известного механизма SYN cookies на уровне обработки пакетов. Пока не будем формировать «белый список»
проверенных клиентов, вести счетчики и управлять фильтром — хватит логов.


Писать будем на C — это не модно, зато практично. Весь код доступен на GitHub по ссылке в конце и разбит на коммиты по этапам, описанным в статье.


Disclaimer. В ходе статьи будет разрабатываться мини-решение для отражения от 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: %p\n", 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: %p\n'

По этой причине отладочный вывод сильно раздувает итоговый код.


Отправка пакетов XDP


Изменим фильтр: пусть он все входящие пакеты отправляет обратно. Это некорректно с сетевой точки зрения, так как нужно было бы менять адреса в заголовках, но сейчас важна работа в принципе.


       bpf_printk("got packet: %p\n", 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 cookies не используются всегда?

Во-первых, если потеряется 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-утилиту для управления фильтром.


Ссылки:


Tags:xdpepbfsyn floodddos
Hubs: Network technologies C Development for Linux
+24
5.8k 75
Comments 8
Popular right now