В период массового импортозамещения средств защиты от DDoS один из провайдеров перевёл свои центры очистки трафика на отечественное решение. Помимо стандартной защиты на уровне L4, провайдер позиционировал его нам как эффективную защиту от L7-атак за счёт механизма фильтрации по TLS-отпечаткам (тогда это был ещё JA3). Однако на практике мы показали, что рандомизация параметров отпечатка (cipher suites, extensions, порядок) позволяет обойти этот механизм и существенно снижает его эффективность против L7-атак. Стоит ли использовать механизмы защиты по отпечаткам JA3/JA4, зная о возможности обхода? Да, стоит. Процесс обхода требует от атакующего значительных ресурсов - кастомного TLS-клиента для генерации уникальных отпечатков. При дополнительной настройке, например, добавлении счётчика с разными лимитами для новых и известных отпечатков (более высокие лимиты для «белых» отпечатков), можно добиться высокой эффективности против ботовых L7-атак (флуд от ботов с повторяющимися отпечатками).

 В этой статье мы реализуем защиту на основе фильтрации TLS-отпечатков, вдохновлённую подходом JA4, но в упрощённой версии FST1 (по отсортированным cipher suites с использованием Jenkins-хэша). Почему не полноценный JA4? Из-за жёстких ограничений eBPF (ограниченный стек, запрет на сложные циклы, отсутствие динамической памяти и строгие правила верификатора ядра) реализация полного JA4 (с учётом всех расширений, ALPN и других параметров) становится крайне сложной.


Полноценное вычисление JA4-отпечатков в чистом eBPF крайне затруднено из-за ограничений верификатора. Поэтому основной подход - гибридный: XDP-программа ловит TLS ClientHello и копирует его payload в ring buffer для передачи в userspace, где и происходит полный парсинг и расчёт отпечатка.

 Ring buffer в eBPF - это самый эффективный способ передачи данных из kernel в userspace в больших объёмах (особенно для XDP сценариев).

Он представляет собой круговую очередь фиксированного размера (например, 1 МБ), где producer (eBPF) пишет события, а consumer (userspace-программа) читает их.

Пример кода:

    __u16 tls_record_len = ((__u16)payload[3] << 8) | payload[4]; // TLS record length (байты 3-4)

    __u32 avail = (__u8 *)data_end - payload;
    __u32 copy_len = avail;
    if (copy_len > 4096) //защита от слишком больших пакетов
        copy_len = 4096;

    struct event *e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0); // запрашиваем место в ring buffer
    if (!e)
        return XDP_PASS;

    // заполняем структуру event
    e->saddr       = ip->saddr;
    e->sport       = bpf_ntohs(tcp->source);
    e->dport       = bpf_ntohs(tcp->dest);
    e->payload_len = copy_len;

    for (int i = 0; i < 4096; i += 8) {
        if (i >= copy_len) break;
        __u64 *dst = (__u64 *)&e->payload[i];
        __u64 *src = (__u64 *)(payload + i);
        if ((void *)(src + 1) > data_end) break;
        *dst = *src;
    }

    bpf_ringbuf_submit(e, 0); //публикуем данные в ring buffer
    return XDP_PASS;

В userspace-программе из ring buffer извлекается ClientHello, выполняется полный парсинг по RFC 8446 (TLS 1.3), RFC 5246 (TLS 1.2) и RFC 4346 (TLS 1.1), вычисляется JA4-отпечаток (с учётом всех расширений, ALPN, SNI и других параметров). Далее отпечаток сверяется с заранее подготовленными списками (whitelist/blacklist) или динамически добавленными (на основе анализа трафика во время атаки), после чего принимается решение о блокировке - например, через добавление IP адреса в eBPF-мапу.

Схема с ring buffer рабочая и удобная, но при объёмных атаках (сотни тысяч новых соединений в секунду) добавляет накладные расходы на копирование и передачу данных. Для максимальной производительности предпочтительнее реализовать всю логику в чистом eBPF/XDP - при условии, что используется упрощённый алгоритм (например, FST1, как в нашем случае), который легко проходит верификацию ядра.

Именно такой подход мы возьмем за основу. В поисках оптимального решения наткнулся на статью FoxMoss «How I Block All 26 Million Of Your Curl Requests» https://foxmoss.com/blog/packet-filtering/

Автор предлагает не вычислять полноценный ja4, а использовать некриптографический хэш Jenkins по отсортированному списку cipher suites.

Да, Jenkins не является криптографическим алгоритмом, но в контексте XDP-фильтрации это преимущество.

Задача fingerprint в XDP - это не криптографическая защита, а быстрое вычисление хэша для последующего пропуска или блокировки нелегитимных клиентов с минимальной нагрузкой на fast-path ядра.

Для этих целей Jenkins hash подходит идеально:
он быстрый, verifier-friendly и легко реализуется в eBPF без нарушения ограничений XDP.

Полученный fingerprint не является каноническим JA4, а представляет собой JA4-like идентификатор, оптимизированный для высокопроизводительной фильтрации трафика на уровне XDP.

Теперь перейдём к реализации и напишем код, взяв за основу алгоритм Jenkins и подход, описанный в статье FoxMoss.

Скрытый текст

Для простоты мы не учитываем IPv6, VLAN-теги и пакеты с IP-опциями - такие пакеты просто пропускаются (XDP_PASS).

Используемые мапы:

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 2000000);
    __type(key, __u32);      // src IPv4
    __type(value, __u64);    // timestamp
} blocked_ip_m SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, __u32);  // FST1 hash (fingerprint)
    __type(value, __u8); // 1 = block
    __uint(max_entries, 1024);
} blocked_ja4_m SEC(".maps");

Начинаем программу со стандартных проверок:

SEC("xdp")
int xdp_ja4_jenkins_filter(struct xdp_md *ctx) {
    __u64 now = bpf_ktime_get_ns() / 1000000000;

    struct ethhdr eth;
    if (bpf_xdp_load_bytes(ctx, 0, &eth, sizeof(eth)) < 0)
        return XDP_PASS;

    if (eth.h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    __u64 ip_off = sizeof(eth);
    struct iphdr ip;
    if (bpf_xdp_load_bytes(ctx, ip_off, &ip, sizeof(ip)) < 0)
        return XDP_PASS;

    if (ip.protocol != IPPROTO_TCP)
        return XDP_PASS;

Если адрес добавлен в blacklist, то отбрасываем пакет:

    __u64 *blocked_ip_ts = bpf_map_lookup_elem(&blocked_ip_m, &ip.saddr);
    if (blocked_ip_ts) {
        return XDP_DROP;
    }

Вычисляем, где начинается полезная нагрузка (payload), чтобы добраться до начала TLS-сообщения ClientHello:

    __u32 ip_hlen = ip.ihl * 4;
    __u64 tcp_off = ip_off + ip_hlen;

Объявляем структуру tcphdr и читаем первые 20 байт TCP-заголовка с помощью безопасной функции bpf_xdp_load_bytes. Если чтение не удалось (пакет короче, вышел за границы или ошибка), то сразу пропускаем пакет:

    struct tcphdr tcp;
    if (bpf_xdp_load_bytes(ctx, tcp_off, &tcp, sizeof(tcp)) < 0)
        return XDP_PASS;
  • ctx - контекст XDP-пакета.

  • tcp_off - откуда начинать чтение.

  • &tcp - адрес, куда записать прочитанные данные.

  • sizeof(tcp) - обычно 20 байт (без TCP-опций).

Скрытый текст

bpf_xdp_load_bytes  это helper для безопасного чтения произвольных данных пакета в XDP.

Получаем начало TLS-сообщения (ClientHello, Application Data и т.д.):

    __u32 tcp_hlen = tcp.doff * 4;
    __u64 payload_off = tcp_off + tcp_hlen;

Вызываем вспомогательную функцию is_tls_client_hello, которая:

  • Читает первые 5 байт с позиции payload_off.

  • Проверяет сигнатуру TLS ClientHello:

    • байт 0: 0x16 (Content Type = Handshake)

    • байт 1: 0x03 (major version)

    • байт 2: 0x01 или 0x03 (minor version для TLS 1.0–1.3)

Если это не TLS ClientHello → пропускаем пакет (XDP_PASS):

    if (!is_tls_client_hello(ctx, payload_off))
        return XDP_PASS;

Сама функция выглядит так:

static __always_inline bool is_tls_client_hello(struct xdp_md *ctx, __u64 payload_off) {
    __u8 record[5];
    if (bpf_xdp_load_bytes(ctx, payload_off, record, sizeof(record)) < 0)
        return false;
    return record[0] == 0x16 && 
           record[1] == 0x03 &&
           (record[2] == 0x01 || record[2] == 0x03); 
}

Приступаем к парсингу ClientHello. Смещаем указатель на 5 байт вперёд - пропускаем TLS-запись (Content Type + Version + Length):

    __u64 ptr_off = payload_off + 5;

Смещаем ещё на 38 байт, пропуская:

  • Handshake-заголовок (type 1 байт + length 3 байта = 4 байта)

  • Legacy version (2 байта)

  • Random (32 байта)

    ptr_off += 4 + 2 + 32;

Теперь ptr_off указывает на поле session_id_len.

Читаем 1 байт - длину session ID. Если чтение не удалось (пакет короче) - пропускаем пакет:

__u8 sid_len; 
if (bpf_xdp_load_bytes(ctx, ptr_off, &sid_len, 1) < 0) 
  return XDP_PASS;

Сдвигаем указатель на начало TLS session ID и проверяем, что размер ≤ 32 байт (по спецификации TLS)

Если больше, то пакет некорректный и мы его пропускаем (защита от мусора/атак):

ptr_off += 1;
if (sid_len > 32) 
  return XDP_PASS;

ptr_off += sid_len;

Читаем 2 байта - длина списка cipher suites (в сетевом порядке big-endian) и преобразуем в хостовый порядок байт (little-endian):

__u16 cipher_len_be; 
if (bpf_xdp_load_bytes(ctx, ptr_off, &cipher_len_be, 2) < 0) 
  return XDP_PASS;

__u16 cipher_len = bpf_ntohs(cipher_len_be);

Проверяем корректность длины списка cipher suites:

  • 0 - пустой список (некорректно)

  • 1600 - слишком большой (защита от OOM/DoS)

  • Нечётная - каждый шифр 2 байта, длина должна быть четной

if (cipher_len == 0 || cipher_len > 1600 || (cipher_len & 1))
  return XDP_PASS;

Вычисляем количество cipher suites (Побитовый сдвиг проще для компилятора и значительно облегчает анализ со стороны eBPF verifier’а):

__u32 real_count = cipher_len >> 1;

Защита от большого кол-ва cipher suites:

if (real_count > 800) 
  return XDP_PASS;

Теперь приступаем к самому вычислению хэша. Для этого вызываем функцию compute_ja4_hash:

__u32 my_hash = compute_ja4_hash(ctx, ptr_off, real_count);

Функция compute_ja4_hash - считает JA4-хэш по отсортированному списку cipher suites из TLS ClientHello с помощью алгоритма Jenkins one-at-a-time.

Что происходит внутри compute_ja4_hash:

Инициализируем переменные: хэш начинается с нуля, а текущий минимум - тоже с нуля (ищем шифры > 0):

__u32 my_hash = 0;
__u16 lowest  = 0;

Формируем контекст для внешнего цикла - передаём указатели на lowest и my_hash, чтобы изменять их по ссылке:

struct {
    struct xdp_md *ctx;
    __u64 ptr_off;
    __u32 real_count;
    __u16 *lowest;
    __u32 *my_hash;
} outer_args = {
    .ctx       = ctx,
    .ptr_off   = ptr_off,
    .real_count = real_count,
    .lowest    = &lowest,
    .my_hash   = &my_hash,
};

Запускаем внешний цикл outer_loop на real_count итераций (например, 21 раз при 21 шифре):

bpf_loop(real_count, outer_loop, &outer_args, 0);

Внешний цикл outer_loop (вызывается real_count раз):

  • Начинает поиск с lowest_high = 0xFFFF (максимум).

  • Создаёт контекст для внутреннего цикла и запускает bpf_loop - он ищет минимум > текущего lowest.

  • Если ничего не нашёл - прерывает внешний цикл.

  • Обновляет lowest и добавляет найденный минимум в хэш с перемешиванием:

*args->my_hash += lowest_high;
*args->my_hash += *args->my_hash << 10;
*args->my_hash ^= *args->my_hash >> 6;

Внутренний цикл inner_loop (вызывается real_count × real_count раз):

  • Вычисляет смещение текущего шифра (idx * 2).

  • Читает 2 байта через bpf_xdp_load_bytes.

  • Преобразует в хостовый порядок (bpf_ntohs).

  • Если значение > lowest и меньше текущего кандидата - обновляет кандидата:

if (val > args->lowest && val < *args->lowest_high) {
    *args->lowest_high = val;
}

После всех итераций выполняем финальные шаги Jenkins - завершающее перемешивание, которое обеспечивает равномерное распределение хэша и устойчивость к простым паттернам входных данных:

my_hash += (my_hash << 3);
my_hash ^= (my_hash >> 11);
my_hash += (my_hash << 15);

Возвращаем готовый 32-битный хэш:

return my_hash;

Теперь возвращаемся в основную функцию xdp_ja4_jenkins_filter.

Проверяем, заблокирован ли полученный JA4-подобный хэш:

__u8 *ja4_val_ptr = bpf_map_lookup_elem(&blocked_ja4_m, &my_hash);

Ищем хэш в мапе blocked_ja4_m (blacklist отпечатков). Если ключ найден и значение равно 1 - это запрещённый отпечаток:

if (ja4_val_ptr && *ja4_val_ptr == 1) {
Скрытый текст

Переменная ja4_val_ptr добавлена с расчётом на будущее: сейчас значение 1 означает «блокировка», но в дальнейшем можно легко расширить логику - например, сделать 0 = «разрешено» (whitelist) или ввести другие флаги, не меняя основной код проверки.

Добавляем IP-адрес клиента в постоянный блок-лист blocked_ip_m с текущим таймстампом (для быстрой блокировки всех последующих пакетов от этого IP без повторного парсинга TLS):

bpf_map_update_elem(&blocked_ip_m, &ip.saddr, &now, BPF_ANY);

Блокируем текущий пакет:

return XDP_DROP;
}

Если отпечаток не найден в блок-листе - просто пропускаем:

return XDP_PASS;

Скомпилируем и загрузим программу:

clang -O2 -g -Wall -target bpf -D__BPF_TRACING__ -I. -I./headers -c xdp_ja4_jenkins.c -o xdp_ja4_jenkins.o
bpftool prog loadall xdp_ja4_jenkins.o /sys/fs/bpf/ja4_jenkins type xdp pinmaps /sys/fs/bpf/ja4_jenkins
ip link set dev ens160 xdpgeneric pinned /sys/fs/bpf/ ja4_jenkins/xdp_ja4_jenkins_filter

Теперь проверим как работает наша программа на реальном трафике. Сгенерируем HTTP-запросы к веб-серверу с помощью любого инструмента нагрузочного тестирования и захватим дамп. В нашем примере используем Vegeta для Linux/arm64: https://github.com/tsenart/vegeta/releases/download/v12.12.0/vegeta_12.12.0_linux_arm64.tar.gz

Из захваченного PCAP-дампа выделяем TLS ClientHello и прогоняем их через тот же алгоритм Jenkins one-at-a-time, который используется в XDP-программе.

Для этого используем небольшую утилиту (ссылка на неё ниже). Она принимает PCAP-файл, извлекает ClientHello и считает хэш. Пример результата на тестовом дампе - хэш 0x0ebb8cc7. https://github.com/mrOctaviusTru/test_ja4_jenkins/tree/main/pcap_validator

Примерный результат:

Добавим этот хэш в нашу мапу blocked_ja4_m и повторно сгенерируем трафик:

bpftool map update pinned /sys/fs/bpf/ja4_jenkins/blocked_ja4_m key hex c7 8c bb 0e value hex 01 any

Повторно генерируем трафик.

При первом ClientHello с этим отпечатком IP-адрес клиента автоматически заносится в мапу blocked_ip_m с текущим таймстампом.

Все последующие пакеты с этого IP дропаются уже на этапе быстрой проверки по IP - без повторного парсинга TLS.


Заключение: В итоге мы получили высокопроизводительный и гибкий фильтр по TLS-отпечаткам, который легко масштабируется, обновляется и адаптируется под реальные атаки. Он не заменяет полноценную защиту от L7, но в комбинации с другими механизмами (rate limiting, behavioral analysis, CAPTCHA) может стать очень эффективным инструментом против ботов и автоматизированных клиентов.

Ссылка на полный код:

https://github.com/mrOctaviusTru/test_ja4_jenkins.git

Полезные ссылки по теме:

https://en.wikipedia.org/wiki/Jenkins_hash_function

https://github.com/FoxIO-LLC/ja4