LINUX.ORG.RU

DPI для любопытствующих

 , ,


15

4

Движок DPI на C: от захвата пакетов до классификации протоколов.

Мне было интересно узнать, какие байты бегают по моей домашней сети. Я не нашёл простого инструмента, который можно было бы собрать за вечер и понять от начала до конца — и пришлось написать свой, на C. Эта статья — рассказ о том, как работает Deep Packet Inspection.

Код проекта: gitflic.ru/wirewalk/tiny-dpi-engine

Эпиграфы к разделам — цитаты из мультика «Шрек». (Не то чтобы я был фанат, но история уж очень подходящая. Если у вас есть идея что использовать в качестве источника цитат для следующей статьи — я открыт к предложениям!).

Что такое DPI и зачем он нужен

- Там так темно и страшно! - Это просто поле. Ничего страшного.

Когда вы открываете сайт, ваш компьютер отправляет и получает сотни сетевых пакетов в секунду. Каждый пакет — это конверт с адресами отправителя и получателя на обложке (IP-адреса, порты) и содержимым внутри.

Payload (полезная нагрузка) — это «содержимое конверта». Заголовки (адреса, порты) — это «надписи на конверте».

Обычный роутер или firewall смотрит только на надписи: «порт 80 — пропускаем, порт 25 — блокируем». Но что если кто-то крутит торренты через порт 443 (обычно занят HTTPS)? По конверту не отличишь — оба выглядят одинаково.

Deep Packet Inspection (DPI, «глубокая инспекция пакетов») — это когда мы вскрываем конверт и смотрим, что внутри. По содержимому пакета определяем реальный протокол, независимо от порта.

Где это применяется:

  • сами-знаете-где
  • Корпоративные сети: ограничить торренты и стриминг, не трогая рабочие сервисы
  • Операторы связи: приоритизировать голос (VoIP) над скачиванием файлов
  • Системы безопасности (IDS/IPS): обнаружить вторжения по сигнатурам
  • Мониторинг: статистика — какой процент трафика занимает видео, какой — DNS

Существуют зрелые решения: nDPI, L7-filter, libprotoident. Зачем писать своё? Чтобы понять, как оно работает. Исследовательский проект — лучший способ разобраться в вопросе.


Архитектура: как устроен движок

- Окей, э-э… Начнём с начала? - Да, начнём с начала.

Наш DPI-движок — это конвейер. Представьте заводскую ленту: на вход подаётся сырой сетевой пакет, он проходит через несколько стадий обработки, на выходе — «этикетка» с названием определённого протокола.

┌─────────────┐    ┌───────────┐    ┌────────────────┐    ┌──────────────┐
│   Захват    │───>│  Разбор   │───>│  Классификация │───>│    Таблица   │
│  (libpcap)  │    │  пакетов  │    │  протоколов    │    │    потоков   │
└─────────────┘    └───────────┘    └────────────────┘    └──────────────┘

Четыре стадии:

  1. Захват — получаем сырые байты из сети.
  2. Разбор — вскрываем «конверт»: извлекаем IP-адреса, порты, payload.
  3. Классификация — определяем протокол по содержимому и номерам портов.
  4. Таблица потоков — запоминаем результат, чтобы не повторять анализ.

Зачем четвёртая стадия? Представьте: вы общаетесь с сайтом. Это диалог — десятки пакетов туда-сюда. Если мы определили протокол по первому пакету, незачем повторять анализ для каждого следующего.

Поток (flow) — это набор пакетов одного соединения, идентифицируемый по пяти полям: src_ip, dst_ip, src_port, dst_port, ip_proto. Почему именно пять — поговорим позже, когда дойдём до таблицы потоков. Главное: первый пакет потока анализируется полностью, остальные наследуют результат. Это называется stateful-подход («подход с сохранением состояния»).


Захват пакетов: как подслушать сеть

- Э-э… мы точно туда идём? - Конечно!… Наверное.

Ваш компьютер непрерывно отправляет и получает сетевые пакеты. Обычно приложение видит только «свои» — браузер видит HTTP, мессенджер видит свои сообщения. Но для DPI нужно видеть все пакеты, проходящие через сетевую карту.

Для этого существует promiscuous mode («неразборчивый режим»). Обычно сетевая карта игнорирует пакеты, адресованные не ей. В promiscuous mode она передаёт ОС всё, что «слышит» — и чужие пакеты тоже.

libpcap — библиотека, которая даёт программе доступ к этому потоку пакетов. Она работает на Linux, macOS, *BSD, скрывая различия между ОС.

Для захвата нужны права root (sudo) — ограничение безопасности ОС. (Не каждому пользователю дана возможность читать чужой трафик).

pcap_t *handle = pcap_open_live("eth0", 65535, 1, 1000, errbuf);

Параметры: интерфейс ("eth0" — имя сетевой карты), максимальный размер захвата (65535 — побольше, чтобы не обрезать), promiscuous mode (1 = включить), таймаут (1000 мс).

Когда libpcap перехватывает пакет, она вызывает нашу функцию — callback. Callback — это функция, которую вы регистрируете заранее, а библиотека вызывает сама при наступлении события.

void callback(unsigned char *user, const struct pcap_pkthdr *h,
              const unsigned char *bytes) {
    dpi_parsed_packet_t pkt;
    dpi_parse_packet(bytes, h->caplen, &pkt);  // разбираем пакет
    dpi_classify(ac, &pkt, flow_table, stats);  // классифицируем
}

bytes — сырые байты перехваченного кадра. h->caplen — сколько байт реально захвачено.

Почему libpcap, а не работа с сетевой картой напрямую? На Linux для этого используется AF_PACKET, на macOS — BPF, на FreeBSD — /dev/bpf. libpcap скрывает различия — один код работает везде. Кроме того, эта библиотека умеет читать сохранённые дампы трафика из pcap-файлов — удобно для отладки.


Как выглядит реальный пакет

- Огры — они как лук! У них много слоёв! - Вы знаете, не все любят лук!

Прежде чем писать код парсинга, давайте посмотрим, что именно мы разбираем. Вот упрощённый hex-дамп реального HTTP-запроса:

ff ff ff ff ff ff   00 11 22 33 44 55   08 00
^^^^^^^^^^^^^^^     ^^^^^^^^^^^^^^^^^   ^^^^^
dst MAC (6 байт)   src MAC (6 байт)    ethertype: 0x0800 = IPv4

                    <- 14 байт = Ethernet-заголовок ->

45 00 00 2e  00 01 00 00  40 06 ?? ??  c0 a8 01 01  c0 a8 01 02
^            ^            ^  ^          ^^^^^^^^^^^   ^^^^^^^^^^^
ver=4,       длина        TTL, TCP     src:          dst:
IHL=5 (20 б) всего        proto=6      192.168.1.1   192.168.1.2

                    <- 20 байт = IPv4-заголовок ->

04 01  00 50  00 00 00 01  00 00 00 00  50 02 ?? ??  ?? ??
^^^^   ^^^^                                             ^^^^^^^^
src:    dst:  seq                  data offset=5      window,
49121   80                         (20 байт)          checksum

                    <- 20 байт = TCP-заголовок ->

47 45 54 20 2f 69 6e 64 65 78 2e 68 74 6d 6c 20 48 54 54 50 ...
G  E  T  _  /  i  n  d  e  x  .  h  t  m  l  _  H  T  T  P ...

                    <- payload: "GET /index.html HTTP..." ->

Видно структуру: три заголовка (Ethernet, IP, TCP) - и payload. Сетевой пакет - это «матрёшка»: каждый слой оборачивает следующий.

| Ethernet (14 байт) | IPv4 (20+ байт) | TCP (20+ байт) |      Payload      |

Кстати, создатели интернета тоже любили лук аналогию со слоями — модель OSI это как раз таки 7 слоёв. Но нам для практического DPI хватит четырёх.

Теперь будем разбирать эту матрёшку программно.


Разбор пакетов: снимаем слои матрёшки

- Это что, настоящая кровь? - Ну… это не НАША кровь.

Порядок байтов: почему всё «перевёрнуто»

Прежде чем читать заголовки — одна важная деталь. В компьютерах многобайтовые числа хранятся в определённом порядке:

  • Little-endian (x86, ARM): младший байт первым. Число 0x1234 -> в памяти 34 12
  • Big-endian (сетевой порядок): старший байт первым. То же число -> 12 34

Исторически сетевые протоколы используют big-endian — это называется network byte order. А ваш компьютер, скорее всего, little-endian. Поэтому при чтении чисел из заголовков нужно «переворачивать» байты. Функция ntohs()network to host short — делает именно это:

uint16_t ethertype = ntohs(out->eth->ethertype);  // "переворачиваем" 2 байта

Забыли ntohs — прочитаете 0x0800 (IPv4) как 0x0008 и ничего не поймёте. Это классические грабли, на которые наступал каждый сетевой программист.

Ethernet -> IP

int dpi_parse_packet(const uint8_t *raw, size_t raw_len,
                     dpi_parsed_packet_t *out) {
    const uint8_t *ptr = raw;          // текущая позиция в буфере
    const uint8_t *end = raw + raw_len; // граница буфера

    /* Ethernet: 14 байт - dst_mac(6) + src_mac(6) + ethertype(2) */
    if (raw_len < 14) return -1;        // слишком короткий - отбрасываем
    out->eth = (const dpi_eth_header_t *)ptr;
    uint16_t ethertype = ntohs(out->eth->ethertype);
    ptr += 14;                          // сдвигаем указатель на 14 байт

Мы берём указатель на начало данных и кастуем его к структуре заголовка. Cast (приведение типа) — это когда мы говорим компилятору: «интерпретируй эти байты как структуру с такими-то полями». Никакого копирования — просто смотрим на те же байты «через другие очки».

Поле ethertype (2 байта) говорит, что внутри:

  • 0x0800 — IPv4 (то что нам нужно)
  • 0x86DD — IPv6 (не поддерживаем)
  • 0x8100 — VLAN-тег (об этом ниже)

Если не IPv4 — пропускаем пакет.

VLAN: когда одна сеть делится на несколько

Иногда один физический кабель (или Wi-Fi) используется для нескольких виртуальных сетей — VLAN (Virtual LAN). В таком случае в кадр добавляется 4 байта — «бирка» с номером виртуальной сети:

| Ethernet (12 байт) | VLAN-тег (4 байта) | реальный ethertype | данные... |

Проверяем: если ethertype = 0x8100 — дальше VLAN-тег, пропускаем 4 байта и читаем настоящий ethertype:

if (ethertype == 0x8100) {
    if (ptr + 4 > end) return -1;
    ethertype = read_u16_be(ptr + 2);
    ptr += 4;
}

IP-заголовок

out->ip = (const dpi_ip_header_t *)ptr;
uint8_t ihl = (out->ip->version_ihl & 0x0F) * 4;

Здесь появляется побитовая операция. Разберём пошагово.

Поле version_ihl — один байт, в котором «упакованы» два значения:

биты:  7  6  5  4 | 3  2  1  0
       версия IP  |    IHL
       (= 4)      | (= 5 обычно)

Нам нужно «вырезать» младшие 4 бита. Оператор & (побитовое И) с маской 0x0F (0000 1111) делает именно это — оставляет только младшие 4 бита, обнуляя старшие:

version_ihl = 0x45  = 0100 0101
mask         = 0x0F  = 0000 1111
результат    = 0x05  = 0000 0101  <- IHL = 5

Умножение на 4 переводит из «32-битных слов» в байты: IHL=5 -> 20 байт. Обычно так и есть, но с опциями IP — до 60.

TCP: заголовок переменной длины

TCP сложнее UDP: заголовок — от 20 до 60 байт. Длина хранится в поле Data Offset — старшие 4 бита data_offset_flags:

uint16_t tcp_hdr_len = ((ntohs(tcp->data_offset_flags) >> 12) & 0xF) * 4;

Разберём эту строку:

  1. ntohs(...) — переворачиваем байты (как обсуждали выше)
  2. >> 12побитовый сдвиг вправо на 12 позиций. Аналогия: в числе 12345 убрать три последние цифры — получится 12. Здесь то же самое, но с битами: «выталкиваем» 12 младших бит, оставляя только 4 старших.
  3. & 0xF — маска, оставляет только эти 4 бита
  4. * 4 — переводим из слов в байты

Payload начинается сразу после заголовка TCP/UDP:

out->payload = ptr;                        // указатель на начало payload
out->payload_len = (size_t)(ip_end - ptr);  // байт до конца IP-пакета

Zero-copy: почему мы не копируем данные

Заметьте: мы не создаём копии пакетов. Все указатели (out->eth, out->ip, out->payload) ссылаются на оригинальный буфер libpcap. Это называется zero-copy.

При скорости 10 Гбит/с через сетевую карту проходит ~1.5 млн пакетов/сек. Если бы мы решили копировать каждый — процессор потратил бы больше времени на memcpy, чем на анализ. А так — просто «смотрим» на байты через указатели, дешево и сердито.

Проверки границ

Каждый шаг: if (ptr + 20 > end) return -1;. Если байтов не хватает — пакет повреждён, пропускаем. В реальной сети бывают «битые» пакеты и специально созданные (от злоумышленников). Строгие проверки это защита от уязвимостей.


Классификация протоколов: три линии обороны

- Откуда ты знаешь, что он - огр? - Он сам сказал! - А если соврал?

Итак, мы извлекли payload из пакета. Что с ним делать? Искать характерные паттерны — сигнатуры протоколов.

Сигнатура — характерная последовательность байтов, по которой можно опознать протокол:

ПротоколСигнатураЧеловекочитаемо
HTTP47 45 54 20"GET "
SSH53 53 48 2d"SSH-"
TLS16 03 01байт 0x16 + версия
SIP49 4e 56 49 54 45 20"INVITE "

Но сигнатуры — не единственный способ. Мы используем трёхстадийную стратегию:

Пакет -> Поток уже классифицирован?
         ├─ Да -> наследуем (быстро!)
         └─ Нет ->
              ├─ Стадия 1: Инспекция payload
              │   Смотрим на байты и структуру заголовка:
              │   "GET " -> HTTP, 0x16 0x03 -> TLS,
              │   version=2 + PT=0..34 -> RTP
              │
              ├─ Стадия 2: Ахо-Корасик
              │   Пользовательские сигнатуры из файла
              │
              └─ Стадия 3: Фолбэк на порты
                  80 -> HTTP, 443 -> HTTPS, 53 -> DNS, 22 -> SSH...

Почему три стадии, а не только сигнатуры?

Зашифрованный TLS на порту 443 не содержит "GET " — зашифрован. Но по порту мы знаем: это HTTPS. RTP на случайном порту — распознаётся по структуре заголовка, а не по сигнатуре. А HTTP на нестандартном порту 8080 — ловится сигнатурой "GET ".

Три метода дополняют друг друга. Самый интересный из них — алгоритм поиска сигнатур. Разберём его подробно.


Ахо-Корасик: поиск 50 сигнатур за один проход

- Я знаю, о чём ты думаешь! - …Пончик? - НЕТ! Хотя… да, пончик.

Наивный подход: для каждой сигнатуры проходим по payload. 50 сигнатур × 1400 байт = 70 000 сравнений на пакет. Многовато.

Алгоритм Ахо-Корасик ищет все сигнатуры за один проход по payload. Два ингредиента: trie-дерево и failure-ссылки.

Trie-дерево

Trie — дерево, где каждый путь от корня к листу — одна сигнатура. Каждый узел — развилка по очередному байту.

Пример для двух сигнатур: "abc" и "bc":

    root
   /    \
  a      b
  |      |
  b      c [bc!]
  |
  c [abc!]

Сканируем "abc": root -> a -> a->b -> a->b->c -> нашли «abc»! Всё просто — идём по дереву.

А если "xbc"? x — нет перехода из root, стоим на месте. b — есть переход из root, идём вправо. c — идём в b->c -> нашли «bc»!

Пока всё тривиально. Проблема появляется дальше.

Failure-ссылки

Только что мы прошлись по дереву без проблем: "abc" -> нашли «abc», "xbc" -> нашли «bc». А вот "abx". Пошли: a (в левую ветку), b (дальше), x — а перехода x из узла a->b нет. Застряли. Без хитростей — возвращаемся в root и начинаем с x. Но мы уже прошли b! И опять начинать сначала?!

Failure-ссылка из узла a->b ведёт не в root, а прямо в узел b под корнем:

    root
   /    \
  a      b <--- failure (a->b) указывает сюда
  |      |
  b ------
  |
  c

Теперь сканируем "abx": a -> a->b -> застряли. Failure перебрасывает нас в b под корнем. Следующий байт x проверяем уже оттуда. Не совпало - стоим. (Но если бы вместо x был c — мы бы сразу нашли «bc», потому что уже стояли в нужном месте.)

Суть: failure-ссылка говорит «вернись туда, где ты потерялся». Результат — один проход по payload, O(N), без возвратов.

Код: построение failure-ссылок

Failure-ссылки строятся один раз при загрузке базы сигнатур, до начала захвата пакетов. Алгоритм — обход в ширину (BFS), уровень за уровнем:

// Уровень 1: дети корня - их failure = root
for (int c = 0; c < 256; c++) {
    if (root->children[c]) {
        root->children[c]->fail = root;
    } else {
        root->children[c] = root;
    }
}

// Уровни 2, 3, ...: для каждого узла ищем failure
while (head < tail) {
    dpi_ac_node_t *node = queue[head++];
    for (int c = 0; c < 256; c++) {
        if (node->children[c]) {
            dpi_ac_node_t *f = node->fail;
            while (f != root && !f->children[c])
                f = f->fail;
            node->children[c]->fail = f->children[c] ?: root;
            queue[tail++] = node->children[c];
        }
    }
}

Оптимизация root->children[c] = root для пустых переходов — хитрый трюк. Без неё при каждом несовпадении нужно проверять: «а не в корне ли мы? а есть ли переход?» С ней — cur->children[c] всегда не-NULL, поиск становится проще и быстрее.

Код: поиск

int dpi_ac_search(const dpi_ac_t *ac, const uint8_t *data, size_t len) {
    dpi_ac_node_t *cur = ac->root;
    for (size_t i = 0; i < len; i++) {
        while (cur != root && !cur->children[data[i]])
            cur = cur->fail;
        if (cur->children[data[i]])
            cur = cur->children[data[i]];
        if (cur->sig_id >= 0)
            return cur->sig_id;
    }
    return -1;
}

Один цикл по payload. 5, 50 или 5000 сигнатур — скорость одна и та же.

Пул аллокаций

Узлы выделяются из одного непрерывного массива — пула. Зачем?

Процессор читает память не по одному байту, а кэш-линиями по 64 байта. Если узлы разбросаны по памяти (каждый malloc — где придётся) — процессор постоянно «промахивается» мимо кэша и ждёт данные из оперативной памяти. В 10-100 раз медленнее.

Если узлы лежат рядом в одном массиве — следующий узел, скорее всего, уже в кэше. Это называется кэш-локальность — «данные рядом -> доступ быстрый».

Бонус: освобождение памяти — один free(pool) вместо обхода всего дерева.


RTP: протокол без текстовой сигнатуры

- Ты же говорил, что огры не похожи на торты! - Говорил. Но этот - похож.

Прежде чем перейти к таблице потоков — один интересный пример классификации.

RTP (Real-time Transport Protocol) используется для голоса и видео в реальном времени: звонки, видеоконференции. У него нет текстовой сигнатуры — нельзя искать "RTP" в payload. Но есть строгая структура заголовка:

if (payload_len >= 12) {
    int version = (payload[0] >> 6) & 0x03;  // Старшие 2 бита
    int pt = payload[1] & 0x7F;               // Младшие 7 бит второго байта
  • version должен быть 2 (всегда, для современного RTP)
  • pt (Payload Type): 0..34 - аудио/видео кодеки (PCMU, GSM, Opus…), 200..207 - пакеты управления (RTCP)
    if (version == 2 && pt <= 34)
        return DPI_PROTO_RTP;       // Голос или видео
    if (version == 2 && pt >= 200 && pt <= 207)
        return DPI_PROTO_RTCP;      // Управление: статистика, завершение
}

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


Таблица потоков: запоминаем, что уже знаем

- Подождите, вы это серьёзно? Вы помните ВСЁ, что я говорил?! - У меня хорошая память.

Мы научились классифицировать один пакет. Но пакетов тысячи — как не повторять работу?

Что такое «поток»

Поток (flow) — набор пакетов одного соединения. Идентифицируется 5-кортежем (5-tuple):

  1. src_ip — IP-адрес отправителя
  2. dst_ip — IP-адрес получателя
  3. src_port — порт отправителя
  4. dst_port — порт получателя
  5. ip_proto — протокол (TCP = 6, UDP = 17)

Почему именно 5? Меньше — недостаточно: два соединения на один IP/порт будут неотличимы. Больше — избыточно: этих пяти достаточно для однозначной идентификации.

Канонизация: A->B = B->A

В диалоге клиента с сервером пакеты идут в обе стороны. Запрос — A->B, ответ — B->A. Формально 5-кортежи разные — IP и порты переставлены. Но это один диалог!

Решение: канонизация — всегда приводим к стандартному виду. (Пусть безобразно — зато однообразно!). IP-адреса сравниваем как числа, меньшее — на первое место:

if (ntohl(src_ip) > ntohl(dst_ip)) {
    // Меняем местами src и dst
}

Теперь A->B и B->A -> одинаковый ключ -> одна запись.

Хэш-таблица

Для быстрого поиска потоков используем хэш-таблицу. Идея: из ключа (5-кортежа) вычисляем число — хэш - и используем как индекс в массиве.

static uint32_t flow_hash(const dpi_5tuple_t *t) {
    uint32_t h = t->src_ip;
    h ^= t->dst_ip;
    h ^= (t->src_port << 16) | t->dst_port;
    h ^= t->ip_proto;
    // Перемешивание (Murmur-like finalizer)
    h = ((h >> 16) ^ h) * 0x45d9f3b;
    h = ((h >> 16) ^ h) * 0x45d9f3b;
    h = (h >> 16) ^ h;
    return h % DPI_FLOW_TABLE_SIZE;
}

Хэш-функция «перемешивает» биты, чтобы записи распределялись равномерно. Без перемешивания похожие IP-адреса скучивались бы в одних ячейках.

Размер таблицы — 65537. Почему не «круглое» 65536? Потому что 65537 — простое число. Когда размер таблицы - степень двойки, младшие биты хэша полностью определяют ячейку, что может вызывать скучивание при определённых закономерностях в данных. Простое число устраняет этот эффект. (Классический трюк, который нам подарил дедушка программирования Дональд Кнут).

Коллизии (два разных ключа -> один хэш) неизбежны — нельзя отобразить бесконечное число ключей в 65537 ячеек без повторений. Коллизию разрешаем цепочками: каждая ячейка — связный список. Если возникает коллизия — просто добавляем значение в список.


Как добавить свой протокол

- Можно мне тоже? - Конечно! Инструкция простая…

Классификатор легко расширить. Пример: добавим MQTT — лёгкий протокол для «интернета вещей» (порт 1883, стартовый байт 0x10).

1. Enum в заголовочном файле:

DPI_PROTO_MQTT,

2. Имя для отображения:

{DPI_PROTO_MQTT, "MQTT"},

3. Порт — фолбэк:

if (dst == 1883 || src == 1883) return DPI_PROTO_MQTT;

4. Payload-эвристика:

if (payload_len >= 2 && (payload[0] & 0xF0) == 0x10)
    return DPI_PROTO_MQTT;

5. Сигнатура в текстовом файле:

MQTT: \x10

Пересобрали — и MQTT-трафик распознаётся. Ахо-Корасик автоматически перестроит trie по обновлённой базе.


Результаты

- Ну и как? Получилось? - Более-менее. - «Более-менее» — это хорошо или плохо?

Тестирование на реальном Wi-Fi-трафике (1000 пакетов, бытовая сеть):

=== DPI Statistics ===
Total packets:   987
Total bytes:     816543
Classified:      944
Unclassified:    43
Active flows:    49
Classification:  95.6%

Per-protocol breakdown:
  HTTP       16
  HTTPS      898
  DNS        20
  TLS        10

95.6% пакетов классифицировано. Для сравнения: коммерческие системы (nDPI, PACE) дают 97-99%. Наш результат неплох для учебного проекта с ~500 строками кода — основная «потеря» приходится на зашифрованный трафик на нестандартных портах.

HTTPS доминирует — ожидаемо для современного интернета. HTTP — единичные запросы. DNS — системные запросы на преобразование имён.

43 неклассифицированных пакета — зашифрованный трафик на нестандартных портах (мессенджеры, VPN) и ICMP (ping).

Пример таблицы потоков (IP-адреса заменены):

SRC IP              PORT   -> DST IP              PORT   PROTO    PACKETS   BYTES
10.0.0.1            53     -> 192.168.1.100      46116  DNS            2       221
93.184.216.34       443    -> 192.168.1.100      49526  HTTPS          7       343
93.184.216.34       80     -> 192.168.1.100      42986  HTTP          12      2207
140.82.121.4        443    -> 192.168.1.100      49054  HTTPS         28     14127
198.51.100.1        9000   -> 192.168.1.100      36768  TLS           13      5207

Обратите внимание: поток на порту 9000 классифицирован как TLS, а не HTTP. Порт 9000 обычно не связан с TLS — но инспекция payload обнаружила байты 0x16 0x03 0x01 (TLS Handshake) и правильно определила протокол. Это именно то, что DPI делает лучше обычного firewall’а.


Формат сигнатур

База сигнатур — текстовый файл. Одна строка — одна сигнатура:

# HTTP
HTTP: GET 
HTTP: POST 
HTTP: HTTP/1.0

# SSH
SSH: SSH-2.0
SSH: SSH-1.99

# TLS (бинарные байты через \xHH)
TLS: \x16\x03\x01
TLS: \x16\x03\x02

# SIP
SIP: INVITE 
SIP: BYE 
SIP: SIP/2.0

Формат: ПРОТОКОЛ: шаблон. Текстовые паттерны — как есть, бинарные — через \xHH. Добавить сигнатуру — одну строку в файле, перекомпилировать не нужно.


Ограничения

- Это всё? - Это МОГЛО БЫТЬ всё.

Проект сознательно не включает:

  • IPv6 — другая структура заголовка (40 байт фиксированной длины, нет IHL)
  • IP-дефрагментацию — фрагментированные пакеты анализируются отдельно; только первый фрагмент содержит TCP/UDP-заголовок
  • TCP reassembly — сегменты не склеиваются; HTTP-заголовок, разбитый на два сегмента, может быть не распознан
  • Шифрованный трафик — зашифрованный TLS непрозрачен; определяем по заголовку, но не анализируем содержимое
  • ML-классификация — промышленные DPI используют машинное обучение (размер пакетов, тайминги); у нас только сигнатуры и порты

Каждый из этих пунктов — тема для отдельной статьи. Или для вашего pull request’а.


Что дальше

- И что теперь? - А вот это уже совсем другая история.

Проект открыт: gitflic.ru/wirewalk/tiny-dpi-engine. Зеркало: github.com/wirewalk/tiny-dpi-engine. Pull request’ы приветствуются.

Возможные направления:

  • IPv6 — расширение парсера
  • TCP reassembly — склеивание сегментов
  • JSON-вывод — для интеграции с системами мониторинга
  • REST API — статистика в реальном времени
  • Бенчмарки — сравнение с nDPI по производительности

Спасибо за ваше внимание к этому вопросу (C). Если статья была полезна — лайк, подписка, комментарий. Вопросы, замечания, идеи? — Круто, буду рад вас услышать.



Проверено: maxcom ()
Последнее исправление: maxcom (всего исправлений: 6)

Лицензия

MIT

Это так не работает, должен быть приложен файл с текстом лицензии и копирайтом в нём, или/и включён текст лицензии в файл/лы. Ну или я не увидел просто, если так то пардон.

LINUX-ORG-RU ★★★★★
()

Интересно. Если заменить Ахо-Корасик на Vectorscan, то можно более интересные правила строить.

maxcom ★★★★★
()
Ответ на: комментарий от LINUX-ORG-RU

Ссылка на проект кривая

Странно, во всех доступных мне браузерах открывается, специально проверил. Может КВН виноват?

wirewalk
() автор топика
Ответ на: комментарий от LINUX-ORG-RU

Это так не работает

Спасибо, учту, изучу вопрос.

wirewalk
() автор топика
Ответ на: комментарий от maxcom

Если заменить Ахо-Корасик на Vectorscan

Прикольно. Поизучаю на досуге, спасибо за наводку.

wirewalk
() автор топика
Ответ на: комментарий от maxcom

Глянул (одним глазом), «Vectorscan/Hyperscan (Intel) - SIMD-ускоренный regex-движок, до 10 Гбит/с. Тяжёлый, C++, x86-only (Vectorscan портирован на ARM)» - для нашего ультралайт проекта это, пожалуй, оверкилл.
Сначала я вообще склонялся к strncmp switch. Остановило то, что это уж как-то слишкои грубо, без эстетики.

wirewalk
() автор топика

Необычная терминология. А что, по-русски действительно header называют конвертом? Я привык к термину «заголовок».

VIT ★★
()

Зачем четвёртая стадия? Представьте: вы общаетесь с сайтом. Это диалог - десятки пакетов туда-сюда. Если мы определили протокол по первому пакету, незачем повторять анализ для каждого следующего.

А можно это использовать, маскируя реальную передачу несколькими dummy для установки некорректной начальной идентификации?

VIT ★★
()
Ответ на: комментарий от VIT

Необычная терминология.

Это не терминология, это «объяснялка на знакомом примере». «Заголовок» - это, конечно, был бы точный перевод.

wirewalk
() автор топика
Ответ на: комментарий от VIT

А можно это использовать, маскируя реальную передачу несколькими dummy для установки некорректной начальной идентификации?

Какой хороший вопрос, хмм…

Именно так работают DPI-evasion техники: первый пакет формируется так, чтобы совпасть с «безопасной» сигнатурой (например, HTTP «GET /»), а все последующие несут полезную нагрузку. DPI наследует классификацию первого пакета и пропускает весь поток как HTTP.

Защита от этого:

  • Периодическая реклассификация (перепроверять поток каждые N пакетов)
  • Проверка на «ожидаемое поведение» протокола (HTTP-поток с бинарными данными после заголовка - подозрителен)
  • DPI второго поколения (nDPI, PACE) используют поведенческий анализ: размеры пакетов, тайминги, направление - а не только payload первого пакета

В нашем учебном проекте я сознательно упростил подход. В production это была бы реальная уязвимость.

wirewalk
() автор топика
Ответ на: комментарий от dataman

«Чуть» подправил форматирование, но надо будет ещё пару раз пройтись. :)

Спасибо! Надеюсь сильно не накосячил, старался культурно оформить.

wirewalk
() автор топика

Алгоритм Ахо-Корасик ищет все сигнатуры за один проход по payload.

Может быть, пригодится https://github.com/morenice/ahocorasick:

C implementation Aho-Corasick string match.

  • Support multi-thread
  • Support callback match API
  • Support multiple languages(english, korean, …)
  • example: See src/main.c
dataman ★★★★★
()
Ответ на: комментарий от CrX

Очень похоже. Не как целиком сгенерированное, а как переработанное. Причём readme в репе, который во многом совпадает с этой статьёй по смыслу - выглядит сильно лучше. Подозреваю автор решил перед публикацией на лоре «улучшить» статью. Или, может, куски к ней дописывал через них.

firkax ★★★★★
()
Последнее исправление: firkax (всего исправлений: 4)

Как гласит народная мудрость: каждый программист обязан понимать, как устроены языки и компиляторы, а также DPI.

kaldeon ★★
()
Ответ на: комментарий от firkax

У README и статьи общий предок, было бы странно если бы они не были похожи. Статья - это некоторый компромисс между сухим README и формальным FAQ.

wirewalk
() автор топика
Ответ на: комментарий от kaldeon

С компиляторами, к слову, не сложилось. Всегда было интересно, но практическая сторона вопроса, в смысле программирование, манила к себе больше.

wirewalk
() автор топика

очень-очень давно делал что-то похожее при помощи divert сокетов во freebsd 4.x

правда, не совсем понятно, зачем парсить пакеты при помощи C, когда уже есть готовая кодовая база wireshark на lua

gagarin0
()
Последнее исправление: gagarin0 (всего исправлений: 1)
Ответ на: комментарий от wirewalk

ты не только сам пост нейронкой написал, но еще и ответы ей спамишь.

Некоторые тут уже совсем охренели, как я посмотрю.

Lrrr ★★★★★
()
Ответ на: комментарий от wirewalk

Речь не про то что они похожи, а про то, что в статье, в отличие от readme, имеются следы использования чатгпт или подобных сервисов, что выглядит ужасно.

firkax ★★★★★
()
Ответ на: комментарий от firkax

имеются следы использования чатгпт

караул ! вызывайте интернет полицию!

gagarin0
()
Ответ на: комментарий от gagarin0

зачем парсить пакеты при помощи C

Сейчас компы много чего умеют, но крафтить своими руками это особый кайф, как и сто лет назад. Это вообще философский вопрос - зачем бегать, если на велике быстрее? Не знаю зачем.
Just For Fun.

wirewalk
() автор топика

Вопросы, замечания, идеи? — Круто, буду рад вас услышать.

  1. У тебя сетевые пакеты разбираются в одном потоке? И следующий сетевой пакет не начинает разбираться пока недоразобран предыдущий пакет? Если это так, то разборщик пакетов очень быстро «загнется» даже на чистом гигабите в секунду.

  2. Зачем ты напридумывал какие-то хэши IP-адресов и сетевых портов? В чем сложность сравнить все эти значения друг за другом? Эти данные приходят в числовом виде, а не строковом и поэтому сравнение будет быстрее, чем вычисление твоего хэша перемножениями.

Enthusiast ★★★★
()
Ответ на: комментарий от Lrrr

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

VIT ★★
()
Ответ на: комментарий от Enthusiast

Зачем ты напридумывал какие-то хэши IP-адресов и сетевых портов? В чем сложность сравнить все эти значения друг за другом?

это не он напридумывал, а нейронка. Его спрашивать бесполезно - очевидно что никакой предметной областью он не интересуется и просто крафтит себе резюме. Или курсовую какую. Репа на гитфлике намекает.

Lrrr ★★★★★
()
Ответ на: комментарий от CrX

На бредогенератор как раз не особо похоже.

Бхххггггггг о да, а знаешь, что еще не похоже на бредогенератор? Вот это.

thesis ★★★★★
()

Вношу предложение о переименовании раздела «статьи».

Угадайте во что.

thesis ★★★★★
()
Ответ на: комментарий от Enthusiast
  1. Думаешь есть риск захлебнуться на гигабите, после всех оптимизаций? Сомневаюсь. Реально узкое место - libpcap. Но если судить по tcpdump и Suricata - норм фигачит в однопоточном режиме, одно ядро на четверть грузит (приблизительно). Итого - многопоточнности нет, и реализовывать смысла не вижу. Для пет-роекта очевидный оверкилл.
  2. Сравнение 5 полей в лоб, по таблице - O(N). Хэш + один бакет - O(1). При тысячах одновременных потоков разница между «проверить 5000 кортежей» и «вычислить один хэш + проверить 1-2 записи в бакете» - существенная, даже учитывая стоимость умножений. Хэш-таблица для flow lookup - это просто стандарт в сетевом ПО (kernel, nDPI, Suricata).
wirewalk
() автор топика
Ответ на: комментарий от Lrrr

Это начало будущего. Авторы, модераторы, пользователи - все агентные нейронки. И админы тоже. И хостер - нейронка. Все, все нейронки. Планета Земля. Населена роботами.

praseodim ★★★★★
()
Ответ на: комментарий от praseodim

Авторы, модераторы, пользователи - все агентные нейронки. И админы тоже. И хостер - нейронка. Все, все нейронки.

Всё, расходимся :)

wirewalk
() автор топика

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

IvanRia
()
mkdir -p build
cc -std=c11 -Wall -Wextra -O2 -g -Iinclude -c -o build/aho_corasick.o src/aho_corasick.c
cc -std=c11 -Wall -Wextra -O2 -g -Iinclude -c -o build/capture.o src/capture.c
cc -std=c11 -Wall -Wextra -O2 -g -Iinclude -c -o build/classify.o src/classify.c
cc -std=c11 -Wall -Wextra -O2 -g -Iinclude -c -o build/flow.o src/flow.c
src/flow.c:128:23: error: use of undeclared identifier 'AF_INET'
  128 |             inet_ntop(AF_INET, &sa, src_ip, sizeof(src_ip));
      |                       ^
src/flow.c:129:23: error: use of undeclared identifier 'AF_INET'
  129 |             inet_ntop(AF_INET, &da, dst_ip, sizeof(dst_ip));
      |                       ^
2 errors generated.
gmake: *** [Makefile:30: build/flow.o] Error 1

FreeBSD-15.0-RELEASE

IPR ★★★★★
()
Ответ на: комментарий от IPR

Поправил, запушил. На FreeBSD <arpa/inet.h> не подтягивает <sys/socket.h>, в отличие от glibc на Linux. Добавил явный #include <sys/socket.h> в flow.c — теперь должно собираться и там и там.

wirewalk
() автор топика
Ответ на: комментарий от wirewalk
./tiny-dpi-engine -i nfe0 -n 1000
tiny-dpi-engine
Loading signatures from: signatures/default.sig
Loaded 34 signatures
Aho-Corasick: 179 nodes
Capturing on nfe0 (max 1000 packets, Ctrl+C to stop)...

=== DPI Statistics ===
Total packets:   966
Total bytes:     67549
Classified:      522
Unclassified:    444
Active flows:    16
Classification:  54.0%

Per-protocol breakdown:
  HTTP       10
  SSH        512

SRC IP             PORT   -> DST IP             PORT    PROTO       PACKETS      BYTES  SIG
------------------------------------------------------------------------------------------
10.0.0.200         57813  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.199         22     -> 10.2.2.1           40174   SSH             188      13244
10.0.0.200         15196  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.199         22     -> 10.2.2.1           28456   SSH             324      22288
10.0.0.200         54965  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.9           5353   -> 224.0.0.251        5353    unknown           4        921  sig=0
10.0.0.105         5353   -> 224.0.0.251        5353    unknown           4        930  sig=0
10.0.0.36          61134  -> 255.255.255.255    6667    unknown           5       1000  sig=0
10.0.0.200         48919  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.200         30685  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.200         20644  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.200         51676  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.200         62972  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.200         16555  -> 239.255.255.250    1900    HTTP              1        165  sig=7
10.0.0.199         48579  -> XXX.XXX.XXX.XXX    1308    unknown         431      60708  sig=0
10.0.0.200         42123  -> 239.255.255.250    1900    HTTP              1        165  sig=7

Работает, спасибо. Если это кто-то в порты засунет, это будет победа.

// Респект тебе, парень!

IPR ★★★★★
()
Ответ на: комментарий от IvanRia

вы это писали исключительно из интереса … ?

Не знаю хорошо это или плохо, но интерес это драйвер практически всех моих действий. Без интереса - не интересно. И, как следствие, без интереса не хватает «тяги», «лошадиных сил». Поэтому, объективно, как бы ни было что-то там правильно, или похвально, или еще как-нибудь - этого недостаточно для того, чтобы я этим занялся.
Так чтобы конкретно на госслужбу - нет, не думал об этом. Мне всегда казалось, что уж где-где, а там бойцов всегда хватает. (И все такие рослые, здоровенные, кровь с молоком, с холодной головой и горячим сердцем, не чета нам, простым ребятам :))
Мысль насчет того, что этот интерес может быть кем-то востребован и даже оплачен - да, есть такое. Но, опять же, без конкретных планов. Бросаю камешки и смотрю, как круги бегут по воде.

wirewalk
() автор топика
Ответ на: комментарий от wirewalk

понятно, все нормально, я не ради позлорадствовать, но мне не понятно, вроде люди умные, где ум там и сообразительность, но ведь кто-то же разрабатывает эти фильтры и их родные и знакомые страдают от неудобств, как они потом им в глаза смотрят, я бы не смог, а ради интереса смог бы

IvanRia
()
Ответ на: комментарий от IvanRia

А что, умные и сообразительные люди питаются как-то по особенному и жить им можно прямо на рабочем месте?

VIT ★★
()
Ответ на: комментарий от Lrrr

Да нет, техническая часть статьи (в основном та, что из readme переехала) выглядит вменяемо. Ужас конкретно в добавленном зачем-то нейрослопе уже после.

firkax ★★★★★
()
Ответ на: комментарий от wirewalk

Не надо всё сводить к клоунаде, у тебя в статье действительно нейрослоп и выглядит это отвратительно. Зачем ты его туда добавил?

firkax ★★★★★
()
Для того чтобы оставить комментарий войдите или зарегистрируйтесь.