
В 2022 году финансовый сектор, в частности банки, столкнулся с волной продолжительных и достаточно мощных DDoS-атак разных векторов. Среди них были и банальные L7 HTTP-флуды, не представлявшие собой ничего сложного в техническом плане, но для организаций с несколькими сотнями пользовательских сервисов и защитой от L7-атак только критичных из них, это стало серьезным вызовом.
Типичная L4-защита не давала необходимой эффективности, а количество атакующих устройств было достаточно, чтобы забить каналы пропускной способностью в десятки гигабит. Тогда на помощь пришла фильтрация трафика по географическому признаку, поскольку основная часть атакующего трафика шла из-за границы. Применение гео-фильтрации оказалось достаточным, чтобы в критический момент восстановить доступность сервисов и выиграть время для настройки более точной защиты на основе детального анализа трафика.
Этот опыт наглядно показал, что иногда простые решения оказываются наиболее эффективными в критических ситуациях. Гео-фильтрация, будучи грубым инструментом, в условиях DDoS-атаки может стать тем самым «спасательным кругом», который позволяет локализовать проблему и выиграть время для более тонкой настройки.
Технология XDP (eXpress Data Path) идеально подходит для таких сценариев — она позволяет обрабатывать пакеты на самом раннем этапе, еще до того, как они попадут в сетевой стек ядра, что обеспечивает беспрецедентную производительность.
Данной статьей хочется продемонстрировать, как с помощью XDP можно достаточно легко реализовать собственный гео-фильтр. Эту защиту можно реализовать, например, модулями nginx, но в таком случае все нежелательные запросы будут проходить полный сетевой стек операционной системы и потреблять ресурсы веб-сервера, прежде чем быть отклоненными. В нашем же случае защита отрабатывает до того, как пакеты поступят в сетевой стек ядра.
В данной статье мы поэтапно реализуем XDP-фильтр на ограниченной версии языка C. Код сознательно упрощен для лучшего понимания основных концепций.
Для начала создадим заголовочный файл maps.h. В нем мы опишем все необходимы структуры и мапы. В eBPF мапы нужны для обмена данными между пользовательским пространством и XDP программой.
Так как работать мы будем с src IP-адресами, а гео-база может содержать префиксы любой длины, то в качестве типа мапы будем использовать BPF_MAP_TYPE_LPM_TRIE , который реализует Longest Prefix Match (LPM) на основе префиксного дерева (trie). Это означает, что для каждого IP-адреса будет найдена запись с самым длинным совпадающим префиксом.
struct {
__uint(type, BPF_MAP_TYPE_LPM_TRIE);
__type(key, struct geo_ip_key);
__type(value, struct geo_value);
__uint(max_entries, 600000);
__uint(map_flags, BPF_F_NO_PREALLOC); // обязательно для LPM
} geo_m SEC(".maps");
Структура geo_ip_key должна иметь определенную структуру и включать в себя длину префикса и IP-адрес:
struct geo_ip_key {
__u32 prefix_len; // Длина префикса в битах
__u32 ip; // IP-адрес в сетевом порядке байт
};
В geo_value будет содержаться гео-идентификатор страны:
struct geo_value {
__u32 geoname_id; // Идентификатор страны
};
Так же нам необходим блок с настройкой самой контрмеры:
struct cfg_geo {
__u32 allowed_geos[MAX_GEO_ALLOWED];
__u32 blocked_geos[MAX_GEO_BLOCKED];
__u8 default_action;
__u8 enabled; // Включена ли фильтрация
__u16 pad; // Выравнивание
};
Где:
allowed_geos — разрешенные страны
blocked_geos — запрещенные страны
default_action — действие по умолчанию, если адрес принадлежит стране, которая не попала ни в список разрешенных, ни в список запрещенных
Здесь мы можем заметить __u16 pad. Это выравнивание (padding). Компилятор может добавить паддинг автоматически для оптимизации доступа к памяти и соблюдения требований процессора. Явное указание делает структуру данных предсказуемой и код более понятным.
Отлично! Мы описали заголовочные файлы, теперь можем приступить к реализации основной программы.
SEC("xdp")
int xdp_geo_filter(struct xdp_md *ctx) {
Программа начинается с макроса SEC("xdp"), который указывает компилятору, что эту функцию нужно прикрепить к XDP-хуку сетевого интерфейса. Функция xdp_geo_filter вызывается для каждого пакета, поступающего на сетевой интерфейс.
Разница в обработке пакетов:
Без XDP: Пакет → Драйвер сетевой карты → Ядро Linux → Приложение
С XDP: Пакет → XDP программа (наша функция) → Драйвер сетевой карты → Ядро Linux → Приложение
В главной функции мы выполняем стандартные проверки безопасности, необходимые для того, чтобы XDP-программа не была отклонена верификатором eBPF.
Определяем границы пакета:
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
Проверяем, достаточно ли места для Ethernet-заголовка. Если адрес выходит за конец пакета, значит пакет поврежден или слишком мал - в таком случае пропускаем его:
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
Проверяем, что это IP-пакет (нам нужны только IP-пакеты):
if (bpf_ntohs(eth->h_proto) != ETH_P_IP) return XDP_PASS;
struct iphdr *iph = (void *)(eth + 1);
if ((void *)(iph + 1) > data_end) {
return XDP_PASS;
}
Извлекаем IP-адрес источника и вызываем функцию гео-фильтрации:
__u32 src_ip = iph->saddr;
if (check_geo(src_ip) == XDP_DROP) {
return XDP_DROP;
}
Вообще XDP может возвращать несколько значений:
XDP_ABORTED 0 // Ошибка выполнения
XDP_DROP 1 // Отбросить пакет
XDP_PASS 2 // Передать пакет дальше в сетевой стек
XDP_TX 3 // Отправить пакет обратно через тот же интерфейс
XDP_REDIRECT 4 // Перенаправить на другой интерфейс
Но в нашем случае функция возвращает только XDP_DROP (1) или XDP_PASS (2)
Реализация функции гео-фильтрации:
static __always_inline int check_geo(__u32 src_ip) {
__u32 config_key = 0;
struct cfg_geo *geo_cfg = bpf_map_lookup_elem(&cfg_geo_m, &config_key);
if (!geo_cfg || !geo_cfg->enabled) // Проверяем, включена ли контрмера
return XDP_PASS;
Создаем ключ для поиска в LPM-trie мапе geo_m используя IP-адрес источника:
struct geo_ip_key key = {
.prefix_len = 32,
.ip = src_ip,
};
Если адреса нет в гео-базе, применяем действие по умолчанию: пропустить или сбросить пакет:
struct geo_value *geo = bpf_map_lookup_elem(&geo_m, &key);
if (!geo)
return geo_cfg->default_action ? XDP_DROP : XDP_PASS;
Если адрес найден в гео-базе, проверяем список разрешенных стран. Проходим по массиву allowed_geos и если идентификатор страны присутствует в нем, пакет пропускается:
#pragma unroll
for (int i = 0; i < MAX_GEO_ALLOWED; i++) {
if (geo_cfg->allowed_geos[i] == 0) break;
if (geo_cfg->allowed_geos[i] == geo->geoname_id)
return XDP_PASS;
}
Скрытый текст
Важно: Директива #pragma unroll указывает компилятору развернуть цикл, чтобы верификатор eBPF мог проверить его ограниченность. Это требование безопасности - все циклы в eBPF должны иметь предсказуемое максимальное количество итераций.
Проверяем список запрещенных стран. Аналогично проходим по массиву blocked_geos. Если идентификатор страны присутствует в черном списке, пакет сбрасывается:
#pragma unroll
for (int i = 0; i < MAX_GEO_BLOCKED; i++) {
if (geo_cfg->blocked_geos[i] == 0) break;
if (geo_cfg->blocked_geos[i] == geo->geoname_id)
return XDP_DROP;
}
Действие по умолчанию применяется, если IP-адрес принадлежит стране, которая не находится ни в белом, ни в черном списке:
return geo_cfg->default_action ? XDP_DROP : XDP_PASS;
Это вся XDP-программа. Теперь ее можно скомпилировать с помощью Clang:
clang -O2 -g -Wall -target bpf -D__BPF_TRACING__ -I. -I./headers -c xdp_geo.c -o xdp_geo.o
Параметры компиляции:
-target bpf - компиляция для eBPF
-O2 - оптимизация для производительности
-D__BPF_TRACING__ - определение для трассировки
-I. -I./headers - пути к заголовочным файлам
Загружаем программу и привязываем ее к сетевому интерфейсу:
bpftool prog loadall xdp_geo.o /sys/fs/bpf/geo type xdp pinmaps /sys/fs/bpf/geo
ip link set dev ens160 xdpgeneric pinned /sys/fs/bpf/geo/xdp_geo_filter
Скрытый текст
Для тестирования мы используем xdpgeneric, для продакшена лучше xdp (драйверный режим)
Убедиться, что программа привязалась можно с помощью команды:
ip link show
Теперь нам надо наполнить мапу geo_m, для этого необходим файл с базой данных, которая сопоставляет диапазоны IPv4-адресов с географической принадлежностью.
Базу можно поискать тут:
MaxMind GeoLite2 https://dev.maxmind.com/geoip/geolite2-free-geolocation-data
IP2Location LITE https://lite.ip2location.com
Пример такого файла в csv:

Здесь нам интересны столбцы network и geoname_id
Формат записи в мапе будет следующим:
"key": {
"prefix_len": 16,
"ip": 30580
},
"value": {
"geoname_id": 1269750
},
Скрытый текст
Для удобства наполнения геоданными предусмотрен Go-скрипт (ссылка в конце статьи).
Использовать так:
go run main.go RU-GeoIP-Country-Blocks-IPv4.csv geo_m
Записей достаточно много, поэтому загрузка данных в мапу занимает какое-то время. Помните мы указывали __uint(max_entries, 600000) в структуре мапы geo_m? После выполнения go программы мы получим около 512k записей:
sudo bpftool map dump name geo_m | grep -c "key"
512171
Теперь у нас есть наполненная гео-база и мы можем протестировать нашу контрмеру
Скрытый текст
Кстати, вопрос знатокам, почему в гео-базах отсутствует адрес 1.1.1.1?

Давайте включим контрмеру, добавим в разрешенные RU, действие по умолчанию сброс. Для этого можем воспользоваться bpftool - основной утилитой для работы с BPF.
У RU geoname_id (уникальный идентификатор местоположения сети) является значение 2017370
eBPF карты хранят числа в little-endian формате. Это значит, что младший байт (наименее значащий) идёт первым, а старший — последним.
Возьмем число 2017370₁₀ = 0x001EC85A₁₆
В little-endian формате оно будет выглядеть как 5ac81e00
В maps.h у нас есть определения
#define MAX_GEO_ALLOWED 32
#define MAX_GEO_BLOCKED 32
Это значит, что массивы allowed_geos и blocked_geos содержат по 32 элемента 4 байта каждый. Первый элемент будет 5a c8 1e 00, оставшиеся - нули. Не забываем про default_action, enabled и pad, это последние байты 01 01 00 00.
sudo bpftool map update pinned /sys/fs/bpf/geo/cfg_geo_m \
key hex 00 00 00 00 \
value hex \
5a c8 1e 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
01 01 00 00
Данный способ внесения изменений не очень удобен, можно написать небольшой скрипт упрощающий этот процесс, но для наших целей этого достаточно.
Выведем содержимое карты cfg_geo_m:
bpftool map dump name cfg_geo_m
[{
"key": 0,
"value": {
"allowed_geos": [2017370,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
],
"blocked_geos": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
],
"default_action": 1,
"enabled": 1,
"pad": 0
}
}
]
Теперь у нас все готово для тестирования. Для проверки мы можем запустить на защищаемом ресурсе tcpdump в случае, если трафик будет попадать под сброс, мы не увидим его в захвате, т.к. наша xdp программа работает раньше.
Так же мы можем включить логирование самой xdp программы добавив непосредственно перед пропуском или сбросом пакета bpf_printk() – это встроенная (helper) функция в eBPF, которая используется для отладки, она позволяет выводить отладочные сообщения прямо из eBPF-кода в trace log ядра Linux.
Скрытый текст
Важно: В продакшене лучше не использовать bpf_printk(), т.к. отладка грузит ядро.
Сообщения, напечатанные через bpf_printk(), можно посмотреть в системных логах:
sudo cat /sys/kernel/debug/tracing/trace_pipe
Сгенерируем трафик используя в качестве src адрес из geoname_id 2017370
В логах увидим события такого типа:
<idle>-0 [005] ..s21 15327.319941: bpf_trace_printk: xdp_geo_filter: PASS - country 2017370 in allow list
При попытке использовать адрес, принадлежащий любой другой страны, например 8.8.8.8, лог покажет следующее:
<idle>-0 [005] ..s21 15509.623322: bpf_trace_printk: xdp_geo_filter: DROP - default action for country 6252001
В случае, если адреса нет в мапе, например используется серый ip, то будет применено действие по умолчанию.
Заключение: Реализация анти DDoS защиты на основе XDP/eBPF представляет собой мощное и эффективное решение для современных сетевых задач. А главное, достаточно несложное в реализации.
Ссылка на полный код:
https://github.com/mrOctaviusTru/test_geo.git
Официальная документация:
https://www.kernel.org/doc/html/latest/bpf/index.html
https://docs.cilium.io/en/stable/reference‑guides/bpf/index.html
Книги:
«Learning eBPF» автор Liz Rice
«BPF Performance Tools» автор Brendan Gregg
IDeleter
Главное учесть VLAN-тэги и IPv6, уйти с xdpgeneric на xdpdrv, держать белые списки и обновлять геобазу атомарно