В 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-адресов с географической принадлежностью.

Базу можно поискать тут:

Пример такого файла в 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

Комментарии (1)


  1. IDeleter
    16.11.2025 16:08

    Главное учесть VLAN-тэги и IPv6, уйти с xdpgeneric на xdpdrv, держать белые списки и обновлять геобазу атомарно