Пример: MCR UDP

Это пошаговый пример, как написать контрмеру MCR с аутентификацией по UDP (описание протокола), чтобы разобраться с программированием контрмеры BPF. Готовая программа займет менее 100 строк.

Вам понадобятся:

  • Mitigator v20.04 и выше.
  • Умение запускать команды из терминала.
  • Минимальные знания языка C (будут пояснения по ходу текста).
  • Знание, что у пакетов есть протоколы, заголовки, данные.

Настройка окружения

Подойдет любая операционная система, рекомендуется Linux. Нужен текстовый редактор для написания кода и компилятор. В качестве редактора можно взять Visual Studio Code или любой другой. Для компиляции в EBPF нужно установить Clang. На Debian и Ubuntu это можно сделать командой:

sudo apt-get -y install --no-install-recommends clang llvm make

Первая программа

Создайте каталог для программ и скачайте в него mitigator_bpf.h (ссылка).

В этом же каталоге создайте mcr.c с таким текстом:

#include "mitigator_bpf.h"

FILTER_V1 enum Result
mcr(Context ctx) {
    return RESULT_DROP;
}

PROGRAM_DISPLAY_ID("MCR UDP tutorial v0.1")

FITLER_V1 отмечает функцию, с которой начнется выполнение. Она принимает контекст фильтрации ctx, его нужно передавать в большинство функций, которые вызывает программа. Программа возвращает желаемое действие над пакетом, в данном случае — сбросить его (RESULT_DROP). То есть наша программа сбрасывает все пакеты. Имя функции не имеет значения, но это не может быть main().

PROGRAM_DISPLAY_ID(...) задает короткую строку, которая будет отображаться в интерфейсе пользователя при загрузке программы. Она обязательна, формат произвольный. По ней пользователь понимает, какая программа загружена.

Чтобы скомпилировать программу, откройте терминал в каталоге с ней и введите:

clang -c -emit-llvm -fno-stack-protector -O2 mcr.c -o - | \
    llc -march=bpf -filetype=obj -o=mcr.o

Если все в порядке, никакого вывода не будет, но появится mcr.o — объектный файл.

Файл mcr.o можно загрузить в контрмеру BPF и включить её. Если теперь пустить в политику любой трафик, он будет сброшен. Захватив его контрмерой PCAP, можно убедиться, что это сделано контрмерой BPF, то есть нашей программой.

Автоматизация разработки

Чтобы не вводить и не искать команду каждый раз, можно поместить ее в скрипт build.sh (Linux и Mac) или build.bat (Windows). На Linux и Mac скрипт требуется сделать исполняемым:

chmod +x build.sh

Более профессионально сделать Makefile следующего содержания (для отступов используются TAB, а не пробелы — это важно):

.PHONY: all
all: mcr.o

%.o: %.c: mitigator_bpf.h
	clang -c -emit-llvm -fno-stack-protector -O2 $< -o - | \
	    llc -march=bpf -filetype=obj -o=$@

После этого сборку можно запускать командой make. Некоторые редакторы обнаруживают Makefile автоматически и включают «горячие клавиши» для сборки.

У реальных программ обычно будет много версий, исправлений, вариантов. Вместо размножения файлов типа mcr_1.c, mcr_2_fix.c и т. п. рекомендуем изучить cистему контроля версий Git (не требуется для продолжения).

Пропуск по таблице проверенных клиентов

Контрмера должна пропускать пакеты только с IP клиентов, прошедших проверку MCR. Для этого нужно хранить список (таблицу) проверенных клиентов.

Каждой программе Mitigator предоставляет таблицу на 1 млн. записей. У записи есть 8-байтовый ключ, 8-байтовое значение и время последнего обновления. По ключу записи можно находить, а в значении хранить связанные данные. Также по ключу можно добавить или обновить запись, при этом время последнего обновления актуализируется.

Зачем нужно время обновления? Дело в том, что записи удаляются из таблицы только в фоновом режиме, когда они слишком долго не обновлялись («время жизни» записей настраивается для политики защиты). Программа должна регулярно обновлять записи, которые требуется сохранять в таблице, даже если их данные не меняются.

Чтобы пропускать только пакеты с IP, находящихся в таблице, нужно:

  1. Извлечь из пакета адрес отправителя.
  2. Найти этот адрес в таблице.
  3. Если адрес найден, пропустить пакет, иначе сбросить его.

Получение адреса отправителя

Адрес отправителя находится в заголовке IPv4, Mitigator предоставляет программам функцию, чтобы получить этот заголовок:

void* packet_network_header(Context ctx);

Как и все остальные функции, packet_network_header() описана в документации.

Он описывается структурой:

struct IpHeader {
    ...
    uint32_t ip_src;
    ...
};

Добавим в начало функции mcr() получение заголовка:

struct IpHeader* ip = packet_network_header(ctx);

Мы сразу используем packet_network_header(), потому что обрабатываем только трафик IPv4. Иначе нужно было бы сначала проверить packet_network_proto() и работать с IPv4 или IPv6 в зависимости от ответа.

Поиск в таблице

Для поиска записи по ключу есть две функции:

Bool table_find(Context ctx, TableKey key, struct TableRecord* record);
Bool table_get(Context ctx, TableKey key, struct TableRecord* record);

Они отличаются тем, что table_get() освежает время обновления записи, если она найдена. Именно это и нужно в MCR: запись должна сохраняться, пока есть пакеты от клиента.

Контекст уже есть, в качестве ключа будет адрес отправителя (TableKey — это целое 64-битное число). Если запись с таким ключом есть в таблице, функция вернет true и запишет в переменную типа struct TableRecord найденное значение и время последнего обновления. В этом случае нужно вернуть из программы RESULT_PASS — указание пропустить пакет. Функция приобретает вид:

FILTER_V1 enum Result
mcr(Context ctx) {
    struct IpHeader* ip = packet_network_header(ctx);

    struct TableRecord record;
    if (table_get(ctx, ip->ip_src, &record)) {
        return RESULT_PASS;
    }

    return RESULT_DROP;
}

Скомпилируйте программу и загрузите новую версию в Mitigator.

Тестирование фильтрации по таблице

Наша программа пока не добавляет проверенных клиентов в таблицу, но можно сделать это вручную через интерфейс контрмеры BPF. Ключ записи вводится как набор байтов в шестнадцатеричной форме. Например, нужно добавить адрес 192.0.2.1, его байты: 0a 00 02 01. Значение можно оставить нулевым, оно не используется программой.

Удобные команды для конвертации:

  • Если адрес набирается вручную: printf "%02x %02x %02x %02x\n" 192 0 2 1
  • Если адрес можно вставить или если есть список адресов: tr -d \n | tr . ' ' | xargs -n4 printf "%02x %02x %02x %02x\n"

Если теперь направить трафик с добавленного адреса, он будет пропускаться: а любой другой — сбрасываться.

При загрузке новой программы старая таблица сохраняется. Это позволяет поменять логику работы без сброса списка проверенных клиентов. Если это не нужно, например, при отладке, таблицу можно и нужно сбрасывать вручную.

Классификация пакетов

Пакеты, которые обрабатывает контрмера, попадают под один из пяти случаев:

  • Если протокол не UDP, проверить IP отправителя и пропустить либо сбросить.
  • Если протокол UDP:

    • Если длина не 400, проверить по таблице (см. выше).
    • Если данные начинаются с MCRH3110, отправить обратно пакет с испытанием.
    • Если данные начинаются с MCRR, проверить ответ на испытание.
    • Иначе проверить по таблице (см. выше).

Наметим структуру программы: будем обрабатывать каждый случай в отдельной функции. Имеющаяся программа уже реализует проверку по таблице. Так как эта логика повторяется, она вынесена в функцию process_other().

#include "mitigator_bpf.h"

LOCAL enum Result
process_other(Context ctx) {
    struct IpHeader* ip = packet_network_header(ctx);

    struct TableRecord record;
    if (table_get(ctx, ip->ip_src, &record)) {
        return RESULT_PASS;
    }

    return RESULT_DROP;
}

LOCAL enum Result
process_mcr_request(Context ctx, void* payload) {
    return RESULT_PASS; /* TODO */
}

LOCAL enum Result
process_mcr_response(Context ctx, void* payload) {
    return RESULT_PASS; /* TODO */
}

LOCAL enum Result
process_udp(Context ctx) {
    /* TODO */
}

FILTER_V1 enum Result
mcr(Context ctx) {
    if (packet_transport_proto(ctx) == IP_PROTO_UDP) {
        return process_udp(ctx);
    }
    return process_other(ctx);
}

PROGRAM_DISPLAY_ID("MCR UDP tutorial v0.2")

Макрос LOCAL необходим для всех вспомогательных функций, иначе компилятор может сгенерировать код, который не пройдет валидацию.

Протокол проверяется packet_transport_proto() вместо анализа заголовка IPv4. Это более универсально, немного быстрее и демонстрирует еще одну функцию.

При обработке UDP необходимо анализировать длину и данные пакета (payload), для получения которых есть специальная функция:

LOCAL enum Result
process_udp(Context ctx) {
    uint16_t length = 0;
    void* payload = packet_transport_payload(ctx, &length);

Сначала проверим длину данных:

    if (length != 400) {
        return process_other(ctx);
    }

Для удобства запишем сигнатуры сообщений MCR в константы:

    const char MCRH3110[] = {'M', 'C', 'R', 'H', '3', '1', '1', '0'};
    const char MCRR[] = {'M', 'C', 'R', 'R'};

Чтобы не сравнивать каждый байт сигнатуры с соответствующим байтом данных, применим трюк. Будем считать, что payload указывает не на 8 байт сигнатуры, а на единое 8-байтовое число (uint64_t), аналогично для массива MCRH3110. Далее сравним эти числа, то есть 8-байтовые блоки:

    if (*(uint64_t*)payload == *(uint64_t*)MCRH3110) {
        return process_mcr_request(ctx, payload);
    }

Аналогично для 4-байтовой сигнатуры MCRR:

    if (*(uint32_t*)payload == *(uint32_t*)MCRR) {
        return process_mcr_response(ctx, payload);
    }

Если ни одна из сигнатур не обнаружена, пакет обрабатывается в общем порядке:

    return process_other(ctx);
}

Тестирование классификации пакетов

Скомпилируйте программу и загрузите её в Mitigator. Можно заметить, что отображаемая версия поменялась на ...v0.2.

Пакеты ping должны всегда сбрасываться. Если этого не происходит, убедитесь, что таблица пуста.

Для проверки можно запустить на защищаемом сервере скрипт, имитирующий проверяющую сторону MCR:

python3 mcr.py --host 0.0.0.0 --port 1234 --key_raw mysecret --udp server

Тогда скрипт-клиент MCR должен быть способен авторизоваться, так как на данном этапе программа клиентские пакеты MCR пропускает (192.0.2.1 — адрес сервера):

python3 mcr.py --host 192.0.2.1 --port 1234 --key_raw mysecret --udp client

Реализация протокола MCR

Выдача испытания

Опишем сообщение с испытанием MCR в виде структуры:

struct Challenge {
    char signature[4];
    uint32_t cookie;
};

Через эту структуру будем заполнять содержимое ответа:

LOCAL enum Result
process_mcr_request(Context ctx, void* payload) {
    struct Challenge* challenge = (struct Challenge*)payload;

Первые четыре байта нужно заполнить сигнатурой MCRC:

    challenge->signature[0] = 'M';
    challenge->signature[1] = 'C';
    challenge->signature[2] = 'R';
    challenge->signature[3] = 'C';

Теперь нужно сгенерировать cookie:

  • привязанное к клиенту, то есть к его потоку UDP;
  • действующее ограниченное время;
  • включающее секретные данные, чтобы его нельзя было подделать.

Это типовая задача для механизмов типа challenge-response, поэтому для нее Mitigator предоставляет специальную функцию:

Cookie cookie_make(Context ctx, const struct Flow* flow);

Структура Flow описывает поток данных, к которому относится пакет, например, для UDP это IP отправителя и получателя, исходящий и целевой порт. Здесь flow определяет привязку к клиенту. В случае MCR UDP можно использовать все признаки потока. Напротив, если бы клиент мог менять порт при ответе, его не нужно было бы учитывать. Чтобы cookie не зависело от части значений struct Flow, эти поля необходимо занулить. Тип Cookie — 32-битное целое число, то есть uint32_t, как и нужно в пакете.

Для формирования struct Flow есть функция:

    struct Flow flow;
    packet_flow(ctx, &flow);

Заполним cookie в ответе:

    challenge->cookie = cookie_make(ctx, &flow);

Пакет нужно отправить обратно, то есть результат функции — RESULT_BACK. Однако перед этим нужно обрезать его до длины 8 байтов:

    set_packet_length(ctx, sizeof(Challenge)); /* sizeof(Challenge) == 8 */
    return RESULT_BACK;
}

Проверка ответа

Аналогично пакету испытания опишем пакет ответа для process_mcr_response():

struct Response {
    char signature[4];
    uint32_t cookie;
    uint32_t hash1;
    uint32_t hash2;
};

LOCAL enum Result
process_mcr_response(Context ctx, void* payload) {
    struct Response* response = (struct Response*)payload;

Сигнатура ответа уже проверена в process_udp(). Нужно проверить, что cookie из ответа соответствует значению, которое было сгенерировано cookie_make() для этого клиента (для его struct Flow). Для этого предназначена функция cookie_check():

    struct Flow flow;
    packet_flow(ctx, &flow);
    if (!cookie_check(ctx, &flow, response->cookie)) {
        return RESULT_DROP;
    }

Далее необходимо проверить результат испытания, используя секретный ключ. Для простоты сначала сделаем ключ константным и фиксированной длины:

    const uint8_t key_data[] = {'m', 'y', 's', 'e', 'c', 'r', 'e', 't'};
    const uint32_t key_size = 8;

Выполняем расчеты, согласно описанию протокола:

    uint32_t cookie_hash = crc32_32(response->cookie, 0);
    uint32_t hash1 = crc32_data(key_data, key_data + key_size, cookie_hash);
    uint32_t hash2 = crc32_32(response->cookie, hash1);
    if ((response->hash1 == hash1) && (response->hash2 == hash2)) {
        table_put(ctx, flow.saddr, 0);
    }
    return RESULT_DROP;
}

В качестве значения используется 0, так как оно не учитывается при проверке.

Параметры программы

Менять константный ключ сложно: надо перекомпилировать программу и заново загрузить её в Mitigator. Для параметров каждой программе предоставляется 1 килобайт памяти, указатель на который можно получить так:

    const uint8_t* key_data = parameters_get(ctx);

После этого изменения секретный ключ можно задавать настройками контрмеры BPF, не меняя программу. Если параметры не заданы или программа считывать больше данных, чем было задано в параметрах, недостающая часть заполняется нулями.

Тестирование работы MCR

Скомпилируйте программу и загрузите в контрмеру, включите её.

Задайте параметры программы через настройки контрмеры. Как и ключи таблицы, параметры задаются как байты в шестнадцатеричном виде. Например, mysecret записывается как 6D 79 73 65 63 72 65 74. Команда для преобразования:

echo -n 'mysecret' | hexdump -v -e '1/1 "%02X "'

Если запустить ping 192.0.2.1 (адрес сервера), все пакеты будут сброшены.

Если запустить скрипт-клиент с правильным ключом, IP-адрес клиента будет добавлен в таблицу проверенных. Повторная команда ping покажет, что пакеты с адреса клиента пропускаются.