Технология 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) специально сделана простой, дабы ядро могло проверить, что код не зацикливается и не повреждает чужую память. Совокупные ограничения и проверки:
Разработка и установка фильтра выглядят так:
Поскольку XDP работает в ядре, отладка ведется по логам трассировки и, собственно, по пакетам, которые программа фильтрует или генерирует. Тем не менее, eBPF обеспечивает безопасность загруженного кода для системы, поэтому экспериментировать с XDP можно прямо на локальном Linux.
Подготовка окружения
Сборка
Clang не может напрямую выдавать объектный код для архитектуры eBPF, поэтому процесс состоит из двух шагов:
При написании фильтра пригодится пара файлов со вспомогательными функциями и макросами из тестов ядра. Важно, чтобы они соответствовали версии ядра (KVER). Качаем их в helpers/:
Makefile для Arch Linux (ядро 5.3.7):
KDIR содержит путь к заголовкам ядра, ARCH — архитектуру системы. Пути и инструменты могут немного отличаться между дистрибутивами.
Пример отличий для Debian 10 (ядро 4.19.67)
CFLAGS подключают директорию со вспомогательными заголовками и несколько директорий с заголовками ядра. Символ __KERNEL__ означает, что заголовки UAPI (userspace API) определяются для кода ядра, так как фильтр выполняется в ядре.
Защиту стека можно отключить (-fno-stack-protector), потому что верификатор кода eBPF все равно проверяет невыход за границы стека. Сразу стоит включить оптимизации, потому что размер байт-кода eBPF ограничен.
Начнем с фильтра, который пропускает все пакеты и ничего не делает:
Команда make собирает xdp_filter.o. Где его теперь испытать?
Тестовый стенд
Стенд должен включать два интерфейса: на котором будет фильтр и с которого будут отправляться пакеты. Это должны быть полноценные устройства Linux со своими IP, чтобы проверять, как обычные приложения работают с нашим фильтром.
Устройства типа veth (virtual Ethernet) нам подходят: это пара виртуальных сетевых интерфейсов, «соединенных» между собой напрямую. Создать их можно так (в этом разделе все команды ip выполняются от root):
Здесь 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.
Тогда процесс, выполняющийся в xdp-test, не будет «видеть» xdp-local (он останется в netns по умолчанию) и при отправке пакета на 192.0.2.1 передаст его через xdp-remote, потому что это единственный интерфейс в 192.0.2.0/24, доступный этому процессу. Это действует и в обратную стррону.
При перемещении между netns интерфейс опускается и теряет адрес. Чтобы настроить интерфейс в netns, нужно запустить ip ... в этом пространстве имен командной ip netns exec:
Как можно видеть, это не отличается от настройки xdp-local в пространстве имен по умолчанию:
Если запустить tcpdump -tnevi xdp-local, можно увидеть, что пакеты, отправленные из xdp-test, доставляются на этот интерфейс:
Удобно запустить шелл в xdp-test. В репозитарии есть скрипт, автоматизирующий работу со стендом, например, можно настроить стенд командой sudo ./stand up и удалить его командой sudo ./stand down.
Трассировка
Фильтр привязывается к устройству так:
Ключ -force нужен, чтобы привязать новую программу, если другая уже привязана. «No news is good news» не про эту команду, вывод в любом случае объемный. Указывать verbose необязательно, но с ним появляется отчет о работе верификатора кода с листингом ассемблера:
Отвязать программу от интерфейса:
ip link set dev xdp-local xdp off
В скрипте это команды sudo ./stand attach и sudo ./stand detach.
Привязав фильтр, можно убедиться, что ping продолжает работать, но работает ли программа? Добавим логи. Функция bpf_trace_printk() похожа на printf(), но поддерживает всего до трех аргументов, кроме шаблона, и ограниченный список спецификаторов. Макрос bpf_printk() упрощает вызов.
Вывод идет в канал трассировки ядра, который нужно включить:
Просмотр потока сообщений:
Обе этих команды делает вызов sudo ./stand log.
Ping теперь должен вызывать в нем такие сообщения:
Если присмотреться к выводу верификатора, можно заметить странные вычисления:
Дело в том, что у программ на eBPF нет секции данных, поэтому единственный способ закодировать форматную строку — immediate-аргументы команд ВМ:
По этой причине отладочный вывод сильно раздувает итоговый код.
Отправка пакетов XDP
Изменим фильтр: пусть он все входящие пакеты отправляет обратно. Это некорректно с сетевой точки зрения, так как нужно было бы менять адреса в заголовках, но сейчас важна работа в принципе.
Запускаем tcpdump на xdp-remote. Он должен показать идентичные исходящие и входящие ICMP Echo Request и перестать показывать ICMP Echo Reply. Но не показывает. Оказывается, для работы XDP_TX в программе на xdp-local необходимо, чтобы парному интерфейсу xdp-remote тоже была назначена программа, хотя бы пустая, и он был поднят.
Восстановим минимальный фильтр (XDP_PASS) в файле xdp_dummy.c, добавим его в Makefile, привяжем к xdp-remote:
Теперь tcpdump показывает то, что ожидается:
Если вместо этого показываются только ARP, нужно убрать фильтры (это делает sudo ./stand detach), пустить ping, затем установить фильтры и попробовать снова. Проблема в том, что фильтр XDP_TX действует и на ARP, и если стек
пространства имен xdp-test успел «забыть» MAC-адрес 192.0.2.1, он не сможет разрешить этот IP.
Статья призвана восполнить недостатки многочисленных материалов по 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).
Разработка и установка фильтра выглядят так:
- Исходный код (например, 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/:
Код:
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
Пример отличий для 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 \ # далее без изменений
Защиту стека можно отключить (-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";
Тестовый стенд
Стенд должен включать два интерфейса: на котором будет фильтр и с которого будут отправляться пакеты. Это должны быть полноценные устройства Linux со своими IP, чтобы проверять, как обычные приложения работают с нашим фильтром.
Устройства типа veth (virtual Ethernet) нам подходят: это пара виртуальных сетевых интерфейсов, «соединенных» между собой напрямую. Создать их можно так (в этом разделе все команды ip выполняются от root):
Код:
ip link add xdp-remote type veth peer name xdp-local
Сетевое пространство имен содержит набор интерфейсов, таблиц маршрутизации и правил NetFilter, изолированные от аналогичных объектов в других netns. Каждый процесс работает в каком-то пространстве имен, и ему доступны только объекты этого netns. По умолчанию в системе единственное сетевое пространство имен для всех объектов, поэтому можно работать в Linux и не знать про netns.
Создадим новое пространство имен xdp-test и переместим туда xdp-remote.
Код:
ip netns add xdp-test ip link set dev xdp-remote netns xdp-test
При перемещении между 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
Код:
ip address add 192.0.2.1/24 dev xdp-local ip link set xdp-local up
Код:
ip netns exec xdp-test ping 192.0.2.1
Трассировка
Фильтр привязывается к устройству так:
Код:
ip -force link set dev xdp-local xdp object xdp_filter.o 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/d***g/tracing/options/trace_printk
Код:
cat /sys/kernel/d***g/tracing/trace_pipe
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 <...>
Код:
$ 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; }
Восстановим минимальный фильтр (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
пространства имен xdp-test успел «забыть» MAC-адрес 192.0.2.1, он не сможет разрешить этот IP.
Комментарий