Технология 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).
Разработка и установка фильтра выглядят так:
Исходный код (например, 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 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 в ядре запрещает переходы назад, то есть, фактически, циклы и вызовы функций.
Программа представляет собой конвейер из функций. Каждая принимает пакет, в котором выделен заголовок соответствующего уровня, например, 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-утилиту для управления фильтром.