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)
Ответ на: комментарий от firkax

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

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

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

что только не придумают, лишь бы ограничить что-то.

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

Что эта штука будет делать с траффиком от ByeDPI?

Ничего особенного. Движок классифицирует его как TLS/HTTPS по первым байтам (0x16 0x03 xx) – и на этом всё.
ByeDPI работает за счёт фрагментации TLS ClientHello на два TCP-сегмента: в первом – заголовок TLS без SNI, во втором – остаток с SNI. Наш движок не делает TCP reassembly, поэтому:
1. Первый сегмент: видит 0x16 0x03 -> классифицирует как TLS
2. Второй сегмент: тот же flow (stateful) -> наследует TLS
3. SNI не видит – но он его и не пытается извлечь
Движок определяет протокол, а не содержимое. ByeDPI бьёт именно по содержимому (SNI), а не по классификации. Если бы мы добавили глубокий парсинг TLS (выдернуть SNI для блокировки) – вот тогда без TCP reassembly ByeDPI бы нас пробил.

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

Но внутреннего детектора нейрослопа у меня нет,

Хочешь сказать что ты полностью сам каждое слово и каждую букву в ней написал, не используя никакие чатгпт и аналоги?

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

Хочу сказать ровно то, что сказал - мне доводилось читать разные статьи. И всегда было странно читать про то, что для кого-то это «нейрослоп» а кому-то норм. Лично я не разбираюсь, не ощущаю. Да и не интересно мне это, если честно. Если содержание меня цепляет - читаю. Не цепляет - да будь хоть сто раз продукт аутентичного естественного интеллекта, прямо от сохи (или откуда там) - мне на это все равно.

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

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

Всегда пожалуйста, рад если было интересно.

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

Спасибо за работу, очень интересно.)

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

DPI многими провайдерами используется для бытовых задач, например для QoS, это не только про блокировки. В частности в РФ давно перешли на ковровые блокировки по IP, потому что DPI не справляется толком с определением XTLS.

Gary ★★★★★
()

Тут лойс можно поставить только за одно «обьяснение».

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